# Production checklist

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

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

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](https://docs.mentoragent.net/configure/production#complete-configuration) at the end puts the options together.

## Authentication and endpoints

- **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](https://docs.mentoragent.net/configure/secure-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](https://docs.mentoragent.net/configure/blazor-server#security-and-roles).
- **`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](https://docs.mentoragent.net/configure/mcp-server#complete-configuration).

## Roles and confirmation

- **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](https://docs.mentoragent.net/concepts/governance#two-checks-in-this-order).
- **[`RequireConfirmationForWrites`](https://docs.mentoragent.net/reference/options#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](https://docs.mentoragent.net/concepts/governance#confirmation-by-default) explains when the warning appears.
- **Tools you cannot annotate are gated by name** with `RequiresApproval`: see [Tools you cannot annotate](https://docs.mentoragent.net/concepts/governance#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`](https://docs.mentoragent.net/reference/options#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](https://docs.mentoragent.net/concepts/governance#what-stays-in-your-code).

## Limits and safety checks

- **`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](https://docs.mentoragent.net/configure/secure-endpoints#complete-configuration) 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`](https://docs.mentoragent.net/reference/options#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.

## Cost and model routing

- **`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`](https://docs.mentoragent.net/reference/options#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](https://docs.mentoragent.net/reference/glossary#agent-card), 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](https://docs.mentoragent.net/configure/model-providers).

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

- **[`RequireLookupForRecordQuestions`](https://docs.mentoragent.net/reference/options#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`](https://docs.mentoragent.net/reference/options#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.

## Conversation history and memory

- **The history is in memory by default**, one per [circuit](https://docs.mentoragent.net/reference/glossary#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`](https://docs.mentoragent.net/reference/options#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:

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

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

> **Caution:**
> Never assign one shared `ChatHistoryProvider` object on an application with several users, unless it partitions its storage by user itself. Every circuit and connection would read and write the same history.

## Observability

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

## 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 `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](https://docs.mentoragent.net/configure/model-providers#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`](https://docs.mentoragent.net/reference/options#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

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](https://learn.microsoft.com/aspnet/core/blazor/security/content-security-policy).

## Keys and configuration

- **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](https://docs.mentoragent.net/configure/model-providers#azure-openai).
- **Public URLs are the production ones**, such as `A2AServerUrl`: see [A2A server](https://docs.mentoragent.net/configure/a2a-server#complete-configuration).

## Complete configuration

The options from this list in one registration, next to the endpoint protection of [Secure the endpoints](https://docs.mentoragent.net/configure/secure-endpoints):

```csharp title="Program.cs"
using Novior.MentorAgent.Extensions;   // AddMentorAgent
using Novior.MentorAgent.Options;      // MentorRoutingStrategy, MentorSafetyCheckFailure
using Microsoft.Extensions.AI;  // AsIChatClient
using OpenAI;                   // OpenAIClient
```

```csharp title="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
});
```

### Options that matter here

| Option | Why it matters in production |
|---|---|
| [`RequireConfirmation`](https://docs.mentoragent.net/reference/options#requireconfirmation), [`RequireConfirmationForWrites`](https://docs.mentoragent.net/reference/options#requireconfirmationforwrites), [`IncludeWorkflowExceptionDetails`](https://docs.mentoragent.net/reference/options#includeworkflowexceptiondetails) | Defaults that keep the confirmation boxes on, make writes ask, and keep exception details out of replies |
| [`MaxMessageLength`](https://docs.mentoragent.net/reference/options#maxmessagelength), [`RateLimitPerUser`](https://docs.mentoragent.net/reference/options#ratelimitperuser), [`RateLimitWindowSecs`](https://docs.mentoragent.net/reference/options#ratelimitwindowsecs) | Bound what one user can send and spend |
| [`EnableSafetyCheck`](https://docs.mentoragent.net/reference/options#enablesafetycheck), [`ClassifierChatClient`](https://docs.mentoragent.net/reference/options#classifierchatclient), [`SafetyCheckFailure`](https://docs.mentoragent.net/reference/options#safetycheckfailure) | Screen each message, on a fast model, with a decision for the case without a verdict |
| [`StrongChatClient`](https://docs.mentoragent.net/reference/options#strongchatclient), [`RoutingStrategy`](https://docs.mentoragent.net/reference/options#routingstrategy), [`EnableCompaction`](https://docs.mentoragent.net/reference/options#enablecompaction), [`WarmUpAtStartup`](https://docs.mentoragent.net/reference/options#warmupatstartup) | Pay for the strong model only when needed, and send fewer tokens |
| [`EnableObservability`](https://docs.mentoragent.net/reference/options#enableobservability), [`ObservabilityIncludeSensitiveData`](https://docs.mentoragent.net/reference/options#observabilityincludesensitivedata) | Traces and metrics, without the text of the conversations |

Every option is in [Configuration options](https://docs.mentoragent.net/reference/options), and the limits under [Security and limits](https://docs.mentoragent.net/reference/options#security-and-limits).

## 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: `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](https://docs.mentoragent.net/configure/production#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.

---

- Previous: [Secure the endpoints](https://docs.mentoragent.net/configure/secure-endpoints.md)
- Next: [Attributes](https://docs.mentoragent.net/reference/attributes.md)
