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

- URL: https://docs.mentoragent.net/configure/any-front-end
- Verified with: MentorAgent 1.0.0
- Last updated: 2026-10-14
- Packages: Novior.MentorAgent.Server, Novior.MentorAgent

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](https://docs.mentoragent.net/configure/a2a-server) and [MCP](https://docs.mentoragent.net/configure/mcp-server).

## When to choose it

Choose it when the front end is not Blazor, or when another client, such as a mobile or console app, needs a backend without a user interface. You build the chat UI yourself, or start from the components on this page. For a Blazor app, with the ready-made widget, see [Blazor Server](https://docs.mentoragent.net/configure/blazor-server), [Blazor WebAssembly and Auto](https://docs.mentoragent.net/configure/blazor-webassembly) or [.NET MAUI](https://docs.mentoragent.net/configure/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

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

**.NET CLI**

```shell
dotnet add package Novior.MentorAgent.Server
```

**PackageReference**

```xml title="YourApp.csproj"
<ItemGroup>
  <PackageReference Include="Novior.MentorAgent.Server" Version="1.0.0" />
</ItemGroup>
```

**Central Package Management**

```xml title="Directory.Packages.props"
<ItemGroup>
  <PackageVersion Include="Novior.MentorAgent.Server" Version="1.0.0" />
</ItemGroup>
```

```xml title="YourApp.csproj"
<ItemGroup>
  <PackageReference Include="Novior.MentorAgent.Server" />
</ItemGroup>
```

**File-based app**

```csharp title="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:

```shell
dotnet add package Microsoft.Extensions.AI.OpenAI --version 10.6.0
```

For another provider, see [Model providers](https://docs.mentoragent.net/configure/model-providers#install-the-packages).

In the JavaScript project, for the hub:

```shell
npm install @microsoft/signalr
```

Server-Sent Events need no library. Clients in other languages are in [Other clients](https://docs.mentoragent.net/configure/any-front-end#other-clients).

## Minimal configuration

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

   ```shell
   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](https://docs.mentoragent.net/get-started/quickstart#describe-what-the-agent-can-do) and [Register the service and the model](https://docs.mentoragent.net/get-started/quickstart#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:

   ```csharp title="Program.cs"
   using Novior.MentorAgent.Extensions;          // AddMentorAgent
   using Novior.MentorAgent.Server.Extensions;   // AddMentorAgentServer, MapMentorAgentServer
   using Microsoft.Extensions.AI;         // AsIChatClient
   using OpenAI;                          // OpenAIClient
   ```

   ```csharp title="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](https://docs.mentoragent.net/configure/model-providers).

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

### Connect the client

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:

```tsx title="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:

```ts title="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}` } : {};
  }
}
```

```ts title="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:

```vue title="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:

```js title="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}` } : {};
  }
}
```

```js title="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](https://docs.mentoragent.net/configure/any-front-end#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](https://learn.microsoft.com/aspnet/core/security/data-protection/configuration/overview), 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](https://www.nuget.org/packages/Novior.MentorAgent.Server#readme-body-tab).

### The endpoints

`MapMentorAgentServer()` maps these endpoints:

| Endpoint | What it does |
|---|---|
| `/mentor-hub` | The SignalR hub: messages, streaming and every event |
| `GET /mentor/chat` | One turn over SSE, with the message in the query string |
| `POST /mentor/chat` | The same, with a JSON body that can carry images |
| `POST /mentor/approve` | The answer to a confirmation: `?actionId=…&approved=true` or `false`. It answers `404` when no turn waits for it any more |
| `POST /mentor/cancel` | Stop, for a hub connection: `?connectionId=…` |
| `GET` and `POST /mentor/session` | Save and restore a hub connection's conversation: `?connectionId=…` |
| `GET /mentor/tour` | The onboarding tour steps, as JSON |
| `GET /mentor/admin/metrics` | The token and cost snapshot, for an operator |

The MCP and A2A endpoints are mapped by their own calls: see [MCP server](https://docs.mentoragent.net/configure/mcp-server#minimal-configuration) and [A2A server](https://docs.mentoragent.net/configure/a2a-server#minimal-configuration). `AddMentorAgentServer()` registers both when their options are on.

## Complete configuration

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

```csharp title="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.

```tsx title="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:

```ts title="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:

```vue title="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()`:

```js title="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](https://docs.mentoragent.net/configure/any-front-end#security-and-roles).

### Options that matter here

| Option | Why it matters for a JavaScript client |
|---|---|
| [`AnonymousIdentity`](https://docs.mentoragent.net/reference/options#anonymousidentity) | With the default, `PerSession`, every hub connection and every SSE request is a different anonymous user |
| [`RateLimitPerUser`](https://docs.mentoragent.net/reference/options#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`](https://docs.mentoragent.net/reference/options#usememorycontext), [`MemoryAutoCapture`](https://docs.mentoragent.net/reference/options#memoryautocapture) | With either on and `/mentor/chat` open to anyone, MentorAgent logs a warning at startup. `MemoryAutoCapture` is on by default |
| [`EnableActionFeedback`](https://docs.mentoragent.net/reference/options#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`](https://docs.mentoragent.net/reference/options#enablegenerativecards) | Sends cards as the `Cards` event or the `cards` frame. The model does not repeat their content in text, so render them |
| [`EnableRichResponses`](https://docs.mentoragent.net/reference/options#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`](https://docs.mentoragent.net/reference/options#enableimageinput) | Accepts images on `SendMessage` and `POST /mentor/chat`. The hub's message size limit is raised to fit |
| [`MaxHostedToolCallsPerSession`](https://docs.mentoragent.net/reference/options#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`](https://docs.mentoragent.net/reference/options#dashboardrole) | The role `GET /mentor/admin/metrics` requires. An empty value opens it: development only |
| [`Theme`](https://docs.mentoragent.net/reference/options#theme), [`BotName`](https://docs.mentoragent.net/reference/options#botname), [`EnableSuggestions`](https://docs.mentoragent.net/reference/options#enablesuggestions) and the other widget options | No effect: your client draws its own UI |

Every option is in [Configuration options](https://docs.mentoragent.net/reference/options).

## Other clients

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.

### Native apps and other languages

Microsoft supports four SignalR clients. The minimum versions of each are in [SignalR supported platforms](https://learn.microsoft.com/aspnet/core/signalr/supported-platforms):

| Client | Package | Where it runs |
|---|---|---|
| [JavaScript](https://learn.microsoft.com/aspnet/core/signalr/javascript-client) | `@microsoft/signalr`, on npm | Current browsers, on Android and iOS too, and Node.js |
| [.NET](https://learn.microsoft.com/aspnet/core/signalr/dotnet-client) | `Microsoft.AspNetCore.SignalR.Client`, on NuGet | Any platform ASP.NET Core supports; .NET MAUI apps use it on Android and iOS |
| [Java](https://learn.microsoft.com/aspnet/core/signalr/java-client) | `com.microsoft.signalr:signalr`, on Maven | Java applications, Android apps included |
| [Swift](https://learn.microsoft.com/aspnet/core/signalr/swift-client) | `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](https://docs.mentoragent.net/configure/maui#as-a-client-of-a-server) 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.

### 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`:

```shell
curl -N "https://example.com/mentor/chat?message=How%20many%20cases%20are%20open%3F"
```

Each frame is a `data:` line with a JSON object:

```text
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](https://docs.mentoragent.net/reference/glossary#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.

### Other agents and tools: A2A and MCP

An AI agent or tool does not need a chat client:

- **Agent-to-Agent (A2A).** Publish the whole agent with the [A2A server](https://docs.mentoragent.net/configure/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](https://docs.mentoragent.net/configure/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](https://modelcontextprotocol.io/docs/sdk) and for [A2A](https://a2a-protocol.org/latest/sdk/). The A2A server and MCP server pages cover authentication, and what happens to the actions that need a confirmation.

## Security and roles

- **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](https://docs.mentoragent.net/configure/secure-endpoints#what-each-endpoint-exposes).
- **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](https://docs.mentoragent.net/configure/secure-endpoints#security-and-roles). 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](https://docs.mentoragent.net/configure/secure-endpoints#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](https://docs.mentoragent.net/configure/secure-endpoints#security-and-roles).
- **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](https://docs.mentoragent.net/concepts/governance#where-nobody-can-answer).

## Common errors

- **"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`.

---

- Previous: [.NET MAUI (Blazor Hybrid)](https://docs.mentoragent.net/configure/maui.md)
- Next: [MCP server](https://docs.mentoragent.net/configure/mcp-server.md)
