MentorAgent Docs
Menu

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

Production checklist

Settings to review before you go to production: governance defaults, safety check, rate limits, cost and routing, history, observability, and outgoing data.

View as Markdown
On this page

Go through this list before real users reach your agent, and again after each upgrade of MentorAgent. Each item says what to check and where it is explained; the complete configuration at the end puts the options together.

  • Every endpoint you map requires authentication. The hub, /mentor/chat and /a2a run turns on your model, and /mcp runs your actions directly. See Secure the endpoints. A Blazor Server app that maps none of them can skip this item.
  • Blazor Server has a signed-in user. Without authentication every user is anonymous and every action with RequiredRoles is refused: see Blazor Server app.
  • AnonymousIdentity stays PerSession on any application with more than one user. Shared puts every anonymous user on one memory and one rate-limit count.
  • The admin views are protected. With Novior.MentorAgent.Server, DashboardRole guards GET /mentor/admin/metrics. The cost dashboard component, MentorDashboard, checks nothing: put it on a page with [Authorize(Roles = "Admin")].
  • CORS lists your real origins. The /mcp endpoint has no Cross-Origin Resource Sharing (CORS) policy by default, which suits desktop and IDE clients. If a browser-based client calls it, define the MentorAgentMcp policy with your origins and apply it with RequireCors: see MCP server.
  • Every action is declared. Declare ReadOnly = true on an action that only reads. A [MentorAction] that declares neither ReadOnly = true nor RequiresConfirmation counts as a write and asks for confirmation. Write RequiresConfirmation = false only on a write that must run unasked. Give RequiredRoles to every action meant for some users only: roles are open by default. See Two checks, in this order.
  • RequireConfirmationForWrites stays true. With false, only RequiresConfirmation = true asks.
  • A method with only [Description] asks for nothing. It has no roles and no confirmation, and RequireConfirmationForWrites does not cover it. Mark a method that changes data with [MentorAction].
  • Read the warning about writes. When MentorAgent first reads your classes, it logs a warning with two lists of writes that run without confirmation: the [MentorAction] methods that opted out, and the methods with only [Description] that are not declared read-only. Treat each name as a decision. Confirmation by default explains when the warning appears.
  • Tools you cannot annotate are gated by name with RequiresApproval: see Tools you cannot annotate.
  • RequireConfirmation stays true. false turns off the confirmation boxes of your actions, agents and gated tools. The only approvals that still appear are those for the MCP servers that the model provider calls directly (HostedMcpServers).
  • IncludeWorkflowExceptionDetails stays false. Set to true, it puts exception messages from specialists and teams in the reply the user reads.
  • Your methods still filter records per user. Roles decide who may call an action, not which records it returns: see What stays in your code.
  • MaxMessageLength refuses long messages before the model is called.
  • RateLimitPerUser is off by default. It counts messages per signed-in user within RateLimitWindowSecs. The user is the NameIdentifier claim, else sub, oid or the name. MCP tool calls count too, in an allowance of their own. A caller with no NameIdentifier, sub or oid claim and no name is counted by remote address when IHttpContextAccessor is registered (AddMentorAgentServer() and AddMentorAgentA2A() register it), and otherwise shares one allowance with all such callers. The limit does not bound anonymous chat users or unidentified A2A tasks: Secure the endpoints adds a limit on the endpoints.
  • EnableSafetyCheck checks each message with one classifier call, for prompt injection, jailbreaks and attempts to extract the system prompt or credentials. Point ClassifierChatClient at a small, fast model.
  • SafetyCheckFailure decides what happens when a check gives no verdict, after a timeout or a provider error. Allow, the default, answers and logs a warning; Block refuses the message. Choose Block when a message the check could not screen must never reach the model; Allow keeps the agent available when the classifier is down.
  • EnableOutputSafetyCheck and RefuseOutOfScope are further checks on the reply and on the topic. The output safety check holds the whole reply until it is checked, so the answer no longer streams. RefuseOutOfScope refuses only a message that plainly has nothing to do with your application, and answers when unsure. Each refusal counts in the dashboard's guardrail blocks.
  • OnException maps an exception to the message that the user reads. When you set it, the chat never shows the text of a tool's exception. It shows your message, or "The action could not be completed." if the hook returns none. The model receives your message too. If the hook returns none, the model receives the text of the exception and can repeat it in its reply. Return a message for every exception whose text must not reach the user.
  • ClassifierChatClient runs MentorAgent's own short decisions: safety checks, the off-topic check, the facts to remember, and the question behind the two Require… options below. A small model makes them cheaper and faster.
  • StrongChatClient with a RoutingStrategy sends only the hard questions to a stronger model. Custom, the default, needs your router in UseStrongModelAsync or UseStrongModel. Semantic needs an EmbeddingGenerator. Classifier, used below, needs nothing more: one small call per turn, on RoutingClassifierClient or else ChatClient, decides whether the turn needs the strong model. If the strategy lacks what it needs, MentorAgent logs a warning and every turn stays on ChatClient.
  • EnableToolFiltering sends only the relevant tools, and needs an EmbeddingGenerator. It recognizes the actions that change data by their confirmation. When no action is relevant enough, it sends the three best-ranked actions that need no confirmation and no role. When every action it selects needs confirmation, it also sends up to three that do not. Both stay within ToolFilterMaxTools. So the items on confirmation above matter here too: an action that changes data and asks for no confirmation can reach the model on any message. See EnableToolFiltering.
  • EnableCompaction shrinks long conversations before each call. It changes what is sent to the model, never what is stored. It works with ChatClient and a history kept by your application, and it is not installed with UseServiceManagedHistory.
  • WarmUpAtStartup does the shared setup work, such as tool embeddings and remote agent cards, once the host has started and is listening, so the first user does not wait for it. A failed warm-up never stops the app. An invalid configuration does, because the options are validated at start. A .NET MAUI app runs no hosted service, so there the option does nothing.
  • ModelPricing turns the dashboard's token counts into costs. MentorAgent ships no prices. Enter them under the model id that the dashboard shows; on Azure OpenAI, that id is your deployment name. The counts live in memory unless you register an IMentorMetricsStore before AddMentorAgent().

The providers and the embedding model are set up in Model providers.

Two options make the agent call an action before it says anything. Both are off by default. Both need EnableToolFiltering, an EmbeddingGenerator and a ChatClient, and each adds one small classifier call to most turns. With both on, one call serves both.

  • RequireLookupForRecordQuestions makes the agent read a value before it answers a question about your records. Without it, a question about a price can still be answered with an offer to look the value up. Declare your reads first, with ReadOnly = true or IsReadOnlyAction: the option requires only those.
  • RequireActionForChangeRequests makes the agent call an action that needs confirmation before it says anything, when the message asks for it and gives every input. Without it, the model can answer "Stock updated" with no tool called: no box appears, and nothing changes. Keep RequireConfirmation on, because only actions that show a box are required. Every [MentorAction] that asks for confirmation can be required, the writes that ask by default included.
  • The history is in memory by default, one per circuit or connection, and a restart loses it.
  • To keep it, use ChatHistoryProviderFactory. It builds one history provider per circuit, hub connection or app run. It receives that scope's IServiceProvider, so your history provider can store each user's history separately. Setting ChatHistoryProvider instead gives one instance to every user of the process.
  • Your history provider is not trimmed. MaxSessionMessages and ChatReducer apply only to the built-in history. Give your history provider its own limit. MentorChatReducer, in the Novior.MentorAgent.Core namespace, is the reducer that MentorAgent uses by default: it keeps every tool call together with its result and cuts whole turns. A ChatReducer set next to your own provider, or with UseServiceManagedHistory, is not used, and a warning says so.
  • The built-in history counts every message. Tool calls and their results count against MaxSessionMessages and stay in the history, so the model still sees the data that a tool read on the next turn. A session holds fewer turns for the same limit. Set ChatReducer to use another reducer.
  • Memories are in memory too. With UseMemoryContext, register your own IMentorMemoryStore before AddMentorAgent(). The built-in store loses everything on restart, and each server instance has its own. A signed-in user is keyed by the NameIdentifier claim, else by sub, oid or the name. A key from a claim other than NameIdentifier carries the name of the claim, such as sub:…. ClearAsync on the store is where you erase a user's data.
  • Your history provider keeps the transcript in the session, not in a field. One history provider serves a circuit or connection for its whole life. The widget's reset button, the hub's ResetSession and ResetSessionAsync start a new conversation, which is a new session, not a new history provider. A history provider that keeps the messages in a field carries the previous conversation into the new one.
  • A saved conversation is the conversation in clear. SerializeSessionAsync returns it as it is, and the model trusts a restored one as history. Keep it on the server, or in ProtectedLocalStorage, never as text that a user can read or edit. GET /mentor/session returns a protected snapshot instead, bound to the signed-in user. To restore it after a restart, or on another instance, persist and share the ASP.NET Core Data Protection key ring, as you do for authentication cookies.

In a history provider of your own, keep the messages in the session's StateBag, as the built-in provider does. Agent Framework's ProviderSessionState<T> does the bookkeeping:

CaseHistoryProvider.cs
using Microsoft.Agents.AI;       // ChatHistoryProvider, ProviderSessionState<T>
using Microsoft.Extensions.AI;   // ChatMessage, AIJsonUtilities
public sealed class CaseHistoryProvider : ChatHistoryProvider
{
    // The transcript lives in the session's StateBag, not in a field: a new conversation is a new session.
    private readonly ProviderSessionState<List<ChatMessage>> _transcript =
        new(_ => [], nameof(CaseHistoryProvider), AIJsonUtilities.DefaultOptions);

    protected override ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(
        InvokingContext context, CancellationToken cancellationToken = default)
        => ValueTask.FromResult<IEnumerable<ChatMessage>>([.. _transcript.GetOrInitializeState(context.Session)]);

    protected override ValueTask StoreChatHistoryAsync(
        InvokedContext context, CancellationToken cancellationToken = default)
    {
        var history = _transcript.GetOrInitializeState(context.Session);
        history.AddRange(context.RequestMessages);
        history.AddRange(context.ResponseMessages ?? []);
        _transcript.SaveState(context.Session, history);
        return ValueTask.CompletedTask;
    }
}

Register it with the factory, so that each circuit and connection gets its own provider:

Program.cs
options.ChatHistoryProviderFactory = _ => new CaseHistoryProvider();

A history provider backed by a database follows the same rule for the key of its rows: keep the key in the session, for example as a conversation id in the StateBag, not in a field of the history provider.

  • EnableObservability emits OpenTelemetry traces and metrics. The model calls follow the OpenTelemetry conventions for generative AI (GenAI). MentorAgent adds a mentor.turn span per turn, a mentor.tool.{toolName} span per tool call, a mentor.mcp.tool.{toolName} span per call to the MCP server, and four counters: mentoragent.turns, mentoragent.tool_calls, mentoragent.errors and mentoragent.guardrail_blocks, which includes the refusals of RefuseOutOfScope.
  • Your application exports them. Add the source and the meter named by ObservabilitySourceName, MentorAgent by default, to your OpenTelemetry tracing and metrics.
  • ObservabilityIncludeSensitiveData stays false outside development: it puts prompts and replies in the telemetry.
  • The logs. MentorAgent logs under categories that start with Novior.MentorAgent. At Debug level they show what each session was built with and which model routing chose. Each MCP tool call writes one Information line, with a fingerprint of the caller and never the caller's ID. A reset of the conversation writes one Information line too. A refused role check names the user by a fingerprint (caller#…), never by name, and a failing tool is logged by the type of its exception. The whole exception is logged at Debug.

What your deployment sends to other services, for your privacy notice and your security review:

  • The model provider receives the conversation and its context:

    • the user's messages and the replies;
    • the system prompt, with the address and name of the current page, the signed-in user's name and roles, and the data the page hands over through IMentorPageContext;
    • the results of your actions, that is, the data your methods read, from your database too;
    • when you turn them on, retrieved documents and the user's memories.
    • with RequireLookupForRecordQuestions or RequireActionForChangeRequests, the classifier model also receives the names, descriptions and required inputs of your actions and the descriptions of your pages.

    With a local model, none of this leaves your network: see Ollama and other local models.

  • The remote systems you configure receive what is handed to them. The MCP servers in McpServers receive the tool calls, and the A2A agents in RemoteAgents the requests delegated to them. The servers in HostedMcpServers are called by the provider, with the headers you set.

  • Nothing goes to the authors of MentorAgent. The library sends no telemetry of its own. Traces and metrics go only to the exporter you configure, and carry the text of prompts and replies only with ObservabilityIncludeSensitiveData.

  • The widget requests nothing from a third party. The package ships its two fonts, Sora and JetBrains Mono, so the stylesheet loads no file from another origin, and the widget works offline.

  • Voice input goes through the browser. With EnableVoiceInput, off by default, the widget uses the browser's speech recognition. In Chrome, for example, the browser sends the audio to its vendor's recognition service. Only the transcript reaches MentorAgent.

With the default options, the widget works under a policy with default-src 'self' and without 'unsafe-inline'. It loads its fonts and images from your own origin. Two things need style-src 'unsafe-inline':

  • Colors. Theme = MentorTheme.Dark or MentorTheme.Minimal, and PrimaryColor, write their colors in a <style> block. Without 'unsafe-inline' the browser drops the block and the widget keeps its default colors. MentorTheme.Custom without a PrimaryColor needs nothing, because its colors are in your own stylesheet.
  • The dashboard. The legend and the bars of <MentorDashboard /> use style attributes.

A WebAssembly client has two more requirements that come from Blazor, not from MentorAgent: script-src 'wasm-unsafe-eval' for the .NET runtime and, in a Blazor Web App, the inline import map that <ImportMap /> renders. See the Content Security Policy guidance in the ASP.NET Core Blazor documentation (external site).

  • Keys come from a secret store or environment variables. User secrets load only in the Development environment. On Azure, Entra ID with a managed identity removes the key: see Model providers.
  • Public URLs are the production ones, such as A2AServerUrl: see A2A server.

The options from this list in one registration, next to the endpoint protection of Secure the endpoints:

Program.cs
using Novior.MentorAgent.Extensions;   // AddMentorAgent
using Novior.MentorAgent.Options;      // MentorRoutingStrategy, MentorSafetyCheckFailure
using Microsoft.Extensions.AI;  // AsIChatClient
using OpenAI;                   // OpenAIClient
Program.cs
var openAI = new OpenAIClient(builder.Configuration["OpenAI:Key"]
    ?? throw new InvalidOperationException("Set OpenAI:Key in a secret store or an environment variable."));

builder.Services.AddMentorAgent(options =>
{
    options.AppName = "Case management";
    options.AppDescription = "Support case management: case status, notes and closure";
    options.ChatClient = openAI.GetChatClient("gpt-4o-mini").AsIChatClient();
    options.ScanAssemblies = [typeof(Program).Assembly];

    // Governance: these are the defaults, written out so nobody changes them by accident
    options.RequireConfirmation = true;
    options.RequireConfirmationForWrites = true;
    options.IncludeWorkflowExceptionDetails = false;

    // Limits, counted per signed-in user
    options.MaxMessageLength = 2000;
    options.RateLimitPerUser = 20;
    options.RateLimitWindowSecs = 60;

    // Safety checks, on a small and fast model
    options.EnableSafetyCheck = true;
    options.ClassifierChatClient = openAI.GetChatClient("gpt-4.1-mini").AsIChatClient();
    options.SafetyCheckFailure = MentorSafetyCheckFailure.Allow; // no verdict: answer and log a warning

    // Cost: a stronger model only for the hard questions, and shorter histories
    options.StrongChatClient = openAI.GetChatClient("gpt-4o").AsIChatClient();
    options.RoutingStrategy = MentorRoutingStrategy.Classifier;
    options.EnableCompaction = true;
    options.WarmUpAtStartup = true;

    // OpenTelemetry: traces and metrics under the "MentorAgent" source and meter
    options.EnableObservability = true;
    options.ObservabilityIncludeSensitiveData = false;   // prompts and replies stay out of telemetry
});
OptionWhy it matters in production
RequireConfirmation, RequireConfirmationForWrites, IncludeWorkflowExceptionDetailsDefaults that keep the confirmation boxes on, make writes ask, and keep exception details out of replies
MaxMessageLength, RateLimitPerUser, RateLimitWindowSecsBound what one user can send and spend
EnableSafetyCheck, ClassifierChatClient, SafetyCheckFailureScreen each message, on a fast model, with a decision for the case without a verdict
StrongChatClient, RoutingStrategy, EnableCompaction, WarmUpAtStartupPay for the strong model only when needed, and send fewer tokens
EnableObservability, ObservabilityIncludeSensitiveDataTraces and metrics, without the text of the conversations

Every option is in Configuration options, and the limits under Security and limits.

  • The key is not found once deployed. User secrets are not loaded outside Development. Set the key as an environment variable or in your secret store.
  • The strong model is never used, and the log says "routing disabled (cheap model only)". The strategy lacks what it needs: UseStrongModelAsync or UseStrongModel for Custom, an EmbeddingGenerator for Semantic.
  • Users see each other's memories or history. AnonymousIdentity is Shared, or one ChatHistoryProvider object serves every user.
  • A new conversation still knows the previous one. Your history provider keeps the transcript in a field: keep it in the session, as in Conversation history and memory.
  • The app stops at startup with "Both ChatHistoryProvider and ChatHistoryProviderFactory are set." MentorAgent checks the options when the host starts. Keep ChatHistoryProviderFactory alone.
  • The safety check is skipped with a timeout warning. The classifier model is too slow: set a faster ClassifierChatClient or raise SafetyCheckTimeout.
  • Replies arrive all at once instead of streaming. A check on the reply is on: EnableOutputSafetyCheck or an OutputGuardrail holds each reply until it is checked.
  • No traces arrive. The MentorAgent source and meter are not in your OpenTelemetry setup, or ObservabilitySourceName gives them another name.
  • Secure the endpoints — Protect the SignalR hub, Server-Sent Events, /mcp and /a2a endpoints with authentication and CORS before you expose them.
  • Model providers — Connect OpenAI, Azure OpenAI, Ollama and other local models, or your own AIAgent, and choose models that support tool calling.
  • Roles, confirmation and governance — How MentorAgent checks roles and asks for confirmation before an action runs, on every channel, and what it leaves to your code.
  • 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.