Production checklist
Settings to review before you go to production: governance defaults, safety check, rate limits, cost and routing, history, observability, and outgoing data.
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.
Authentication and endpoints
Section titled “Authentication and endpoints”- Every endpoint you map requires authentication. The hub,
/mentor/chatand/a2arun turns on your model, and/mcpruns 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
RequiredRolesis refused: see Blazor Server app. AnonymousIdentitystaysPerSessionon any application with more than one user.Sharedputs every anonymous user on one memory and one rate-limit count.- The admin views are protected. With
Novior.MentorAgent.Server,DashboardRoleguardsGET /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
/mcpendpoint has no Cross-Origin Resource Sharing (CORS) policy by default, which suits desktop and IDE clients. If a browser-based client calls it, define theMentorAgentMcppolicy with your origins and apply it withRequireCors: see MCP server.
Roles and confirmation
Section titled “Roles and confirmation”- Every action is declared. Declare
ReadOnly = trueon an action that only reads. A[MentorAction]that declares neitherReadOnly = truenorRequiresConfirmationcounts as a write and asks for confirmation. WriteRequiresConfirmation = falseonly on a write that must run unasked. GiveRequiredRolesto every action meant for some users only: roles are open by default. See Two checks, in this order. RequireConfirmationForWritesstaystrue. Withfalse, onlyRequiresConfirmation = trueasks.- A method with only
[Description]asks for nothing. It has no roles and no confirmation, andRequireConfirmationForWritesdoes 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. RequireConfirmationstaystrue.falseturns 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).IncludeWorkflowExceptionDetailsstaysfalse. Set totrue, 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.
Limits and safety checks
Section titled “Limits and safety checks”MaxMessageLengthrefuses long messages before the model is called.RateLimitPerUseris off by default. It counts messages per signed-in user withinRateLimitWindowSecs. The user is theNameIdentifierclaim, elsesub,oidor the name. MCP tool calls count too, in an allowance of their own. A caller with noNameIdentifier,suboroidclaim and no name is counted by remote address whenIHttpContextAccessoris registered (AddMentorAgentServer()andAddMentorAgentA2A()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.EnableSafetyCheckchecks each message with one classifier call, for prompt injection, jailbreaks and attempts to extract the system prompt or credentials. PointClassifierChatClientat a small, fast model.SafetyCheckFailuredecides what happens when a check gives no verdict, after a timeout or a provider error.Allow, the default, answers and logs a warning;Blockrefuses the message. ChooseBlockwhen a message the check could not screen must never reach the model;Allowkeeps the agent available when the classifier is down.EnableOutputSafetyCheckandRefuseOutOfScopeare 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.RefuseOutOfScoperefuses 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.OnExceptionmaps 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.
Cost and model routing
Section titled “Cost and model routing”ClassifierChatClientruns MentorAgent's own short decisions: safety checks, the off-topic check, the facts to remember, and the question behind the twoRequire…options below. A small model makes them cheaper and faster.StrongChatClientwith aRoutingStrategysends only the hard questions to a stronger model.Custom, the default, needs your router inUseStrongModelAsyncorUseStrongModel.Semanticneeds anEmbeddingGenerator.Classifier, used below, needs nothing more: one small call per turn, onRoutingClassifierClientor elseChatClient, decides whether the turn needs the strong model. If the strategy lacks what it needs, MentorAgent logs a warning and every turn stays onChatClient.EnableToolFilteringsends only the relevant tools, and needs anEmbeddingGenerator. 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 withinToolFilterMaxTools. 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. SeeEnableToolFiltering.EnableCompactionshrinks long conversations before each call. It changes what is sent to the model, never what is stored. It works withChatClientand a history kept by your application, and it is not installed withUseServiceManagedHistory.WarmUpAtStartupdoes 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.ModelPricingturns 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 anIMentorMetricsStorebeforeAddMentorAgent().
The providers and the embedding model are set up in Model providers.
Answers from your data
Section titled “Answers from your data”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.
RequireLookupForRecordQuestionsmakes 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, withReadOnly = trueorIsReadOnlyAction: the option requires only those.RequireActionForChangeRequestsmakes 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. KeepRequireConfirmationon, 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.
Conversation history and memory
Section titled “Conversation history and memory”- 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'sIServiceProvider, so your history provider can store each user's history separately. SettingChatHistoryProviderinstead gives one instance to every user of the process. - Your history provider is not trimmed.
MaxSessionMessagesandChatReducerapply only to the built-in history. Give your history provider its own limit.MentorChatReducer, in theNovior.MentorAgent.Corenamespace, is the reducer that MentorAgent uses by default: it keeps every tool call together with its result and cuts whole turns. AChatReducerset next to your own provider, or withUseServiceManagedHistory, is not used, and a warning says so. - The built-in history counts every message. Tool calls and their results count against
MaxSessionMessagesand 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. SetChatReducerto use another reducer. - Memories are in memory too. With
UseMemoryContext, register your ownIMentorMemoryStorebeforeAddMentorAgent(). The built-in store loses everything on restart, and each server instance has its own. A signed-in user is keyed by theNameIdentifierclaim, else bysub,oidor the name. A key from a claim other thanNameIdentifiercarries the name of the claim, such assub:….ClearAsyncon 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
ResetSessionandResetSessionAsyncstart 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.
SerializeSessionAsyncreturns it as it is, and the model trusts a restored one as history. Keep it on the server, or inProtectedLocalStorage, never as text that a user can read or edit.GET /mentor/sessionreturns 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:
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:
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.
Observability
Section titled “Observability”EnableObservabilityemits OpenTelemetry traces and metrics. The model calls follow the OpenTelemetry conventions for generative AI (GenAI). MentorAgent adds amentor.turnspan per turn, amentor.tool.{toolName}span per tool call, amentor.mcp.tool.{toolName}span per call to the MCP server, and four counters:mentoragent.turns,mentoragent.tool_calls,mentoragent.errorsandmentoragent.guardrail_blocks, which includes the refusals ofRefuseOutOfScope.- Your application exports them. Add the source and the meter named by
ObservabilitySourceName,MentorAgentby default, to your OpenTelemetry tracing and metrics. ObservabilityIncludeSensitiveDatastaysfalseoutside 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.
Data that leaves your application
Section titled “Data that leaves your application”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
RequireLookupForRecordQuestionsorRequireActionForChangeRequests, 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
McpServersreceive the tool calls, and the A2A agents inRemoteAgentsthe requests delegated to them. The servers inHostedMcpServersare 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.
Content Security Policy
Section titled “Content Security Policy”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.DarkorMentorTheme.Minimal, andPrimaryColor, write their colors in a<style>block. Without'unsafe-inline'the browser drops the block and the widget keeps its default colors.MentorTheme.Customwithout aPrimaryColorneeds nothing, because its colors are in your own stylesheet. - The dashboard. The legend and the bars of
<MentorDashboard />usestyleattributes.
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 and configuration
Section titled “Keys and configuration”- Keys come from a secret store or environment variables. User secrets load only in the
Developmentenvironment. 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.
Complete configuration
Section titled “Complete configuration”The options from this list in one registration, next to the endpoint protection of Secure the endpoints:
using Novior.MentorAgent.Extensions; // AddMentorAgent
using Novior.MentorAgent.Options; // MentorRoutingStrategy, MentorSafetyCheckFailure
using Microsoft.Extensions.AI; // AsIChatClient
using OpenAI; // OpenAIClientvar 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
});Options that matter here
Section titled “Options that matter here”| Option | Why it matters in production |
|---|---|
RequireConfirmation, RequireConfirmationForWrites, IncludeWorkflowExceptionDetails | Defaults that keep the confirmation boxes on, make writes ask, and keep exception details out of replies |
MaxMessageLength, RateLimitPerUser, RateLimitWindowSecs | Bound what one user can send and spend |
EnableSafetyCheck, ClassifierChatClient, SafetyCheckFailure | Screen each message, on a fast model, with a decision for the case without a verdict |
StrongChatClient, RoutingStrategy, EnableCompaction, WarmUpAtStartup | Pay for the strong model only when needed, and send fewer tokens |
EnableObservability, ObservabilityIncludeSensitiveData | Traces and metrics, without the text of the conversations |
Every option is in Configuration options, and the limits under Security and limits.
Common errors
Section titled “Common errors”- 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:
UseStrongModelAsyncorUseStrongModelforCustom, anEmbeddingGeneratorforSemantic. - Users see each other's memories or history.
AnonymousIdentityisShared, or oneChatHistoryProviderobject 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
ChatHistoryProviderFactoryalone. - The safety check is skipped with a timeout warning. The classifier model is too slow: set a faster
ClassifierChatClientor raiseSafetyCheckTimeout. - Replies arrive all at once instead of streaming. A check on the reply is on:
EnableOutputSafetyCheckor anOutputGuardrailholds each reply until it is checked. - No traces arrive. The
MentorAgentsource and meter are not in your OpenTelemetry setup, orObservabilitySourceNamegives them another name.
See also
Section titled “See also”- 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.
