# Action policies and observers

> The types that let your code decide on each action, record every decision of the gate, know who confirmed a call, and list the actions.

- URL: https://docs.mentoragent.net/reference/action-policies
- Verified with: MentorAgent 1.2.0
- Last updated: 2026-11-08
- Packages: Novior.MentorAgent, Novior.MentorAgent.Abstractions

The tool gate is the part of MentorAgent that decides whether a call to one of your actions may run: the roles, the confirmation, then the action. This page lists the types that let your code take part in it. An action policy decides, in your code, whether a user may use an action and whether a call may run. An observer receives every decision of the gate. `MentorActionConfirmation` tells an action who confirmed it. `IMentorActionCatalog` lists your actions as MentorAgent sees them.

[Roles, confirmation and governance](https://docs.mentoragent.net/concepts/governance#your-own-rules-action-policies) explains when to use them, with an example of a policy. The types are in the `Novior.MentorAgent.Abstractions.Interfaces` and `Novior.MentorAgent.Abstractions.Models` namespaces. The registration methods are in `Novior.MentorAgent.Extensions`. All of them are new in 1.2.0.

## The order of a call

A call to one of your actions is decided in this order:

1. the roles of the action, `RequiredRoles`;
2. `IsAllowedAsync` of every policy;
3. `BeforeActionAsync` of every policy;
4. the confirmation: the action's own, or the one that a policy asked for;
5. the action.

A call refused or stopped before step 4 shows no confirmation box. After the user confirms, MentorAgent asks the roles and `IsAllowedAsync` again, so a permission taken away while the box was open stops the action.

## `IMentorActionPolicy`

A class of yours that decides on the application's actions. Register it with `AddMentorActionPolicy<T>()`, or an instance with `AddMentorActionPolicy(policy)`. A policy is a singleton. Every policy registered is asked, in the order of registration. Both methods have a default body, so a policy writes only the one it needs.

| Method | Default | What it decides |
|---|---|---|
| `IsAllowedAsync(MentorActionRequest, CancellationToken)` | `true` | Whether this user may use this action at all. Asked with `Stage = Offer` when MentorAgent decides what to offer: at every turn, for each action of the coordinator, and at every `tools/list` of the MCP server. An action that it does not allow is left out, like one the user has no role for. Asked again with `Stage = Call` before every call, and after the user confirms. A call that it does not allow is refused. With several policies, the first `false` decides |
| `BeforeActionAsync(MentorActionRequest, CancellationToken)` | `Proceed` | Whether this call may run, with its arguments: a [`MentorActionVerdict`](https://docs.mentoragent.net/reference/action-policies#mentoractionverdict). Every policy is asked, and the strictest answer wins: `Stop`, then `Confirm`, then `Proceed` |

**Where policies are asked.**

| Surface | `IsAllowedAsync` at `Offer` | Before a call | A `Confirm` verdict |
|---|---|---|---|
| The widget, the hub and `/mentor/chat`, in both modes | At every turn | `IsAllowedAsync`, then `BeforeActionAsync` | The usual confirmation box |
| Specialists, team members and the agents of an `IMentorAgentSource` | Not asked: their roles decide what they are offered | `IsAllowedAsync`, then `BeforeActionAsync` | The usual confirmation box |
| A task from another agent (A2A) | At every turn | The same | Refused, because nobody can confirm |
| The MCP server | At every `tools/list` | At every `tools/call` | Refused, because nobody can confirm |

Policies decide on your actions and on the tools of the MCP servers that you consume. They never decide on MentorAgent's own tools, such as `navigate_to`, `remember`, `load_skill`, `route_to_specialist`, a team's tool or `invoke_ui_action`, so not on the UI actions of a page either. The A2A agent card is not filtered: it is one card for every caller.

**A policy that fails stops the call.** Each call to a policy may take [`ActionPolicyTimeout`](https://docs.mentoragent.net/reference/options#actionpolicytimeout), 5 seconds by default. A policy that throws, returns `null` or does not answer in time stops the call, and the model reads that the check could not be completed. When MentorAgent decides what to offer, such a policy leaves the action out. The log names the type of the exception, never its message.

`IsAllowedAsync` is asked for every action offered, by every policy, on every turn: keep it fast, for example with a cache per user.

## `MentorActionRequest`

What a policy is asked about.

| Property | Type | What it holds |
|---|---|---|
| `Stage` | `MentorActionStage` | `Offer` when MentorAgent decides what to offer, `Call` when a call is about to run |
| `ToolName` | `string` | The technical name of the tool, such as `close_case` |
| `Description` | `string?` | The description that the model reads |
| `Arguments` | `IReadOnlyDictionary<string, object?>?` | At `Call`, the arguments by parameter name, as the model or the MCP client sent them, usually a `JsonElement` each. Nothing has checked them: read them as you would read the body of a request. `null` at `Offer` |
| `User` | `ClaimsPrincipal?` | The signed-in user, or `null` when nobody is signed in. On the MCP server, the caller that [`McpCallerPrincipal`](https://docs.mentoragent.net/reference/options#mcpcallerprincipal) returns, or else the user of the request |
| `Surface` | `MentorActionSurface` | Where the call comes from: `InProcess` (a Blazor Server circuit or a MAUI app), `Hub`, `Sse`, `Mcp` or `A2A` |
| `AgentKind`, `AgentName` | `MentorAgentKind?`, `string?` | The agent that asks: `Coordinator`, `Specialist`, `TeamMember` or `AgentSource`, with its name when it has one |
| `SessionId`, `TurnId`, `CallId` | `string?` | Opaque IDs of the session, of the user turn and of the call. `CallId` is `null` at `Offer`, and `TurnId` on the MCP server |
| `PageName` | `string?` | The page that the user is on, as it named itself with `IMentorPageContext.SetPageName` |
| `RequiredRoles` | `IReadOnlyList<string>` | The roles of the action: any one of them is enough. Empty when it requires none |
| `RequiresConfirmation` | `bool` | Whether the action asks for a confirmation of its own |
| `Services` | `IServiceProvider` | The services of the conversation: the circuit, the hub connection, the request or the A2A task. Resolve your scoped services here, such as a `DbContext` |

## `MentorActionVerdict`

What `BeforeActionAsync` answers for one call. Its `Kind` is a `MentorActionVerdictKind`, and its `Reason` is your text.

| Verdict | Effect |
|---|---|
| `MentorActionVerdict.Proceed` | No objection. It never removes a confirmation that the action asks for |
| `MentorActionVerdict.Confirm(reason)` | Asks the user to confirm first, even for an action that asks for no confirmation. Where nobody can confirm, on the MCP server or in a task from another agent, the call is refused |
| `MentorActionVerdict.Stop(reason)` | The call does not run. The model reads that a rule stopped it, followed by your reason, never that the user declined |

The reason reaches the model and the observers. Never put a secret in it.

## `IMentorActionObserver`

Receives every decision of the gate, for every session and every surface of the process, and for every agent that calls a tool. Use it for an audit trail: who asked for which action, and why the gate decided as it did. Register it with `AddMentorActionObserver<T>()`, or an instance with `AddMentorActionObserver(observer)`.

```csharp title="ActionAuditLog.cs"
public class ActionAuditLog(ILogger<ActionAuditLog> logger) : IMentorActionObserver
{
    public ValueTask OnDecisionAsync(
        MentorActionDecision decision, CancellationToken cancellationToken)
    {
        logger.LogInformation(
            "{Tool}: {Outcome} ({Reason}) for {User}, confirmed by {ConfirmedBy}",
            decision.ToolName, decision.Outcome, decision.ReasonCode,
            decision.User?.Identity?.Name ?? "anonymous",
            decision.ConfirmedBy?.Identity?.Name ?? "nobody");
        return ValueTask.CompletedTask;
    }
}
```

- **One method.** `OnDecisionAsync(MentorActionDecision, CancellationToken)` receives one [decision](https://docs.mentoragent.net/reference/action-policies#mentoractiondecision).
- **Never in the way.** An observer never runs on the path of the action. Each observer has a queue of its own and receives the decisions in the order they were taken. A slow observer, or one that throws, delays and stops nothing, and its failure is logged.
- **Five seconds a call.** A call that has not finished in 5 seconds is no longer awaited, and its token is cancelled. The next decision can then arrive while that call still runs, so an observer must be thread-safe.
- **A singleton.** It is resolved once, from the root services of the application. An observer that needs scoped services creates a scope with `IServiceScopeFactory`.
- **No record of its own.** MentorAgent keeps no copy of the decisions. A record of who confirmed what, built on an observer, and what it can miss: see [Audit: who confirmed what](https://docs.mentoragent.net/concepts/governance#audit-who-confirmed-what).

## `MentorActionDecision`

One decision of the gate. It never carries the arguments of the call.

| Property | What it holds |
|---|---|
| `Outcome` | What the gate decided: a [`MentorActionOutcome`](https://docs.mentoragent.net/reference/action-policies#mentoractionoutcome) |
| `ReasonCode` | Why: one of the [`MentorActionReasons`](https://docs.mentoragent.net/reference/action-policies#mentoractionreasons) |
| `ToolName` | The technical name of the tool |
| `DisplayName` | The text shown to the user for the tool |
| `Surface` | `InProcess`, `Hub`, `Sse`, `Mcp` or `A2A` |
| `AgentKind`, `AgentName` | The agent that asked, with its name. `null` on the MCP server, where the caller names the tool itself |
| `SessionId`, `TurnId`, `CallId` | Opaque IDs of the session, of the user turn and of the call. On the MCP server, the session is `mcp#` and a fingerprint, never the session ID itself |
| `ConfirmationId` | The ID of the confirmation, on the decisions that belong to one |
| `User` | Who asked for the action, or `null` when nobody is signed in |
| `RequiredRoles` | The roles of the action |
| `ConfirmedBy` | On `Confirmed`, who confirmed: the caller of `POST /mentor/approve`, or the signed-in user of the session in a circuit and in a MAUI app |
| `Timestamp` | When the gate decided, in UTC |
| `ExceptionType` | On `Failed` and `PolicyFailed`, the full name of the type of the exception, never its message |
| `Detail` | The reason that a policy gave, on `Blocked`, on `ConfirmationRequested` and on `Denied` with `nobody-to-confirm`. `null` otherwise |

## `MentorActionOutcome`

| Outcome | When |
|---|---|
| `Allowed` | The roles and every policy let the call through. The confirmation, if any, comes next |
| `Denied` | Refused, for example for a missing role, or because a policy does not allow the action |
| `ConfirmationRequested` | The user is asked to confirm |
| `Confirmed` | The user said yes |
| `Rejected` | The user said no, or nobody answered within [`ConfirmationTimeout`](https://docs.mentoragent.net/reference/options#confirmationtimeout) |
| `Stopped` | Stopped before it finished: Stop, the end of the session or the end of the turn |
| `Blocked` | A policy stopped the call with `Stop`. Its reason is in `Detail` |
| `PolicyFailed` | A policy threw or did not answer in time, so the call did not run |
| `Executed` | The action ran |
| `Failed` | The action threw |

A call goes through the outcomes in one of these orders, the same in `Blocking` and in `Native` mode:

- `Allowed`, then `Executed`, `Failed` or `Stopped`;
- `Allowed`, `ConfirmationRequested`, then `Rejected`, `Stopped` or `Confirmed`. After `Confirmed` comes the outcome of the run, or `Denied` when a role or a permission was taken away while the box was open;
- `Denied`, `Blocked` or `PolicyFailed` alone.

## `MentorActionReasons`

The reason codes, as string constants. They stay the same across versions: a new code comes only in a minor version, and the [release notes](https://docs.mentoragent.net/release-notes) of that version say so.

| Outcome | Reason codes |
|---|---|
| `Allowed` | `allowed`; `auto-approved` when, in `Native` mode, a rule of [`AutoApprovalRules`](https://docs.mentoragent.net/reference/options#autoapprovalrules) approved the call |
| `Denied` | `role-missing`; `login-required` (a role is required and nobody is signed in); `auth-not-configured` (no `AuthenticationStateProvider`); `auth-check-failed` (reading the user threw); `not-authorized` (a policy does not allow the action); `nobody-to-confirm`; on the MCP server, also `withheld` and `rate-limited` |
| `ConfirmationRequested`, `Confirmed`, `Rejected` | `operator`; `policy` when a policy asked for the confirmation; `confirmation-timeout` on `Rejected` when nobody answered in time |
| `Stopped` | `stop`; `session-ended`; `turn-ended` |
| `Executed` | `completed`; `result-withheld` when `OnToolResult` threw |
| `Failed` | `exception` |
| `Blocked` | `policy` |
| `PolicyFailed` | `policy-exception`; `policy-timeout` |

## `MentorActionConfirmation`

Inside the method of an action, `MentorActionConfirmation.Current` says who confirmed this call, and when. It works in `Blocking` and in `Native` mode, and it is `null` for a call that nobody confirmed. So a ledger of your writes can record who approved each one.

```csharp title="CaseEscalation.cs"
public class CaseEscalation(ILogger<CaseEscalation> logger)
{
    [MentorAction(
        Description = "Escalates a support case to the second line",
        RequiresConfirmation = true)]
    public string EscalateCase(string caseNumber)
    {
        // Who confirmed this call, and when: null when nobody confirmed it
        var confirmation = MentorActionConfirmation.Current;
        logger.LogInformation(
            "Case {Case} escalated, confirmed by {User} at {Time}",
            caseNumber,
            confirmation?.ConfirmedBy?.Identity?.Name,
            confirmation?.ConfirmedAt);
        return $"Case {caseNumber} escalated.";
    }
}
```

| Property | What it holds |
|---|---|
| `ConfirmationId` | The ID of the confirmation, the same as `MentorActionDecision.ConfirmationId` |
| `ToolName` | The tool that was confirmed |
| `ConfirmedAt` | When the user confirmed, in UTC |
| `ConfirmedBy` | Who confirmed: the caller of `POST /mentor/approve`, or the signed-in user of the session in a circuit and in a MAUI app |

## `IMentorActionCatalog`

Registered by `AddMentorAgent()`. Its `GetActions()` lists every `[MentorAction]` of the coordinator, of the specialists and of the team members, one `MentorActionInfo` each, decided again at every call. Use it for an administration page, or for a test that fails when an action is published by mistake:

```csharp title="ActionChecks.cs"
// The writes that the MCP server publishes to every caller, for example
// to fail a test when one appears
public static IEnumerable<string> WritesPublishedToEveryone(
    IServiceProvider services) =>
    services.GetRequiredService<IMentorActionCatalog>().GetActions()
        .Where(action => !action.ReadOnly
            && action.McpExposure == MentorMcpExposure.Published)
        .Select(action => action.ToolName);
```

| `MentorActionInfo` property | What it holds |
|---|---|
| `ToolName`, `Description` | The tool, and the description that the model reads |
| `AgentKind`, `AgentName` | `Coordinator`, `Specialist` or `TeamMember`, with the name of the agent |
| `RequiredRoles` | The roles of the action |
| `RequiresConfirmation` | Whether a call asks for a confirmation now, the [`RequiresApproval`](https://docs.mentoragent.net/reference/options#requiresapproval) predicate included |
| `ReadOnly` | Whether the action is declared as only reading, and asks for no confirmation |
| `OnAgentCard` | Whether the A2A agent card lists it. `false` when the host does not serve A2A |
| `McpExposure` | A `MentorMcpExposure`: `Published` to every caller; `PublishedToRoles`, to the callers that `McpCallerPrincipal` identifies with one of its roles; `Withheld`, because it asks for a confirmation or needs a role with no `McpCallerPrincipal`; `NotServed`, because the host does not map the MCP server, the action belongs to a specialist or a team, or its class is not registered |

The catalog says what MentorAgent's own rules decide. A policy can still leave an action out for one user. The tools of the MCP servers that you consume, and those of an agent source, are not in it.

## Other new types

| Type | What it is |
|---|---|
| `MentorActionStarted` | The argument of `IMentorStateService.OnActionStarted`: the technical name of the tool next to the text shown to the user. `OnActionExecuting` carries only the text shown. Raised where the turn runs, not by the WebAssembly client |
| `MentorReplyStarted` | The argument of the hub event `ReplyStarted`, sent before each reply of the model: `{ "aiGenerated": true }`. See [`ShowAiDisclosure`](https://docs.mentoragent.net/reference/options#showaidisclosure) |
| `MentorSessionContextPlacement` | The values of [`SessionContextPlacement`](https://docs.mentoragent.net/reference/options#sessioncontextplacement): `Instructions` or `LastUserMessage` |

---

- Previous: [Configuration options](https://docs.mentoragent.net/reference/options.md)
- Next: [Glossary](https://docs.mentoragent.net/reference/glossary.md)
