# AgentPrism — full documentation Every hand-written page of the AgentPrism documentation, concatenated. The generated API and HTTP references are not included; use the compiler, the XML documentation, and https://agentprism.doayen.web.tr/http-api/ for those. --- # Capability map AgentPrism is a library, not a hosted service. You choose the pieces, keep control of the dependency graph, and run the control plane inside your own .NET application. This page is the inventory: what exists, where it lives, and what turns it on. ## The shortest complete picture ```mermaid flowchart LR accTitle: AgentPrism capability flow accDescr: Agent definitions enter the catalog, run with tools and context, persist state and telemetry, then feed evaluation and governance. DEF["Agent definitions"] --> CAT["Catalog and compiler"] CAT --> RUN["Runs and sessions"] RUN --> REC["Recording and telemetry"] RUN --> ORCH["Workflows and jobs"] RUN --> EXT["HTTP · OpenAI · MCP · A2A"] DEF --> CTX["Tools · skills · memory"] STORE["Memory or SQL stores"] --> CAT STORE --> RUN GOV["Security and governance"] --> RUN GOV --> EXT ``` The default `AddAgentPrism()` registration is useful on its own. It gives you the catalog, compiler, in-memory stores, run pipeline, sessions, jobs, evaluation contracts, quotas, audit services, and other core services. Provider, SQL, UI, workflow, MCP, voice, and external protocol packages add their own explicit calls. ## Agent design and model control | Capability | What it gives you | Enable or define it | Boundary | |---|---|---|---| | Declarative agents | Instructions, model binding, tools, skills, callable agents, metadata, and runtime policy as data | `IAgentPrismBuilder.AddAgent(AgentDefinition)` | A code definition wins a name collision with a database definition | | Factory agents | A direct escape hatch that returns any MAF `AIAgent` | `AddAgent(name, factory)` | The catalog still applies AgentPrism decorators when it resolves the agent | | Database definitions | Create, validate, version, diff, roll back, and delete definitions at run time | HTTP API or console after `MapAgentPrism()` | Code-defined agents are visible but read-only | | Definition validation | Checks providers, tools, skills, callable agents, cycles, and policy before save | Compiler and `POST /api/agents/validate` | Validation does not call a model | | Model binding | Provider, model, temperature, output limit, `top_p`, reasoning effort, and provider-specific settings | `AgentDefinition.Model` | Credentials stay in provider configuration, never in the definition | | Structured output | Explicit text, JSON, or JSON Schema responses | `ModelBinding.ResponseFormat` | Provider support is validated or translated by that provider | | Agent graph | One agent can call registered agents as tools | `CallableAgentNames` | Shared limits bound call depth, total child runs, and total tokens | | Harness mode | Context and iteration limits plus optional todo, file-memory, web-search, skill, and mode providers | `AgentDefinition.Harness` | The harness extends the agent; it does not replace MAF types | | Context compaction | Trigger-based truncation or summarization with preserved turns and an optional utility model | `AgentDefinition.Compaction` and `AgentPrism:UtilityModel` | Compaction is per definition and can be disabled by harness settings | | Working memory | Todo state, file memory, text search, and vector search tools | `AgentDefinition.Memory` | Vector search also needs PostgreSQL and an embedding generator | AgentPrism uses `AIAgent`, `AgentSession`, `ChatMessage`, and `AIFunction` directly. It is a control plane around MAF, not a competing agent abstraction. ## Model providers Several providers can be active at the same time. Each agent selects one by its stable provider name. | Package | Registration | Provider names | Notable capability | |---|---|---|---| | `AgentPrism.OpenAI` | `UseOpenAI()` | `openai`, `openai-responses` | Chat Completions and Responses clients | | `AgentPrism.OpenAI` | `UseOpenAICompatible(name, ...)` | `name`, and optionally `name-responses` | OpenRouter, Groq, Ollama, LM Studio, vLLM, and other compatible endpoints | | `AgentPrism.Anthropic` | `UseAnthropic()` | `anthropic` | Claude, prompt caching, and extended-thinking settings | | `AgentPrism.Google` | `UseGoogle()` | `google` | Gemini safety thresholds and thinking settings | | `AgentPrism.Azure` | `UseAzureOpenAI()` | `azure-openai` | Azure deployments with an API key or a consumer-supplied Entra credential | | Any package | `AddModelProvider()` | Chosen by the implementation | A custom `IModelProvider` without a provider package | All built-in providers can publish a configured model catalog. The catalog feeds the console and pricing; it is not an allowlist. Health checks are cached. A shared circuit breaker protects provider calls. AgentPrism does not invent model names or prices. ## Tools, skills, and context | Capability | Registration or source | What is enforced | |---|---|---| | Generated tools | `[AgentPrismTool]` and `AddGeneratedTools()` | Compile-time discovery without reflection or dynamic code | | Direct tools | `AddTool(AIFunction, requiresApproval)` | Exact tool instance and approval policy | | Delegate tools | `AddTool(delegate, ...)` | Convenient reflection path; trimming and dynamic-code warnings reach the caller | | Scanned tools | `AddToolsFrom()` or `AddToolsFrom(Type)` | Only attributed methods become tools; this path uses reflection | | Tool approval | `RequiresApproval`, the registration flag, or `AddToolApprovalPolicy()` | A sensitive call cannot execute until a person or standing rule decides it | | Client-side tools | `AddClientTool(name, description, jsonSchema)` | The declaration lives in code like every other tool; the server never runs the body. The model's call comes back to the caller, which answers it with `AgentRunRequest.ToolResults` | | Custom content guards | `AddContentGuard()` | Multiple guards run; the strictest result wins | | Pattern guard | `AddPatternContentGuard()` | Denied terms can block; selected PII patterns can mask input or output | | Skills | `AddSkill()` or database/file skill sources | Markdown instructions and resources are bounded and validated | | Skill scripts | `UseSkillScripts()` | Explicit enablement, platform-isolation acknowledgement, interpreter allowlist, tenant grant, timeout, output limit, and concurrency limits | | Remote MCP tools | `UseMcp()` | Tool discovery, name normalization, resource limits, authentication, refresh, prompts, and OAuth coordination | | MCP resources | `AgentDefinition.McpResourceUris` | A bounded snapshot of selected server resources enters agent context | | Knowledge search | PostgreSQL, `IEmbeddingGenerator`, and memory settings | Chunking, embedding, HNSW cosine search, tenant isolation, and result limits | Only application code defines executable tool logic. The console can edit which registered tools an agent may use, but it cannot create a new executable function. Stored skill scripts are a separate, deliberately gated feature; AgentPrism does not claim to provide an operating-system sandbox. ## Runs, sessions, and media | Capability | Surface | Important behavior | |---|---|---| | Streaming runs | .NET or `POST /api/agents/{name}/run` | Text and tool activity stream as SSE events | | Non-streaming runs | .NET or an idempotent HTTP request | A completed response can be stored and replayed safely | | Run recording | Core decorator pipeline | Default-on summaries, events, tool calls, usage, cost, errors, and optional input; it can be disabled and store failure never breaks the run | | Cancellation | Run API and cancellation registry | A caller can request cancellation by run id while preserving the final recorded state | | Replay | Recorded run input and replay service | Re-run against the current or selected definition, with tool replay modes and mismatch protection | | Compare and score | HTTP API and console | Compare two runs and attach human or automatic scores | | Sessions | `AgentSessionManager` and session endpoints | Durable conversation identity and readable history when the store supports it | | Branching | Session branch API | Fork a durable conversation from an addressable item; SQL storage is required | | Attachments | Attachment API and message references | Image, audio, PDF, and text uploads use size limits and magic-byte validation | | Multimodal messages | MAF content types plus stored attachments | Providers receive supported image, audio, document, and text content without a new AgentPrism message abstraction | | Speech tools | `AgentPrism.Voice` and `UseVoice()` | ElevenLabs synthesis and transcription, or consumer implementations of the speech contracts | | Live voice conversation | `UseVoiceConversation()` plus `MapAgentPrism()` | A long-lived WebSocket joins transcription, an agent session, and synthesis; it is absent until registered | A run is the unit of evidence. Everything that happened is recorded against a run id, and a store failure never gets permission to stop the run itself. ## Workflows and background work | Capability | Enable it | Storage and execution model | |---|---|---| | Multi-agent workflows | `AgentPrism.Workflows`, `UseWorkflows()`, and `AddWorkflow()` | Compiled graphs execute MAF workflow nodes and record a root run | | Durable checkpoints | Workflow options and a SQL store | A workflow can resume after a restart instead of starting again | | Human input | Workflow request and response endpoints | A waiting workflow resumes from its checkpoint as a new execution step | | Job queue | Registered by `AddAgentPrism()` | Leases, retries, items, status, cancellation, and handler dispatch | | Custom jobs | `IServiceCollection.AddJobHandler()` | Your handler receives a durable job kind without changing the core queue | | Workflow functions | `AddWorkflowFunction()` | A typed function runs as a graph node without an agent of its own | | Schedules | Scheduling API, console, or store | One-time and cron schedules enqueue work; time zones are explicit | | Worker control | `IServiceCollection.UseScheduling()` | A process can run workers or act only as an API node | | Async HTTP runs | `Prefer: respond-async` | The API returns `202` and a location while a worker owns execution | | Idempotency | `Idempotency-Key` | Same tenant, operation, and key return the stored response instead of running twice | | Singleton execution | `AgentPrism:SingletonExecution` | A distributed lease selects one active executor for singleton services | | Run reconciliation | `AgentPrism:RunReconciliation` | Heartbeats let a scanner fail orphaned runs after process loss | In-memory stores make these contracts usable for local work. Durable queues, checkpoints, schedules, cross-process leases, and recovery need a SQL provider for production behavior. ## Evaluation and controlled change | Capability | Definition | Result | |---|---|---| | Eval suites and cases | API, console, or stores | Repeatable inputs, expected properties, checks, and run history | | Built-in checks | Eval case configuration | Deterministic checks run without a judge model | | Custom checks | `AddEvalCheck(kind, check)` | Application code adds a named MAF `EvalCheck` | | Run judges | `IRunJudge` or `AddModelRunJudge()` | Manual or automatic scores with named criteria | | Online evaluation | Judge registration plus enabled sampling | A bounded sample of live runs is scored in the background | | Experiments | Experiment API and console | Stable traffic assignment compares agent versions and reports each arm separately | | Canary rollback | Explicit canary policy | A background scan can stop or roll back a canary when its configured rule fails | AgentPrism reports evidence. It does not declare a statistical winner for an experiment, and automatic rollback is off until you configure it. ## Security and governance | Capability | Where it applies | Default or gate | |---|---|---| | Loopback restriction | All mapped management surfaces | Remote access is off by default | | Static bearer token | `MapAgentPrism()` options | Optional; compare uses constant time | | ASP.NET Core policy | `RequireAuthorization(policy)` | Uses your authentication and identity pipeline | | Reader, Operator, Admin roles | Endpoint groups | Optional policy names; production can require all three at startup | | API keys | HTTP API and stores | Hashed, revocable, expiring, tenant-bound, and narrowed by a closed scope enum | | Multi-tenancy | `UseTenancy()` | Single tenant by default; a verified key outranks a claim or header | | Quotas | Run admission | Enabled with an empty rule set, so no run is rejected until a rule exists | | Rate limiting | HTTP requests | Off by default; partition by tenant, key, or remote address | | Approvals | Tool execution and queued resume | Expiring requests, explicit decisions, and revocable standing rules | | Audit trail | Administrative writes | Actor, action, entity, before/after data, and secret masking | | Webhooks | Signed outbound events | HTTPS, SSRF checks, response limits, reserved-header rejection, retry jobs, and failure disablement | | Outbound network guard | `AgentPrism:Egress` | One guard for webhook delivery, MCP connections, and provider endpoints; private network targets refused by default, checked inside the socket connect callback | | Configuration key prefixes | Stored secret references | A record stores a key **name**, never a value, and each name must sit under an allowed prefix | | Retention and archive | Stored operational data | Deletion defaults are off; preview and jobs make cleanup explicit | | Content inspection | Model input and output | No guard cost until a guard is registered | | External surface guard | MCP server and A2A | Requires the `ExternalInvoke` scope and refuses an unsafe remote-access combination | | Cross-origin access | `AgentPrismEndpointOptions.AllowedOrigins` | Empty by default; no `Access-Control-Allow-Origin` header is ever sent until an exact origin is added — there is no wildcard option | An API-key scope never grants a role. Effective authority is the intersection of the caller's role and key scopes. See the complete scope table in [Compatibility](/reference/compatibility/#api-key-scopes). ## Observability and operations | Capability | Output | Control | |---|---|---| | Run event stream | Gapless, ordered domain events | Recording options choose deltas, tool payloads, input, and payload size | | OpenTelemetry traces | `ActivitySource` spans | Your exporter remains in control; AgentPrism can also persist a sample | | Metrics | Run counts, duration, tokens, cost, tools, errors, judges, and optional quota gauges | Standard .NET metrics; high-cardinality and store-backed gauges are bounded | | Cost attribution | Per model, agent, run, child run, and voice usage | Prices come from a model catalog or explicit configuration | | Provider health | Cached status and optional background polling | On-demand by default; a provider without a health check reports `Unknown` | | Health checks | `AddAgentPrismHealthChecks()` | Adds checks to the consumer's health-check system; you choose the route with `MapHealthChecks()` | | Diagnostics report | `GET /api/diagnostics` and console | Endpoint is off by default because it reveals deployment shape | | Retention preview | HTTP API and console | Shows eligible rows before a cleanup job changes data | Observability never changes behavior. Every signal here is a side effect of a run, and a failure to record one is logged and stepped over rather than raised to the caller. ## Integration surfaces | Surface | Registration | Intended caller | |---|---|---| | .NET API | `AddAgentPrism()` and `IAgentCatalog`, tuned with `IAgentPrismBuilder.Configure(...)` and extended through `IAgentPrismBuilder.Services` | Application code that wants direct MAF objects | | Management HTTP API | `MapAgentPrism()` | The embedded console, automation, or your own client | | OpenAPI | Your application's `AddOpenApi()` setup | Client generation and API exploration | | OpenAI compatibility | Included in `MapAgentPrism()` | Existing Chat Completions, Responses, and Conversations clients | | Embedded console | `AgentPrism.UI` and `UseUI()` | Operators, developers, evaluators, and security administrators | | Embeddable chat widget | `AgentPrism.UI`'s `embed.js` asset (served once `UseUI()` is registered) | A page you embed the widget in, running under its own origin | | MCP client | `AgentPrism.Mcp` and `UseMcp()` | Agents that consume tools from remote MCP servers | | MCP server | `UseMcpServer()` and `MapAgentPrismMcpServer()` | External MCP clients that invoke explicitly exposed agents as tools | | A2A server | `UseA2A()` and `MapAgentPrismA2A()` | External agents that invoke an explicit allowlist of AgentPrism agents | | Voice WebSocket | `UseVoiceConversation()` and `MapAgentPrism()` | Browser or native real-time audio clients | `MapAgentPrism()` exposes the documented management and OpenAI operations. The diagnostics endpoint, voice WebSocket, health route, MCP server, and A2A routes are conditional or separately mapped, so they are not all represented by the generated 143-operation HTTP reference. ## Coding-agent support A coding agent working in your repository cannot use a capability it does not know exists. Two channels tell it, and both are generated from this page. | Capability | Enable it | Boundary | |---|---|---| | Agent map file | `AgentPrismWriteAgentsFile` | Writes `AGENTS.md` at the repository root during build; an existing file is never overwritten | | Local reference file | `AgentPrismWriteLocalReference`, on by default with the map | Writes `AgentPrism.LocalReference.md` beside each project, naming the API documentation and the HTTP API document of the exact version that project restored; regenerated every build, never committed | | Map for web agents | `llms.txt` and `llms-full.txt` | Published with this site; nothing to register. `llms.txt` carries the map and one line per documentation page; `llms-full.txt` carries every page in full | | Usage diagnostics | Automatic with `AgentPrism.Core`; `AgentPrismUsageDiagnostics` turns the family off | The `AgentPrism.Usage` category reports absent wiring, a literal secret, hand-written substitutes for shipped behaviour, and instructions that leave the map unreachable | | Tool diagnostics | Automatic with `AgentPrism.Core` | The `AgentPrism.Tools` category reports a tool method the generator cannot use | The map is refreshed by deleting `AGENTS.md` and building again; the file is never rewritten in place because you may have added notes to it. The template `dotnet new agentprism-api` sets the property, so a generated project has the map from its first build. A repository that already keeps its own `AGENTS.md` never receives the map file at all, and copying the capability list into it would only create a second copy to maintain: add one line naming `AgentPrism.LocalReference.md` instead, which is what `APG0402` asks for and what the first section of that file answers. The map names every entry point; it explains none of them. `AgentPrism.LocalReference.md` answers the next question by pointing at what is already on your disk: the XML documentation each package carries into the NuGet cache, where every entry point carries a worked example, and the OpenAPI document that `AgentPrism.AspNetCore` ships. One file is written beside each project, not one at the repository root: a solution that splits a web host from a worker gives each project a different set of packages, and one shared file could hold only one of those answers. The paths are specific to your machine and to the versions that project restored, so the file is regenerated on every build and belongs in `.gitignore` — the template's `.gitignore` already covers it. ## Storage and testability | Capability | Choice | |---|---| | Zero-infrastructure start | In-memory implementations for every required core store | | PostgreSQL persistence | `UsePostgreSql()`; durable contracts plus pgvector knowledge search | | SQL Server persistence | `UseSqlServer()`; durable contracts without vector knowledge search | | SQLite persistence | `UseSqlite()`; durable single-node or local use with a native SQLite dependency | | Store replacement | Register your implementation before AgentPrism; `TryAdd*` preserves the consumer registration | | Provider-free tests | `AgentPrism.Testing.FakeModelProvider` scripts deterministic model turns | | Integrated tests | `AgentPrismTestHost` builds a real catalog and in-memory stores | | Assertions | `RunAssertions` checks recorded runs without binding to a unit-test framework | In-memory stores make every contract usable before any database exists, and the consumer's own registration always wins over the built-in one. Use [Compatibility](/reference/compatibility/) before you choose packages for a target framework or native AOT application. Use [Configuration](/reference/configuration/) for verified section names and defaults. ## Read next - [Choosing packages](/packages/) — which of these capabilities each package carries - [Your first agent](/getting-started/first-agent/) — the smallest application that uses any of them - [Configuration](/reference/configuration/) — the section names and defaults behind every row above --- # Agents and definitions An agent is **data**: a name, a model binding, instructions, and the names of the tools, skills, and other agents it may use. The compiler turns that data into a MAF `AIAgent`. ```csharp new AgentDefinition { Name = "support", DisplayName = "Support Assistant", Instructions = "You are a support assistant. Answer briefly and clearly.", Model = new ModelBinding { Provider = "openai", Model = "…" }, ToolNames = ["get_order_status"], SkillNames = ["refund-policy"], CallableAgentNames = ["billing"], } ``` Because it is data, it can be edited without a deployment — and versioned, diffed, rolled back, and A/B tested. That is the whole reason for the shape. ## Culture-keyed instructions `InstructionsByCulture` maps a culture tag (`"en"`, `"tr"`) to its own instructions text. `POST /api/agents/{name}/run` accepts an optional `culture` field; the compiler resolves it in this order: 1. An exact match (`culture: "tr"` → the `"tr"` entry) 2. The requested culture's parent subtag (`"tr-TR"` → the `"tr"` entry) 3. `Instructions` — the default, used whenever nothing else matches An unmatched culture never fails the run; it falls back to the default. The `Accept-Language` HTTP header is not consulted — a browser header silently changing the content sent to the model would be a surprise, so the culture is always an explicit field on the request. A compiled agent is cached per resolved culture: two runs of the same agent in different cultures never share a compiled instance. ## Where agents come from The catalog merges two sources, in priority order: ```mermaid flowchart LR accTitle: Agent catalog sources accDescr: Code registrations and database definitions merge into one catalog, with code definitions winning name collisions. C["Code
AddAgent(definition) or AddAgent(name, factory)"] --> CAT["IAgentCatalog"] D["Database
definitions written through the API"] --> CAT CAT --> R["ResolveAsync(name)"] ``` `AddAgent(name, factory)` is a code registration too — the factory returns a MAF `AIAgent` directly, built however you want, and the catalog still applies AgentPrism's decorators (recording, telemetry, approval) when it resolves the agent. On a name clash the higher-priority source wins and the other is dropped from the list. **Code wins.** That is why the API refuses to store a definition under a name a code agent already uses: the stored definition would never resolve, and a silent shadow is worse than a `409`. A code-defined agent has no stored definition and no version history — its history is your source history. The API reports that distinctly: `GET /api/agents/{name}` still returns `200`, with `definition` set to `null` and `isEditable` set to `false`. The console checks `isEditable` to decide whether to offer an edit form. ## Compiling a definition ```mermaid flowchart LR accTitle: Agent definition compilation accDescr: A saved definition resolves its model, tools, skills, and callable agents, then produces the Microsoft Agent Framework AIAgent. D["AgentDefinition"] --> M["Model provider registry
→ IChatClient"] D --> T["Tool registry
→ AIFunction[]"] D --> S["Skill catalog"] D --> G["Callable agents
→ child invokers"] M -.->|"unknown provider"| E["compilation error"] T -.->|"unknown tool"| E S -.->|"unknown skill"| E G -.->|"unknown agent"| E M --> A["AIAgent"] T --> A S --> A G --> A ``` Every name must resolve. An unknown tool, skill, provider, or callable agent is a compilation error, not a run-time surprise. The same check runs on the **save** path, so a definition that could not run is rejected when it is written. `POST /api/agents/validate` runs it without saving and without calling any model — useful in CI. A validation failure there is not an HTTP error: the response is `200` with a report, because "this definition is invalid" is an answer, not a transport failure. Compiled agents are cached by name, version, and a fingerprint of their dependencies, so changing a skill invalidates the agents that use it. ## Versions Every save appends a version rather than overwriting. Nothing rewrites history: - `GET /api/agents/{name}/versions` — full snapshots, newest first, not deltas - `GET /api/agents/{name}/versions/{a}/diff/{b}` — both snapshots, verbatim; the diff is computed by the client, because the server takes no position on presentation - `POST /api/agents/{name}/rollback` — writes the old content as a **new** version Rollback moving forward is the point: the rollback is itself auditable and can be rolled back in turn. Versions are also what make [experiments](/concepts/evaluation/) possible — an A/B test splits traffic between two versions of the same agent, which is why a code-defined agent cannot be experimented on. ## Agents calling agents List other agents in `CallableAgentNames` and the compiler wraps each one in a child invoker and hands them to MAF's background agents provider. The model starts a task, waits, and reads the result. ```mermaid flowchart TD accTitle: Callable agent task execution accDescr: A root run starts a bounded child-agent task, persists child results, and returns the result to the root agent through a generated tool. P["root run · depth 0"] --> T["start task"] T --> CI["child invoker
depth · budget · tenant checks"] CI -->|"allowed"| CR["child run · depth 1
its own run row"] CI -->|"refused"| X["a readable error as the tool result
no run row is created"] CI --> E["child.started / child.completed
on the root stream"] ``` Four rules hold across the tree: - Every run in the tree shares one budget, so a tree cannot spend more than a single run was allowed - Every run in the tree shares one trace id, and only the root owns the trace buffer - A child runs in the **same tenant**; a tenant change refuses the call - A child **cannot ask for approval** — a child run that tries fails The whole tree is readable with `GET /api/runs/{runId}/tree`, from any member. ## Read next - [Runs and recording](/concepts/runs/) - [Tools, skills, and MCP](/concepts/tools/) --- # Evaluation and experiments Four ways to find out whether an agent is any good. They answer different questions and are meant to be used together. | | Question | When it runs | |---|---|---| | **Eval suites** | Did this change break anything? | On demand, against a fixed case set | | **Online judges** | Is production drifting? | Continuously, on sampled live runs | | **Human feedback** | What do people think? | Whenever someone scores a run | | **Experiments** | Is version B better than A? | Live, splitting real traffic | ## Eval suites A suite names the agent under test and carries **declarative checks** — a JSON array stored and read as one unit with the suite. Create or update the suite before adding cases: ```bash curl -X PUT http://localhost:5081/agentprism/api/evals/support \ -H 'Content-Type: application/json' \ -d '{ "agentName": "support", "checks": [{"kind":"toolCalled","tools":["get_order_status"]}] }' ``` A suite needs at least one check before it can run — an empty `checks` array fails the run outright instead of reporting every case as passed. Six built-in kinds cover the common cases, matched directly to `Microsoft.Agents.AI.EvalChecks` factories: | Kind | Checks | Fields | |---|---|---| | `nonEmpty` | The response has at least `minLength` characters | `minLength` (default 1) | | `containsExpected` | The response contains the case's `expectedOutput` | `caseSensitive` (default false) | | `keywords` | The response contains every string in `values` | `values`, `caseSensitive` | | `toolCalled` | The listed `tools` were called, `all` or `any` of them | `tools`, `mode` (default `all`) | | `toolCallsPresent` | At least one tool was called | — | | `hasImageContent` | The response carries image content | — | Application code can add more with `IAgentPrismBuilder.AddEvalCheck(kind, check)` — a named MAF `EvalCheck` that becomes usable under a custom kind name alongside the six built-in ones. A kind that matches neither fails the run with a clear error instead of being silently skipped. Cases are a separate, ordered list of queries: ```bash curl -X PUT http://localhost:5081/agentprism/api/evals/support/cases \ -H 'Content-Type: application/json' \ -d '[{"query":"Where is order 4182?","expectedOutput":"shipped"}]' ``` That `PUT` is a **full replacement**: cases missing from the body are removed, so send the whole list every time. Sequence numbers come from the body's order, which means reordering re-numbers the cases and past results then line up with different ones. Treat the list as ordered data, not a set. `expectedOutput` reaches `containsExpected`; a case also carries an `expectedTools` field for record-keeping, but the tool names a `toolCalled` check verifies come from the suite's own check definition, shown above. Running a suite queues a job. Each case runs in its own fresh session against the agent and produces its own run row, so a failing check can be traced to the exact conversation that produced it. ```bash curl -X POST http://localhost:5081/agentprism/api/evals/support/run curl http://localhost:5081/agentprism/api/evals/support/runs ``` Cases can also be **promoted from a real run** — a production conversation that went wrong becomes a regression case in one request. The query comes from the run's `RunStarted` event, so failed and sessionless runs can be promoted. A run from a multi-turn session is accepted only when it has no previous turn; otherwise a single query cannot represent the conversation that produced the answer. Promoting the same run twice returns the existing case rather than duplicating it. ## Online evaluation Register an `IRunJudge` and finished runs are sampled and scored automatically. The summary endpoint reports the average score, the sample count, and what the judging cost. That summary is **in-memory** and resets when the process restarts. For an authoritative number, query the stored scores. Judging costs model calls, which is why it samples rather than scoring everything. `POST /api/runs/{runId}/judge` scores one run immediately, skipping the sampling decision — for calibration and debugging. The built-in judge is configured with `ModelRunJudgeOptions`: `Criteria` states the standard to score against, and `Instructions` replaces the judge prompt when the default wording does not fit your domain. ## Human feedback Scores can be attached to a run, or to a single message in it. Human scores and judge scores live in **one** list with a source field on each entry, not in separate endpoints, so "what do we think of this run" is one question. Deleting a score is written to the audit trail: removing a judgement is itself traceable. ## Experiments An experiment splits traffic between **two versions of the same agent**. Since code-defined agents have no version history, they cannot be experimented on. ```mermaid flowchart LR accTitle: Experiment version assignment accDescr: An eligible agent request is assigned to the current or candidate version by a stable hash, then records that assignment on the run. REQ["POST /api/agents/support/run"] --> ASSIGN{"a Running experiment
for this agent?"} ASSIGN -->|no| CUR["current version"] ASSIGN -->|yes| SPLIT["assign an arm by weight"] SPLIT --> VA["version A"] SPLIT --> VB["version B"] VA --> REC["recorded with its arm"] VB --> REC ``` Variant weights must sum to 100, and only one experiment per agent can be `Running` at a time. Assignment happens **only** on `POST /api/agents/{name}/run` — the OpenAI-compatible endpoints and child-agent calls do not go through it, which keeps the comparison to traffic you meant to split. The results endpoint gives per-arm counts, error rates, tokens, and durations. It makes **no statistical claim about a winner**; it shows the raw numbers and leaves the judgement to you. Stopping affects new runs only. A run already in flight keeps its arm, results stay readable, and the agent becomes free for another experiment. Deleting a `Running` experiment is refused — stop it first, so traffic is never split against a definition that no longer exists. ### Canary rules A two-arm experiment can carry a canary rule: one arm is the canary and the other is the control. The evaluation is **not persisted** — it is recomputed from current run results on every read, so it never reports a stale verdict. ## Read next - [Agents and definitions](/concepts/agents/) — versions, which experiments need - [Governance](/concepts/governance/) --- # Governance Governance is explicit and visible. Tenancy, quotas, rate limits, retention cleanup, and content guards need configuration. Audit decorators and their default store are registered by `AddAgentPrism()`; authentication only changes which actor name they can record. ## Multi-tenancy Off by default. Turned on, the tenant is resolved in a fixed order: ```mermaid flowchart TD accTitle: Tenant resolution order accDescr: AgentPrism first uses an API key tenant, then configured claim or header tenancy, and otherwise resolves the built-in default tenant. K{"authenticated with an API key?"} -->|yes| KT["the key's tenant"] K -->|no| S{"tenancy enabled?"} S -->|no| D["default tenant"] S -->|yes| C{"claim type configured?"} C -->|yes| AU{"request authenticated?"} AU -->|yes| CL["the claim's value"] AU -->|no| D C -->|no| H{"header resolution allowed?"} H -->|yes| HD["the header's value"] H -->|no| D CL --> V{"valid format · allowlisted?"} HD --> V V -->|yes| T["tenant resolved"] V -->|no| D ``` Two rules are worth reading twice. **An API key outranks everything.** A key *proves* a secret; a claim or a header only *asserts* one. If a key and a header disagree, the request is refused with `403` before it reaches any endpoint. **If a claim type is configured, the header is never read.** Otherwise an authenticated user could reach another tenant's data by adding a header. The header path also has to be enabled explicitly — an HTTP header is not proof of identity. Isolation is enforced by contract tests that check it in both directions, across the in-memory store and all three SQL providers, with a coverage gate requiring every public store method to be either tested or exempted with a documented reason. ### Attributing spend below the tenant The tenant answers "whose data is this". Two further questions — which **user** spent this, and which **job** it was spent on — are answered by `IRunAttributionContext`, the sibling interface described in [Runs](/concepts/runs/#who-ran-it-and-for-what). The security property is the same one the tenant header has: the value is never taken from the run request body. A `userId` field there would let any client write spend against another user's name and forge the cost record outright, so the body is not a source of attribution at all — the server resolves it from your identity pipeline, and a `userId` sent in the body is ignored. The recorded user id is an **opaque string**. AgentPrism does not resolve it, does not validate it, and stores no personal detail of its own; what it identifies is your application's decision. :::caution Erasure does **not** match on `runs.user_id`. `IDataSubjectResolver` is the only thing that knows which subject a value belongs to — AgentPrism deliberately holds no mapping — so a resolver must return those runs itself. Find them with `GET /api/runs?userId={id}&includeChildren=true` and include their ids in the scope's `RunIds`. Erasing a run row removes its `user_id` along with everything else on it. ::: ## Per-tenant provider credentials and egress By default every tenant shares the model provider credential a `Use...()` call registered at startup — one key, one bill, one usage pool. A tenant can instead bring its own key (BYOK): its usage and its bill stay separate. A record for this binds a tenant and a provider to the **name** of a configuration key, never to the key's value — the value is read from `IConfiguration` only at call time and is never written to a database, a log line, or an HTTP response. A tenant with no binding for a provider keeps using the shared setup-time credential; nothing changes until an administrator writes one, and a binding whose configuration key carries no value does not fall back to the shared key silently — the run fails with a clear error, so a misconfigured tenant is never billed against the wrong account. An egress policy narrows which providers a tenant's agents may call at all. A tenant with no saved policy is unrestricted; saving one is an additive restriction. Naming a forbidden provider in an agent definition is rejected **at compile time** — before any request reaches the network — and writing a credential binding for a forbidden provider is rejected too, so the two surfaces cannot disagree. Both are managed under `/api/tenants/{tenantId}/providers` and `/api/tenants/{tenantId}/egress`, guarded by the `SecurityAdmin` API key scope. See [Per-tenant credentials](/guides/model-providers/#per-tenant-credentials-byok) for the full HTTP contract. ## The audit trail Who changed what, when, and from what to what. Agent definitions, MCP servers, tenants, approval rules, quotas, retention policies, and approval decisions all land in it. Runs do **not** — the run history already holds the full record. The one exception is a content guard's block decision, which is a governance decision rather than a run detail and must stay traceable after retention deletes the run. Writes happen in store **decorators**, not in endpoints, so no code path can change one of those entities without an entry. The actor comes from your authentication. With none configured the actor is `null`, and that is not hidden. Before anything is written it passes a secret filter: any field whose name contains `apiKey`, `authorization`, `password`, `secret`, or a singular `token` has its value replaced with `***`. Plural `tokens` — count fields like `maxOutputTokens` — is deliberately excluded. ```bash curl 'http://localhost:5081/agentprism/api/audit?action=agent.update' curl 'http://localhost:5081/agentprism/api/audit/quota:{id}' ``` ### Tamper detection Every entry carries a hash of its own content and the hash of the entry before it, chained per tenant. `GET /api/audit/verify` walks the chain and reports one of three outcomes: | Status | Meaning | |---|---| | `Valid` | Every entry's hash matches its content and links to the one before it | | `Broken` | An entry's stored hash no longer matches its content — it was altered after it was written | | `Gap` | A link between two entries is missing — a row was deleted, or a write never completed | `Broken` and `Gap` both name the first entry where the chain fails. ```bash curl 'http://localhost:5081/agentprism/api/audit/verify' # {"status":"Valid","entriesChecked":42,"firstFailingEntryId":null} ``` :::note[Entries written before this feature ships have no hash] They are excluded from the walk rather than misreported as tampered — a chain starts at the first entry written after upgrading, not retroactively. ::: ## Approvals Two shapes, matching how the run was started. **In-band.** A streaming run that hits a tool needing approval carries the request in its stream and the decision in the next turn. **The mailbox.** A queued run stops at `AwaitingApproval` and the request waits in `GET /api/approvals/pending`, with the tool's recorded arguments for the approver to read and an absolute expiry. Deciding either way **resumes** the run — the model has to see a result or a refusal and continue. And the decision opens a **new** run; the one that stopped is never rewritten. :::note[The audit entry is written before the decision is applied] Everywhere else an audit failure is swallowed. Not here: an approval decision that cannot be recorded is not applied at all. Approvals and skill scripts are the only two places with that inversion, and both are places where the missing record would be the whole problem. ::: Standing decisions are **approval rules** — a pre-approval for a tool. They do not expire. `GET /api/approvals/rules` is the list to review periodically, because each entry is a tool call that will never ask again. A rule narrows its scope one of three ways: to one exact set of arguments (a hash, written by the "don't ask again" flow above), to a set of argument **conditions** (for example `amount <= 100`, written with `POST /api/approvals/rules`), or not at all — matching every call of the tool. A rule carries a hash or conditions, never both. Conditions are comparisons, never expressions: a dotted path into the arguments, one operator from a closed set (`Equals`, `NotEquals`, `GreaterThan`, `GreaterThanOrEqual`, `LessThan`, `LessThanOrEqual`, `In`, `NotIn`), and a value. All of a rule's conditions must match — there is no `OR`; write two rules instead. A condition fails closed: an unresolved path, a missing argument, or a type mismatch (text `"100"` does not satisfy a numeric rule) all mean the call still asks for approval. ```mermaid flowchart TD accTitle: Approval decision order accDescr: A code-defined policy runs first and can force or waive approval; only when it is undecided do the persisted data rules decide, falling back to asking the user. A["tool call requiring approval"] --> B{"code policy registered?"} B -->|"no"| D["data rules"] B -->|"yes"| P["policy runs"] P --> R{"result"} R -->|"Required"| ASK["ask the user"] R -->|"NotRequired"| GO["run without asking"] R -->|"Undecided"| D D --> M{"a rule matches?"} M -->|"yes"| GO M -->|"no"| ASK ``` A **code-defined policy**, registered with `builder.AddToolApprovalPolicy("refund_order", context => ...)`, runs before the data rules and can override them in both directions. Code is a security boundary; the data rules are writable from the UI and are not allowed to loosen a policy that says `Required`. An unhandled exception in a policy is treated as `Required` and logged — a broken policy never silently releases a tool from approval. ### Tool authorization Approval and authorization answer different questions. Approval asks "is this call okay this time" and stops to wait for a person; authorization asks "can this caller call this tool at all" and answers instantly from `IToolAuthorizationHandler` — your own policy, checked before approval and before the call's timeout even starts. A denied call does not fail the run: the model gets the reason as an ordinary tool result and continues its turn. See [Tools, skills, and MCP](/concepts/tools/#authorization-and-timeout) for the interface and an example. ## Quotas and rate limits Two different mechanisms, deliberately not merged. Rate limits work at second and minute scale in memory; quotas work at day and month scale in the database. Neither refuses anything by default: rate limiting is off, and quotas are empty until a rule exists. An agent with no matching rule is unlimited. A rule sets any of three limits — runs, tokens, or cost — over a period, scoped to the tenant or to one agent. Exceeding one returns `429` with which quota was hit and when the counter resets. :::caution[Counters are approximate] The check happens **before** a run starts; consumption is written **after** it finishes. A run already in progress is never cut off mid-flight, so brief overshoot is possible by design. ::: ## Retention Recorded runs accumulate. A retention policy sets an age or row limit per target — run events, tool calls, traces, jobs, webhook deliveries, eval results, checkpoints, attachments, sessions, and more. A database policy takes precedence. When none exists and retention is enabled in configuration, AgentPrism falls back to its target defaults, including 30 days for run events and 14 days for spans. With retention disabled, nothing is removed. A policy with `enabled: false` is configured but paused. When a policy has `archive: true`, cleanup first sends its batch to the registered `IArchiveSink`. If no sink exists, no rows are deleted. This fail-safe trades storage growth for protection from silent data loss. No endpoint deletes synchronously. Preview first — it is the only way to see the size of a deletion before it happens — then run, which queues a job. ```bash curl 'http://localhost:5081/agentprism/api/retention/preview' curl -X POST 'http://localhost:5081/agentprism/api/retention/run' curl 'http://localhost:5081/agentprism/api/retention/history' ``` The history of what was deleted is itself never cleaned up. ## Data subject rights Retention removes data by **age**. Export and erasure remove it by **identity** — a data subject's own sessions, runs, and conversations, on request (a GDPR-style "right to erasure"). AgentPrism does not store personal identity itself: `sessions.id` is a value your own application chose, and only your application knows which session, run, or conversation belongs to which end user. You supply that mapping by registering an `IDataSubjectResolver`: ```csharp public sealed class MyResolver : IDataSubjectResolver { public ValueTask ResolveAsync( string subjectId, string tenantId, CancellationToken cancellationToken = default) => new(new DataSubjectScope { SessionIds = LookUpSessionIds(subjectId), RunIds = LookUpRunIds(subjectId), ConversationIds = LookUpConversationIds(subjectId), }); } builder.Services.AddSingleton(); ``` Without a resolver registered, both endpoints return `409` — never a silent empty result that could be misread as "already erased". ```bash curl 'http://localhost:5081/agentprism/api/data-subjects/user-42/export' curl -X DELETE 'http://localhost:5081/agentprism/api/data-subjects/user-42' curl -X DELETE 'http://localhost:5081/agentprism/api/data-subjects/user-42?dryRun=false' ``` :::caution[`dryRun` defaults to `true`] A bare `DELETE` previews the row counts per target and deletes nothing. `?dryRun=false` is required to actually erase. ::: Erasure removes the session, run, conversation, attachment, score, and voice-session rows that belong to the subject — including summarized conversation messages, which retention otherwise keeps forever. It never touches the audit trail: an audit record is "who did what", not the subject's own data, and stays intact and verifiable after an erasure. The erasure itself **is** written there, with the row count per target; if that write fails, the whole erasure rolls back. Export returns every matching row, keyed by target, as one JSON document. Attachment file bytes are not included — only their metadata. ## Content guards An `IContentGuard` inspects content going to and coming from the model. Decisions are `Allow`, `Mask`, or `Block`, and **the strictest decision wins**. Off by default: with no guard registered the wrapper is never added and the measured cost is zero. The guard sits **inside** the tool-call loop, above the raw client. A tool result re-enters the model on a second call, and a guard outside the loop would never see it. Blocked content never reaches the provider network and does not trip the circuit breaker. Recording stores the placeholder `[content_blocked]`, while the audit entry records the guard, rule, and direction without the blocked text. :::note[Masked content stays masked] Input preview runs before the recording path. When a guard returns `Mask`, the model, recorded input, and run events receive the masked value. AgentPrism does not retain a hidden raw copy for later inspection. ::: ## Webhooks Subscribe to events and AgentPrism posts them to your endpoint. The signature is `HMAC-SHA256(timestamp + "." + body, secret)`, with the timestamp inside the signature so a replay cannot be reused. Your receiver decides the tolerance window. The secret is never stored: the subscription carries the **name** of the configuration key it is read from. Delivery goes through the job queue, so a `test` call reports that it was queued, not how it went. The delivery history has one entry per event carrying the latest status and attempt count — retries update that entry rather than adding rows. Address validation happens inside the socket connect callback, so the address validated is the address connected to. See [securing the endpoints](/getting-started/security/) for why. ## Read next - [Securing the endpoints](/getting-started/security/) - [The HTTP API](/http-api/) --- # Architecture AgentPrism sits between your application and the Microsoft Agent Framework. It adds a catalog, a compiler, a recording layer, an HTTP surface, and a console — and it adds nothing between you and MAF's own types. ## The layers ```mermaid flowchart TD accTitle: AgentPrism architecture layers accDescr: The embedded console and HTTP API use the control plane, which coordinates model providers, runtime execution, and replaceable stores. APP["Your ASP.NET Core application"] UI["AgentPrism.UI
embedded React console"] HTTP["AgentPrism.AspNetCore
management API · OpenAI-compatible endpoints
access layers · SSE"] PROV["Providers
OpenAI · Anthropic · Google · Azure · Voice"] STORE["Persistence
PostgreSQL · SQL Server · SQLite"] OPT["Optional
Workflows · MCP"] CORE["AgentPrism.Core
catalog · compiler · tool registry
run recording · session manager · in-memory stores"] ABS["AgentPrism.Abstractions
contracts"] MAF["Microsoft Agent Framework
AIAgent · AgentSession · ChatMessage · AIFunction"] APP --> HTTP HTTP --> UI HTTP --> CORE PROV --> CORE STORE --> CORE OPT --> CORE CORE --> ABS --> MAF ``` The dependency direction is one-way and has no cycles: every provider and persistence package points at `Core`, `Core` points at `Abstractions`, and `Abstractions` points at MAF. Nothing points back. An architecture test enforces it, so a reference that would break the picture fails the build rather than the review. Workflows and MCP are the interesting case: they do **not** reference the HTTP layer, and the HTTP layer reaches them only through abstractions. That is what keeps them optional — without the workflow engine registered, the workflow *execution* endpoints answer `501` while definition management keeps working. ## Four rules Everything else follows from these. ### No surprises `AddAgentPrism()` works alone. Without a configured database every store falls back to memory. A developer installs the package, writes one line, and has a working console. A database is never required, and neither is any particular model vendor — OpenAI, Anthropic, Google, Azure OpenAI, and any OpenAI-compatible endpoint (including a self-hosted engine like Ollama or vLLM) all work side by side. ### Tools are defined in code only The console can create an agent; it can never write tool *code*. If it could, anyone who reached the console could execute code on your server. There are exactly two deliberate exceptions, both described in [tools](/concepts/tools/) with their guards: remote **MCP servers**, where the process runs somewhere else and AgentPrism is only a client, and **skill scripts**, where the process runs on this machine — the strictest exception, off by default, behind six sequential gates. In both, a console user enables an existing capability rather than writing new code. That distinction is the rule. ### MAF objects are passed through, not wrapped `AIAgent`, `AgentSession`, `ChatMessage`, and `AIFunction` are used directly. No parallel type hierarchy is laid on top of them. Wrapping would create maintenance debt with every MAF release and cut you off from the MAF ecosystem. AgentPrism is a *control plane*, not an *abstraction layer*. ### Every extension point is replaceable All services register with `TryAdd`. Register your own implementation before calling `AddAgentPrism()` and yours wins. The same holds for MAF's own hosting types, which is why interfaces like conversation storage can be swapped out. ## Where things live | | | |---|---| | Contracts, records, enums | `AgentPrism.Abstractions` | | Catalog, compiler, recording, in-memory stores | `AgentPrism.Core` | | Endpoints, access layers, OpenAI compatibility | `AgentPrism.AspNetCore` | | Schema, migrations, vector search | `AgentPrism.PostgreSql` and friends | | The console | `AgentPrism.UI` | See [choosing packages](/packages/) for which to install. ## Read next - [Agents and definitions](/concepts/agents/) — what an agent is here - [Runs and recording](/concepts/runs/) — what gets written, and when - [Governance](/concepts/governance/) — tenancy, audit, quotas, retention --- # Runs and recording A **run** is one execution of an agent. Recording is on by default for agents resolved through the AgentPrism catalog, whether the call came from HTTP, the console, a workflow, an eval, or your code. You can disable it. A failed store write also leaves the agent running, so recording is best-effort rather than an availability dependency. ## How recording happens `IAgentCatalog.ResolveAsync` never returns a bare agent. What it hands back is wrapped in a chain of decorators: ```mermaid flowchart LR accTitle: Agent execution decorator order accDescr: Run recording wraps telemetry, approvals, guards, online evaluation, and the inner agent in a fixed outer-to-inner order. REC["RunRecordingAgent
order 0 — outermost"] --> OTEL["OpenTelemetryAgent
order 10"] OTEL --> APR["ToolApprovalAgent
order 20"] APR --> AGENT["the compiled AIAgent"] ``` The order is deliberate. **Recording is outermost** so the time it measures includes everything the inner layers spend. **Approval is innermost**, closest to the model call — outside it, telemetry would count the wait for a human as part of its own duration. Decorators are plain `DelegatingAIAgent` wrappers rather than MAF middleware, because middleware is per-agent and a harness agent adds its own inner decorators. An outer wrapper behaves identically for every agent type. :::note[Recording never breaks a run] If the run store fails, the run continues and the error is logged. Observability does not get to break function. The same rule holds for the audit trail — with one deliberate exception, described in [governance](/concepts/governance/). ::: ## What a run carries The summary — `GET /api/runs/{runId}` — has status, timings, token counts, error class, and cost when pricing is configured. It does not carry the conversation. The conversation is the **event stream**, written with gapless sequence numbers by a single writer: ```mermaid stateDiagram-v2 accTitle: Recorded run event lifecycle accDescr: A run starts, emits zero or more message and tool events, then ends exactly once as completed, failed, cancelled, or awaiting input. [*] --> RunStarted RunStarted --> MessageDelta RunStarted --> ToolInvoking MessageDelta --> MessageDelta MessageDelta --> ToolInvoking ToolInvoking --> ToolInvoked ToolInvoking --> ToolFailed ToolInvoked --> MessageDelta ToolFailed --> RunFailed MessageDelta --> MessageCompleted MessageCompleted --> RunCompleted RunCompleted --> [*] RunFailed --> [*] ``` One writer producing the numbers is what makes the live stream and a later replay identical, and it is what lets a client resume with `Last-Event-ID` after a dropped connection. ```bash curl -N http://localhost:5081/agentprism/api/runs/{runId}/events ``` Tool calls are also written individually — name, arguments, result, duration, error — so "which tool failed and with what input" is a query, not a log search. A reasoning model's thinking is a separate event type, `ReasoningDelta`, never merged into `MessageDelta`. It is off by default — reasoning output can run far longer than the answer, and it can restate user input in a form the final answer never shows: ```csharp services.Configure(options => options.RunRecording.RecordReasoningDeltas = true); ``` With it off, a reasoning model still streams its thinking to the caller in real time — this setting only controls whether it is **recorded**. ## Observing events beyond the store Register an `IRunEventSink` to receive every event as it is written, in addition to the store — a live dashboard, a message queue, a second archive: ```csharp public sealed class QueueRunEventSink(IMessageQueue queue) : IRunEventSink { public async ValueTask OnEventAsync(RunEvent runEvent, CancellationToken cancellationToken = default) => await queue.PublishAsync(runEvent, cancellationToken); } services.AddSingleton(); ``` A sink runs on the hot path — queue and return, do not block on further I/O — and one instance serves every concurrent run, so it must be thread-safe. A sink that throws is disabled for the rest of that run and logged; neither the store write nor any other registered sink is affected. Register none and nothing changes. ## Who ran it, and for what A run also records **who** it belongs to and **which job** it was made for. Both answer questions the tenant cannot: a tenant tells you whose data this is, not which of that tenant's users spent the money. Neither value is ever read from the run request body. A `userId` field on `POST /api/agents/{name}/run` would let any client write spend against another user's name, so the body is not a source of attribution at all. The value comes from `IRunAttributionContext`, which your application binds to its own identity pipeline: ```csharp public sealed class ClaimsRunAttributionContext(IHttpContextAccessor accessor) : IRunAttributionContext { public string? UserId => accessor.HttpContext?.User.FindFirst("sub")?.Value; public IReadOnlyDictionary? Labels => accessor.HttpContext?.Request.Headers.TryGetValue("X-Job", out var job) == true ? new Dictionary { ["job"] = job.ToString() } : null; } // Registered BEFORE AddAgentPrism(); AgentPrism uses TryAdd, so yours wins. builder.Services.AddSingleton(); ``` Register nothing and nothing changes: both columns stay `NULL` and no behaviour differs. For work that runs outside a request — a queued job, a scheduled run, a direct .NET call — use the ambient scope instead: ```csharp using (AmbientRunAttributionScope.Begin("user-42", labels: null)) { await agent.RunAsync("summarise this ticket"); } ``` The user id is an **opaque string**. AgentPrism neither resolves nor validates what it means and stores no personal detail of its own — the same stance the data-subject erasure flow takes, which covers this column too. Labels are bounded on purpose: at most **8** per run, keys up to **64** characters, values up to **256**. Breaking a limit **rejects the request with 400**; nothing is trimmed to fit, because a trimmed label set still reads as a complete measurement to whoever queries the report later. :::caution Labels and user ids are **query** dimensions, not **metric** dimensions. They live in the `runs` table and are never added to `agentprism.tokens` or `agentprism.run.cost` — promoting a free-form label set to a metric tag has no upper bound on time-series cardinality. ::: Both are filters on the run list and breakdowns in the summary: ```bash curl "http://localhost:5081/agentprism/api/runs?userId=user-42" curl "http://localhost:5081/agentprism/api/runs?label=team:payments" curl "http://localhost:5081/agentprism/api/stats" | jq '.byUser, .byLabel' ``` `byLabel` rows do **not** sum to `totalRuns`: a run carrying three labels appears in three of them. A label set is not a partition of the runs. ### Starting a run from .NET with explicit identity `AgentPrismRunOptions` is the .NET-side counterpart of the run request. It is not a configuration section: it is passed per call, and all but the last property answer "which run is this, and where does it sit in a larger story". | Property | What it sets | |---|---| | `RunId` | The identifier to record this run under. Supply your own when the caller already has one; otherwise AgentPrism generates it | | `ParentRunId` · `RootRunId` · `Depth` | The run's place in a call tree. The child-agent invoker fills these in; set them yourself only when you drive a tree by hand | | `AgentVersion` | The definition version this run used, when you resolved a specific one | | `ExperimentId` | The experiment this run is a sample of, so results group correctly | | `ReplayOfRunId` | The original run this one replays, which is what makes a comparison possible | | `SessionId` | The session at the root of the tree. It feeds the run scope, not the run row's own `session_id` | | `Kind` · `Variant` · `Budget` | The run's kind, its experiment variant, and the shared budget a call tree draws from | | `BeforePendingApprovalIsPublished` | A callback that runs immediately before a run closes as `AwaitingApproval`, on the streaming and the buffered path alike. Record the approval request here | Leave every property unset for an ordinary run: AgentPrism then records a root run with a generated id, and the values above are filled in by the components that own them. The last one is a hook rather than an identity, and it exists because the status and the request become visible at different moments. A run closes inside the agent call, so without it the status is published first and [`GET /api/approvals/pending`](/http-api/approvals/) answers an empty list for a run that already says it is waiting. The callback receives the messages the run produced, and anything it throws fails the run — an `AwaitingApproval` status whose request was never recorded is unanswerable. ## Three ways to start a run | | How | Response | |---|---|---| | **Streaming** | `POST /api/agents/{name}/run` | `text/event-stream`, one frame per event | | **Deduplicated** | the same call with `Idempotency-Key` | a single JSON response — a replay cannot be reconstructed from a stream | | **Queued** | the same call with `Prefer: respond-async` | `202 Accepted` and a `Location` header | | **Triggered** | `POST /api/triggers/{tenantId}/{name}`, signed by an external system | `202 Accepted` and a `Location` header | A queued run behaves the same once a worker picks it up. The difference shows at the end: if a queued run needs a tool approval it closes as `AwaitingApproval` and the request lands in the approval mailbox, whereas a streaming run carries the approval in its next turn. A triggered run is a queued run under the hood — same placeholder row, same worker — started by a signed HTTP request instead of a management API caller. See [Inbound triggers](/guides/inbound-triggers/). :::caution[A closed run is never rewritten] A run that ended `AwaitingApproval` stays that way forever. Deciding the approval opens a **new** run with the same session and a new run id. History is append-only, so what you read a week later is what actually happened. ::: ## Errors are classified A failed run carries an error class, not just a message: a provider outage, a content filter, a blocked guard decision, a quota, a timeout. Two of them are deliberately distinct — `ContentFiltered` means the *provider's* filter cut the response, while `ContentBlocked` means *your* guard refused it. The operator response differs: one is a provider setting, the other is your policy. `GET /api/stats` aggregates the classes, so a rise in one bucket is visible before anyone reports it. ## Replay and comparison - `GET /api/runs/{runId}/input` — the recorded input, when input recording is on - `GET /api/runs/{a}/compare/{b}` — both summaries, verbatim; the client shows the comparison - `POST /api/runs/{runId}/replay` — create a sessionless, single-turn replay. The default `ReplayTools` mode reuses recorded tool results; `NoTools` produces only the model response; `LiveTools` can repeat real side effects and therefore requires Admin - `POST /api/runs/{runId}/judge` — score a run with the registered judges, skipping the sampling decision, for calibration See [Reliable runs](/guides/reliability/) for idempotency, cancellation, reconciliation, replay constraints, and failure handling. ## Read next - [Sessions and conversations](/concepts/sessions/) - [Evaluation and experiments](/concepts/evaluation/) --- # Sessions and conversations A **session** is where a conversation's state lives between turns. A **run** is one turn. They are separate on purpose: a session has many runs, and a run can happen without one. ## The lifecycle ```mermaid sequenceDiagram accTitle: Session conversation lifecycle accDescr: A caller sends a session id, AgentPrism loads conversation history, invokes the agent, appends new items, and returns the response. autonumber participant Caller participant Manager as AgentSessionManager participant Store as ISessionStore participant Agent as AIAgent Caller->>Manager: GetOrCreateSessionAsync(agent, sessionId) Manager->>Store: GetAsync(sessionId) alt a record exists Store-->>Manager: SessionRecord Manager->>Agent: DeserializeSessionAsync(state) else no record Store-->>Manager: null Manager->>Agent: CreateSessionAsync() end Agent-->>Manager: AgentSession Manager->>Manager: stamp the id into the session state Manager-->>Caller: AgentSession Caller->>Agent: RunAsync(message, session) Caller->>Manager: SaveSessionAsync(agent, session) Manager->>Store: SaveAsync(record) ``` The caller drives it. AgentPrism does not decide when a conversation starts or ends. The identity stamp matters: the session id is written into the session's own state bag, so it survives serialization. A restored session knows which session it is, which is how the recording layer can put the right session id on a run without being told. ## Reading a conversation back `GET /api/sessions/{sessionId}` returns metadata plus `messages` — but `messages` is `null` when the configured storage cannot expose a readable history. With in-memory storage the history lives inside an opaque provider blob; `state` always carries that raw blob, and it is not a chat log. With a SQL provider the history is stored as ordered items, so messages come back in sequence order. That ordering is not cosmetic: the index of a message **is** the sequence number the branch endpoint takes. ## Branching `POST /api/sessions/{sessionId}/branch` copies items up to and including a sequence number into a **new** conversation and opens a session on it. ```mermaid flowchart LR accTitle: Conversation branch operation accDescr: Branching copies parent conversation items through a selected sequence into a new conversation and opens a new session on that copy. P["parent conversation
items 0..9"] -->|"branch at 4"| B["new conversation
copy of items 0..4"] B --> S["new session"] P -.->|"provenance only"| B ``` The items are **copied**, not shared. Writing to the branch never changes the parent, and the pointer back to the parent is provenance, nothing more. This is what "try the same conversation with a different agent from turn five" looks like. Branching needs a SQL provider. On in-memory storage there are no addressable items to copy, and the endpoint answers `501` rather than pretending. :::note[Only the session screen can branch mid-conversation] The playground folds its transcript from a live event stream, and those events carry no sequence numbers. Branching from the playground therefore copies the **whole** conversation. The session screen reads stored items and can branch at any point. ::: ## OpenAI-compatible conversations `/v1/conversations` maps onto the same sessions. Two behaviours are worth knowing: - A conversation id is a **reservation**. An id that has never carried a call is still valid and answers `200`. So a `404` means "not yours", not "never used" — an id owned by another tenant is reported as missing rather than forbidden, so the API does not confirm that it exists. - `previous_response_id` and `conversation_id` are treated as untrusted input. Tenant ownership is verified on every use. ## Attachments Attachments are uploaded independently and referenced from messages; the bytes live in storage and only a small reference travels with a message. The upload's type is decided by inspecting its magic bytes, not by the `Content-Type` the client claims. Deleting a session deletes the attachments it owns. You can also call `DELETE /api/attachments/{id}` for one attachment, and the orphan-attachment retention target cleans uploads that never become part of a session. Individual deletion is a hard delete: an older message that still contains the reference will no longer be able to download the bytes. The link is deliberately not a database foreign key because an upload can exist before its session does. Downloads are served with `Content-Disposition: attachment` and `X-Content-Type-Options: nosniff` together, so uploaded HTML can never execute in the console's origin. ## Read next - [Runs and recording](/concepts/runs/) - [Attachments and multimodal input](/guides/multimodal/) - [Workflows](/concepts/workflows/) --- # Tools, skills, and MCP An agent gains capability in a few ways. They differ in where the code lives and who is allowed to add it. | | What it is | Where the code runs | |---|---|---| | **Tools** | Methods in your codebase | Your process | | **Client-side tools** | A declaration in your codebase, no server-side body | The caller's process (typically a browser) | | **Skills** | Markdown instructions plus resources | Nowhere — they are text | | **MCP tools** | Tools published by a remote MCP server | Someone else's process | ## Tools A tool is a method you wrote, registered at startup. See [adding a tool](/getting-started/tools/) for the mechanics. The rule that governs the whole design: **tools are defined in code only**. The console lets a user *select* from registered tools; it never defines one. If it could, anyone who reached the console could execute code on your server. Wrapping for approval happens in the **registry**, not at the call site. The registry is the single place where "an agent may only point at a registered tool" is enforced, so no other code path can skip the wrapper. ### Authorization and timeout Two more wrappers apply next to approval, in a fixed order: **authorization** (outermost), **timeout**, then **approval** (innermost), then the real method. Authorization asks a different question than approval. Approval asks "is this call okay this time" and stops to wait for a person. Authorization asks "can this caller call this tool at all" and answers instantly from your own policy — implement `IToolAuthorizationHandler` and register it; the default allows every call, so an application that registers nothing keeps today's behavior exactly. ```csharp public sealed class MyAuthorizationHandler : IToolAuthorizationHandler { public ValueTask AuthorizeAsync( ToolAuthorizationRequest request, CancellationToken cancellationToken = default) => request.RequiredPermission is "orders.cancel" && !CallerHasPermission(request) ? ValueTask.FromResult(ToolAuthorizationResult.Deny("You cannot cancel orders.")) : ValueTask.FromResult(ToolAuthorizationResult.Allow()); } services.AddSingleton(); ``` A denied call does not fail the run: the model receives the reason text as an ordinary tool result and continues its turn — the same way a search that finds nothing is not an error. If your handler throws, the call is denied (fail-closed), never allowed. `[AgentPrismTool]` also carries an effect class and a per-tool timeout: ```csharp [AgentPrismTool( "cancel_order", "Cancels an order.", RequiresApproval = true, Effect = ToolEffect.Destructive, RequiredPermission = "orders.cancel", TimeoutSeconds = 30)] public static string CancelOrder(string orderId) => ...; ``` `Effect` (`Read`/`Write`/`Destructive`/`External`) is information, not a gate — the console shows it as a badge, and the audit trail records it. A call that outlives its timeout does not fail the run either: the model sees a tool error and continues, the same as a denial. `CancellationToken` is cooperative, so a tool body that never reads its own token is not forcibly stopped — only the *wait* is cut short; the timeout applies to execution only, never to a pending approval, which can wait indefinitely. ## Client-side tools `AddClientTool(name, description, jsonSchema)` registers a tool the SAME way — the declaration lives in code — but with no body at all. The model can still call it; the server returns the pending call to the caller instead of running anything, and the caller answers it on the next request. See [Client-side tools and the embeddable widget](/guides/client-side-tools/) for the full mechanism and the chat widget built on it. ## Skills A skill is markdown with frontmatter, optionally carrying resources — reference text the agent can pull in. Skills are tenant-scoped and editable from the console, because they are *instructions*, not code. An agent lists skills by name. Deleting a skill an agent still names is a real break: compiling that agent then fails with "the skill was not found" until the reference is removed or the skill is recreated. Check which agents use a skill before deleting it. ### Skill scripts — the strict exception A skill may also carry **scripts**, and this is the second deliberate exception to the code-only rule. Unlike MCP, the process runs **on this machine**. It is off by default and can only be turned on in code, with a mandatory acknowledgement flag, an interpreter allowlist that starts empty, and skill roots given in code. Every execution passes six gates in order, and if any is closed the process never starts: ```mermaid flowchart LR accTitle: Skill script security gates accDescr: A skill script runs only after enabled, tenant grant, extension allowlist, path, budget, and runner checks all pass in order. G1["1. enabled"] --> G2["2. valid grant
for this tenant"] G2 --> G3["3. extension on the
interpreter allowlist"] G3 --> G4["4. argument size
and schema"] G4 --> G5["5. written to
the audit trail"] G5 --> G6["6. concurrency quota"] G6 --> RUN["separate process
clean environment · stdin args
timeout · output limit"] ``` :::danger[Gate five is an exception to an exception] Everywhere else in AgentPrism an audit-trail write failure is swallowed, because observability must not break function. Here it is not: a script execution that cannot be written to the audit trail would be remote code execution with no record of it, so the run is refused. ::: Grants are visible and revocable at `GET /api/skill-script-grants`. A grant without a script name covers every script in a skill; one with a name covers only that script. Grants can expire. :::caution[AgentPrism does not sandbox] It provides **no** filesystem jail, network restriction, memory or CPU quota, or privilege dropping. All four belong to the hosting environment — a container, cgroups, and an unprivileged user. The acknowledgement flag exists so the feature cannot be enabled without seeing this: with execution on and the flag off, the application fails at **startup**. ::: ## MCP servers Registering a remote MCP server means accepting tool definitions from outside, which is the first deliberate exception to the code-only rule. The process runs elsewhere; AgentPrism is only a client. Five guards: 1. **`http` and `https` only — there is no stdio transport.** Starting a local process would break the rule outright. 2. **`RequiresApproval` defaults to true** for tools discovered this way. 3. **A remote tool whose name collides with a code-registered tool is ignored.** Your code always wins; a remote server cannot shadow a local tool. 4. **The registration stores no credential.** It stores the *name* of the configuration key the value is read from at call time. 5. **Every call is recorded** with the source server's name. Prompts fetched from an MCP server are a **snapshot** an administrator copies into the console — an agent never pulls one live. Resource access is limited to the URI set the server itself advertises; accepting arbitrary URIs would be an SSRF tool. OAuth tokens are held in memory per tenant and server and are **never written to the database**. ## Knowledge Separate from tools: documents are chunked, embedded, and searched by vector distance. This needs PostgreSQL — the other providers answer `501` on those endpoints. `POST /api/knowledge/{collection}/search` runs the same retrieval an agent performs, which makes it the way to separate a retrieval problem from a prompt problem. If the right chunk does not come back there, the agent was never going to see it. ## Read next - [Governance](/concepts/governance/) — approvals, audit, and limits - [Workflows](/concepts/workflows/) --- # Workflows A workflow runs several agents together. Where a callable agent is one agent using another as a tool, a workflow is an orchestration you define and can watch. Workflows need `AgentPrism.Workflows`. Without the engine registered, definition management still works and only the execution endpoints answer `501` — the package stays optional on purpose. ## Five patterns | Kind | What it does | |---|---| | `Sequential` | Agents run in order; each output is the next input | | `Concurrent` | Agents run at the same time; results are merged | | `Handoff` | One agent starts and hands off when needed — the **model** decides | | `GroupChat` | A manager distributes turns among participants | | `Magentic` | A manager plans, tracks progress, and replans; a manager agent is required | `MaxIterations` bounds the turn count for `Handoff`, `GroupChat`, and `Magentic` — the only structural guard against two agents handing off to each other forever. ```csharp new WorkflowDefinition { Name = "triage", Kind = WorkflowKind.Handoff, AgentNames = ["frontline", "billing", "technical"], } ``` Which fields are required depends on the kind, and the definition is validated when it is **saved** using the same rules the compiler applies. A shape that could not run is rejected at write time rather than on the first execution. ## Function nodes A real pipeline has steps that are not AI calls — a file download, a format conversion, a database write. `AddWorkflowFunction` registers one by name: ```csharp agentPrism.AddWorkflowFunction, List>( "word-count", services => (messages, context, cancellationToken) => { var text = messages[^1].Text; return ValueTask.FromResult>([new(ChatRole.User, $"{text}\n\n({text.Split(' ').Length} words)")]); }, "Appends a word count. Runs no model call."); ``` A `Sequential` definition's `Nodes` list can then mix that name in with catalog agents, in order: ```csharp new WorkflowDefinition { Name = "summarize-and-count", Kind = WorkflowKind.Sequential, Nodes = [ new WorkflowNodeReference { Name = "summarizer", Kind = WorkflowNodeKind.Agent }, new WorkflowNodeReference { Name = "word-count", Kind = WorkflowNodeKind.Function }, ], } ``` `Nodes` and `AgentNames` are mutually exclusive — a definition sets one or the other. Only `Sequential` supports function nodes: the ready-made builders for the other four patterns accept only agents. The factory passed to `AddWorkflowFunction` runs once, when the function registry is built — not once per run. Every workflow compile shares the same handler closure, so **the handler must be thread-safe**: a captured counter or non-thread-safe client needs its own guard. :::note[Code only, same boundary as tools] A function's body is never written from the UI or the database — only its *name* crosses that boundary, the identical shape `AgentDefinition.ToolNames` already uses for tools. `GET /api/workflows/functions` lists what is registered, for a picker to choose from. ::: A function node opens no `runs` row of its own and contributes nothing to cost or token totals — it made no model call. `ExecutorInvoked` / `ExecutorCompleted` / `ExecutorFailed` still fire for it, same as any node, so it is visible in the run's event stream and colored live in the graph. :::caution[The handler must be idempotent] Resuming from the run's *latest* checkpoint after it already completed does not call the handler again — there is nothing left to run. Resuming from an **earlier** checkpoint (the shape a real crash recovery takes) replays the super-step that follows it, and the handler runs again with the same input. A handler with a real side effect must tolerate being called more than once. ::: Workflows can also be built in code with MAF's own builder. :::caution[The registration key is what routing uses] When you add a workflow in code, `AddWorkflow("approval-flow", …)` is the key every URL resolves against. A different name passed to the builder's `WithName(…)` is cosmetic — and an inconsistency between the two produces a silent `404` or a timeout rather than an error. Use the same string in both places. ::: ## Running one ```bash curl -N -X POST http://localhost:5081/agentprism/api/workflows/triage/run \ -H 'Content-Type: application/json' \ -d '{"message":"My invoice is wrong and the app crashes"}' ``` Events stream over SSE. The first frame reports the run id, and **every agent invoked inside the workflow opens its own run row** — so the whole thing is readable as a tree with `GET /api/runs/{runId}/tree`, with each agent's tokens and duration attributed separately. `GET /api/workflows/{name}/graph` returns the compiled graph. Node ids are identical to the executor ids in the run events, which is how the console colours nodes live as the workflow progresses. The response also carries MAF's generated Mermaid text. ## Checkpoints A workflow writes checkpoints as it goes, controlled by `AgentPrism:Workflows:EnableCheckpointing` (default `true`). `GET /api/workflows/runs/{runId}/checkpoints` lists them and `POST /api/workflows/runs/{runId}/resume` continues from one — omit the id to resume from the latest. Resuming opens a **new** run. The original is never rewritten, so "what happened, then what we did about it" stays two readable records rather than one edited one. Checkpoints survive a process restart only with a SQL provider registered (`UsePostgreSql()`, `UseSqlServer()`, or `UseSqlite()`). The in-memory store keeps at most 50 checkpoints per session and drops the oldest — enough for local development, not for a workflow you expect to resume after a restart. They are also a retention target, so an old run may have none left even on durable storage. Turning off `EnableCheckpointing` does not silently disable resumption: a workflow that stops to wait for a human answer fails outright instead of hanging with no way to resume. ## Asking a human A workflow can stop and wait for input: ```mermaid flowchart LR accTitle: Durable workflow input cycle accDescr: A workflow request closes the run as awaiting input, writes a checkpoint, then resumes from that checkpoint in a new run after a response. RUN["run"] --> ASK["executor raises a request"] ASK --> WAIT["run closes as AwaitingInput
a checkpoint is written"] WAIT --> LIST["GET .../requests"] LIST --> RESP["POST .../respond"] RESP --> NEW["a NEW run resumes from the checkpoint
events stream over SSE"] ``` Requests are read from the run's own event stream — there is no separate table — and only a run in `AwaitingInput` has any. The answer is matched to the request that is re-published with the same id when execution resumes. This is the same append-only shape as tool approvals: the waiting run stays as it was, and the response opens a new one. ## Read next - [Runs and recording](/concepts/runs/) — reading the tree - [Evaluation and experiments](/concepts/evaluation/) --- # Your first agent Two ways in. The template writes a working application for you; the manual path shows you what the template wrote. ## With the template ```bash AGENTPRISM_VERSION=1.0.0-preview.N # replace N with the published preview dotnet new install "AgentPrism.Templates@$AGENTPRISM_VERSION" dotnet new agentprism-api -o MyAgents cd MyAgents ``` Pinning the template version makes the generated package references reproducible. The project template has five options: | Option | Values | Default | |---|---|---| | `--persistence` | `memory`, `postgres`, `sqlite`, `sqlserver` | `memory` | | `--provider` | `openai`, `anthropic`, `google`, `azure` | `openai` | | `--ui` | `true`, `false` | `true` | | `--AgentPrismVersion` | A NuGet version or version range | `*-*` (latest preview) | | `--skipRestore` | `true`, `false` | `false` | For example: ```bash dotnet new agentprism-api -o MyAgents \ --persistence postgres \ --provider openai \ --ui true \ --AgentPrismVersion "$AGENTPRISM_VERSION" ``` Set your key — it never goes in a file that gets committed: ```bash dotnet user-secrets set "AgentPrism:Providers:OpenAI:ApiKey" "sk-…" dotnet run ``` Open the address `dotnet run` prints, with `/agentprism` on the end — the template listens on `http://localhost:5081` by default. The first build also writes `AGENTS.md` at the root of your repository: the AgentPrism capability map, for a coding agent working in the project. An existing file is never overwritten, and [the property that writes it](/troubleshooting/#agentsmd-does-not-appear) can be removed from the project file. ## By hand ```bash dotnet new web -o MyAgents cd MyAgents dotnet add package AgentPrism --prerelease dotnet user-secrets init dotnet user-secrets set "AgentPrism:Providers:OpenAI:ApiKey" "sk-…" ``` ```csharp title="Program.cs" using AgentPrism; var builder = WebApplication.CreateBuilder(args); var agentPrism = builder.AddAgentPrism() .UseOpenAI(builder.Configuration.GetSection(OpenAIProviderOptions.SectionName)) .UseUI(); agentPrism.AddAgent(new AgentDefinition { Name = "support", DisplayName = "Support Assistant", Description = "Answers order and shipping questions.", Instructions = "You are a support assistant. Answer briefly and clearly.", Model = new ModelBinding { Provider = OpenAIProviderNames.ChatCompletions, Model = "…", // today's model name, from your provider's documentation }, }); var app = builder.Build(); app.MapAgentPrism("/agentprism"); app.Run(); ``` ```bash dotnet run ``` :::note[Why the model name is a blank] AgentPrism ships no built-in model list and pins no model name. Provider catalogues change faster than a NuGet release, and a hard-coded name would be wrong within months. Take the current name from your provider's documentation, or put it in `appsettings.json` under `AgentPrism:Providers:OpenAI:DefaultModel`. ::: ## Run it **In the console.** Open `/agentprism`, pick **Playground**, choose `support`, and send a message. The reply streams in; tool calls appear as cards with their arguments and results. **Over HTTP.** The same run, as a server-sent event stream: ```bash curl -N -X POST http://localhost:5081/agentprism/api/agents/support/run \ -H 'Content-Type: application/json' \ -d '{"message":"Where is order 4182?"}' ``` **From an OpenAI client.** The compatible endpoint accepts the familiar wire format. Point the client at AgentPrism, provide its authentication, and use the agent name as the `model`: ```bash curl -X POST http://localhost:5081/agentprism/v1/responses \ -H 'Content-Type: application/json' \ -d '{"model":"support","input":"Where is order 4182?"}' ``` Here `model` is the *agent* name. Which model it actually calls is the agent's business, not the caller's. ## Look at what happened Run recording is on by default for agents resolved through the catalog. In the console, open **Runs**: status, duration, token counts, cost when pricing is configured, and the ordered event stream. Over HTTP it is the same data: ```bash curl http://localhost:5081/agentprism/api/runs curl http://localhost:5081/agentprism/api/runs/{runId} curl -N http://localhost:5081/agentprism/api/runs/{runId}/events ``` Nothing extra was configured to make that happen. Recording can be disabled. A store failure is also best-effort: it is logged and the agent still runs, so observability cannot take down product functionality. ## What you have An agent defined in code, a console, and a recorded history — with no database. Every store is in memory, so all of it ends when the process does. ## Read next - [Adding a tool](/getting-started/tools/) — let the agent do something - [Persistence](/getting-started/persistence/) — make it survive a restart - [Securing the endpoints](/getting-started/security/) — before it leaves your machine --- # What AgentPrism is AgentPrism is a **control plane** for agents built with the Microsoft Agent Framework (MAF). You bring the agents; it gives you the layer around them — a place to define them, an HTTP API to drive them, a record of every run, and a console to look at all of it. It is a set of NuGet packages, not an application. It runs inside your ASP.NET Core process, using your configuration, your authentication, and your database. ## What you get | | | |---|---| | **Definitions** | An agent as data: model, prompt, tools, skills, callable agents. Versioned, with rollback | | **Runs** | Default-on recording — status, timings, tokens, cost, tool calls, traces, and an ordered event stream | | **HTTP API** | 143 generated operations, plus OpenAI-compatible Responses and Chat Completions surfaces | | **Console** | 27 screens embedded when you add `AgentPrism.UI` and call `UseUI()` | | **Workflows** | Multi-agent execution with checkpoints and human-in-the-loop | | **Evaluation** | Suites, cases, automatic judges, and A/B experiments between agent versions | | **Governance** | Roles, scoped API keys, tenancy, approvals, guards, quotas, retention, webhooks, and audit | See the [complete capability map](/capabilities/) for providers, testing, RAG, voice, scheduling, external protocols, and production operations. ## What it deliberately is not **It is not an abstraction over MAF.** `AIAgent`, `AgentSession`, `ChatMessage`, and `AIFunction` are used directly and appear in the public API as themselves. There is no parallel type hierarchy to learn and nothing between you and MAF's own extension points. **It is not a place to write code.** Tools are defined in your codebase and nowhere else. An agent can be created and edited from the console, but tool *code* can never be written through it — see [tools](/concepts/tools/) for the two narrow, guarded exceptions. **It is not a hosted service.** There is no account, no telemetry leaving your process, and no dependency on anything you do not run yourself. That includes the model call itself: point a provider at a cloud API, or at a self-hosted engine such as Ollama or vLLM on your own network — see [picking a model provider](/packages/#picking-a-model-provider). ## Four rules it will not break These hold everywhere in the codebase, and knowing them explains most of the API. 1. **No surprises.** `AddAgentPrism()` works alone. Without a database every store falls back to memory, so the first thing you write runs without infrastructure. 2. **Tools are code only.** The console selects from registered tools; it never defines them. 3. **MAF objects are passed through, not wrapped.** 4. **Every extension point is replaceable.** Everything registers with `TryAdd`, so your own implementation registered first always wins. ## Is it for you? It fits when you are building agents in .NET and want the operational layer without building it: a record of what happened, a console for the people who did not write the code, and a way to change an agent without a deployment. It does not fit if you want a hosted agent product, or if you are not on .NET. Runtime packages target `net8.0`, `net9.0`, and `net10.0`; the testing and template packages require .NET 10, and the source generator that ships inside Core targets `netstandard2.0`. ## Read next - [Your first agent](/getting-started/first-agent/) — a working application in about five minutes. --- # Persistence Without a database every store is in memory and everything ends with the process. That is deliberate — it makes the first agent work with no infrastructure — but it is not where you stop. ## Pick one ```csharp builder.AddAgentPrism() .UsePostgreSql(connectionString); // AgentPrism.PostgreSql ``` | Package | Choose it when | |---|---| | `AgentPrism.PostgreSql` | The default. The only one with vector search for knowledge | | `AgentPrism.SqlServer` | You already run SQL Server | | `AgentPrism.Sqlite` | One node, or a durable local development setup | All three implement the same store contracts and pass the same shared contract tests. Their operational limits differ: only PostgreSQL supports Knowledge, only PostgreSQL keeps the AOT promise, and SQLite is a single-node choice. :::caution[The connection string is a secret] It never belongs in `appsettings.json`. Use `dotnet user-secrets` in development and the environment or a secret store in production. The same rule runs through the whole product: a webhook or MCP registration stores the *name* of the configuration key its secret is read from, never the value. ::: Binding from configuration is the usual shape: ```csharp .UsePostgreSql(builder.Configuration.GetSection(AgentPrismPostgreSqlOptions.SectionName)) ``` ```json title="appsettings.json" { "AgentPrism": { "PostgreSql": { "ConnectionString": "", "SchemaName": "agentprism", "AutoApplyMigrations": true, "CommandTimeoutSeconds": 30, "EnableKnowledge": false } } } ``` ## How each provider isolates its tables | Provider | Namespace | Migration coordination | |---|---|---| | PostgreSQL | Separate `agentprism` schema by default | `pg_advisory_lock`, scoped to the schema | | SQL Server | Separate `agentprism` schema by default; your `dbo` objects stay untouched | `sp_getapplock`, scoped to the schema | | SQLite | No schema support; `agentprism_` table prefix by default | A sidecar file lock next to the database | Rename `SchemaName` or `TablePrefix` when your conventions require it. A bare SQLite `Data Source=:memory:` connection is rejected because each opened connection would see a different database; use a shared in-memory URI for tests. :::note[Knowledge is opt-in and needs pgvector] The migration set that creates the `vector` extension and the `document_embeddings` table only applies when `EnableKnowledge` is `true` — off by default, so a managed PostgreSQL instance without permission to install extensions works with no configuration at all. Turn it on only if an agent uses Knowledge: ```csharp .UsePostgreSql(options => { options.ConnectionString = connectionString; options.EnableKnowledge = true; }) ``` With it off, an agent definition that sets `Memory.EnableVectorSearch` fails compilation with a clear error instead of a database error at run time. Embedding `Dimensions` become part of the column type: changing embedding models later needs a schema migration and a re-embed of existing documents. See [Knowledge](/guides/knowledge/). ::: ## Migrations run at startup The SQL files ship embedded in the assembly and are applied when the application starts. Two properties make that safe with several instances starting at once: - The runner takes the provider-specific lock shown above, so instances serialize instead of racing. - Each applied file's SHA-256 is recorded. If the content later differs from what was applied, startup **fails loudly** rather than running against a schema that is not what the code expects. :::danger[Do not edit an applied migration] The checksum covers the file's whole text, comments included. Editing a file that has already been applied makes every existing database refuse to start. Add a new migration instead. If you must take such a change, drop and recreate the schema in that environment first. ::: Set `AutoApplyMigrations = false` when schema changes are their own deployment step. AgentPrism then verifies but does not write. The diagnostics endpoint can report whether the schema is current, but it is deliberately not mapped by default because it exposes setup details: ```csharp app.MapAgentPrism("/agentprism", options => { options.EnableDiagnosticsEndpoint = true; }); ``` After that opt-in, `GET /agentprism/api/diagnostics` is an Admin surface and still passes through the configured access layers. ## What changes once it is durable Runs, events, and tool calls survive restarts, so the console shows real history rather than the current process. Sessions can be read back as chat history rather than an opaque blob — which is also what makes branching a conversation possible. Queued runs, schedules, evals, experiments, and quotas all become usable, since they depend on state outliving a request. ## Keeping it from growing forever A recorded run is data, and recorded runs accumulate. Retention policies set an age or row limit per target — run events, tool calls, traces, jobs, webhook deliveries, eval results, checkpoints, and more. Database policies take precedence. When no database policy exists and `AgentPrism:Retention:Enabled` is true, configuration falls back to built-in target defaults, such as 30 days for run events and 14 days for spans. With retention disabled, nothing is deleted. An `archive: true` policy also deletes nothing when no `IArchiveSink` is registered; data loss is the failure mode the worker avoids. Cleanup runs through the job queue. Preview a policy before you execute it: ```bash curl 'http://localhost:5081/agentprism/api/retention/preview' curl -X POST 'http://localhost:5081/agentprism/api/retention/run' ``` ## Durability also enables governance A durable `audit_log` can be **verified**: `GET /api/audit/verify` walks a hash chain and reports whether any entry was altered or deleted after it was written. And because sessions, runs, and conversations are real rows now, a data subject's content can be found and erased by identity, not just aged out — see [Data subject rights](/concepts/governance/#data-subject-rights). ## Read next - [Securing the endpoints](/getting-started/security/) — required reading before this leaves your machine. --- # Securing the endpoints By default AgentPrism is reachable from `localhost` and nowhere else. That is the safe state, and an application that maps it and forgets to configure anything is not exposed. Everything below is about leaving that state on purpose. ## The layers Four independent layers, applied in this order. Use as many as you need. ```mermaid flowchart TD accTitle: HTTP authorization layers accDescr: A request passes authentication, role policy, optional API-key scope, and optional tenant isolation before an endpoint runs. REQ["Incoming request"] --> META{"path = /api/meta ?"} META -->|yes| OK["Endpoint runs"] META -->|no| POL{"Authorization policy set?"} POL -->|"set, fails"| F403["403 Forbidden"] POL -->|"unset or passes"| LB{"Remote access off
and caller not loopback?"} LB -->|yes| F403b["403 Forbidden"] LB -->|no| HDR{"Authorization header present?"} HDR -->|no| TOKU{"AuthToken configured?"} TOKU -->|yes| F401["401 Unauthorized"] TOKU -->|no| OK HDR -->|yes| TOK{"Matches the static token?"} TOK -->|yes| OK TOK -->|no| KEY{"Valid API key?"} KEY -->|no| F401 KEY -->|yes| SC{"Endpoint needs a scope
the key lacks?"} SC -->|yes| F403c["403 Forbidden"] SC -->|no| OK ``` ### 1. The loopback restriction On by default. A request from anywhere but loopback gets `403`. It exists to make accidental exposure impossible, so turning it off should be a deliberate line in a review: ```csharp app.MapAgentPrism("/agentprism", options => options.AllowRemoteAccess = true); ``` :::danger Never turn this on without a token, an API key, or a policy behind it. On its own it publishes your agents — and the ability to run them — to anyone who can reach the port. ::: ### 2. A bearer token A single shared token, compared in constant time: ```csharp options.AuthToken = builder.Configuration["AgentPrism:AuthToken"]; ``` Good enough for one operator or a private network. It cannot be revoked individually, carries no identity, and gives everyone the same rights. ### 3. API keys Issued from `/api/api-keys`, stored hashed, scoped per capability, revocable, and optionally expiring. Each key belongs to a tenant. Scopes **narrow** a role, they never widen it: what a caller may do is the intersection of its role and its key's scopes. Scope values use the closed JSON enum; for example, a key with only `RunsWrite` cannot administer agents no matter what role the caller has. The [compatibility reference](/reference/compatibility/#api-key-scopes) lists all 17 values and the capability each one grants. A key also proves which tenant is calling — which is why it outranks any claim or header for tenant resolution. A secret is proof; a header is a claim. ### 4. An authorization policy The production path. Hand AgentPrism a policy name and it runs inside your own authentication pipeline: ```csharp options.RequireAuthorization("AgentPrismAdmin"); ``` ## Roles Three policy names — `Reader`, `Operator`, `Admin` — that you bind to your own claims: | Role | Can | |---|---| | Reader | Read agents, runs, sessions, traces, statistics | | Operator | Reader, plus start runs, decide approvals, delete sessions | | Admin | Everything: write definitions, add MCP servers, manage tenants and approval rules, read the audit trail | AgentPrism stores no users and no roles. If a policy is not registered in your application, that endpoint group simply falls back to the layers above — so upgrading never breaks a working deployment. Turn on `RequireRolePolicies` and a missing policy becomes a **startup** error instead of a silent gap. :::caution[Reverse proxies change the network boundary] The loopback rule sees the connection presented to ASP.NET Core. Configure trusted forwarded headers and HTTPS at the proxy before you use the apparent client address as a boundary. In production, require role policies even when the proxy already authenticates users. ::: ## Two deliberate exemptions **`/api/meta`** answers without authentication. The console has to learn which authentication method to present before it can ask for anything. It returns no sensitive data. **The console shell** — its HTML, JavaScript, and CSS — is exempt from the bearer layer only. A browser cannot attach an `Authorization` header to a ` ``` `registerTool` supplies the **implementation** for a tool the server already declared by name — the widget never sees a schema, and it cannot invent a new tool. The handler may be synchronous or return a `Promise`; if the model calls a tool with no registered handler, the widget answers with an `errorMessage` on your behalf instead of leaving the call hanging. The widget carries its own small dictionary (English and Turkish), independent from the console's — pulling in the console's translations would blow its budget for a handful of strings. ### The identity it needs `data-api-key` is a [tenant API key](/getting-started/security/) scoped to `RunsWrite`, not the management bearer token. The token that unlocks the console must never reach a browser outside your own network; a scoped, revocable API key is the credential meant for exactly this. ## Read next - [Add a tool](/getting-started/tools/) — the server-side default - [Tools, skills, and MCP](/concepts/tools/) — where each capability's code actually runs - [Security](/getting-started/security/) — API keys, scopes, and the three-layer access model --- # Coding agents A coding agent cannot use a capability it does not know exists. It will write a retry loop around a chat client, hand-roll an approval queue, or invent a cost table — carefully, and for no reason, because AgentPrism ships all three. AgentPrism closes that gap from inside the build, without a service to run or an index to keep in sync. Three files land in your repository or beside your project, and seven compiler diagnostics speak up when an agent writes something the package already covers. ## Turn it on One MSBuild property, **off by default**. Set it where your project file can see it — the project itself, or a `Directory.Build.props` at the repository root: ```xml true ``` That turns on both files. `AgentPrismWriteLocalReference` follows it unless you set it yourself, so you can keep the capability map and skip the machine-specific reference: ```xml true false ``` The project template sets the first property, so a project created with `dotnet new agentprism-api` already has both files. ## What each file is for ```mermaid flowchart LR accTitle: What a coding agent reads, and which question each file answers accDescr: The build writes the capability map and the local reference. The local reference names the map on disk, so a repository that keeps its own instructions reaches it through one pointer line. The site copies serve an agent with no checkout. BUILD["dotnet build"] --> MAP["AGENTS.md
repository root
written only when absent"] BUILD --> LOCAL["AgentPrism.LocalReference.md
beside each project"] OWN["Your own AGENTS.md
one line naming that file"] --> LOCAL MAP --> Q1["What capability exists,
and what call turns it on"] LOCAL --> Q1 LOCAL --> Q2["Exact paths to the XML docs
and the HTTP API document"] SITE["llms.txt · llms-full.txt"] --> Q3["The map, a one-line page index,
and the full text, for an agent
with no checkout"] ``` ### `AGENTS.md` — the capability map Written once to your **repository root**, under 10 KB, and read by most coding agents at the start of a session. It names every registration entry point, the package it lives in, and the rule each capability group obeys. It is written **only when the file does not already exist**. Your own `AGENTS.md` is never overwritten, never merged, and never reformatted. ### If you already have an `AGENTS.md` Most repositories do, which means the map above is never written and the copy inside the package is never found. Do not copy the capability list into your file — it would be a second copy to maintain, and it would go stale the first time you upgrade. Two steps instead. First, ask for the pointer file on its own; this writes nothing at your repository root and never touches your `AGENTS.md`: ```xml true ``` Then add one line to your own file: ```markdown AgentPrism: read AgentPrism.LocalReference.md beside each project for the capability map and the API documentation of the installed version. ``` The pointer cannot go stale: the file it names is rewritten on every build, and its first section is the absolute path to the capability map in your NuGet cache. `APG0402` fires while that line is missing — but **only once the property above is on**, because until then there is no file to point at. It looks for the exact file name anywhere in `AGENTS.md`; prose, a list, or a code fence all count. ### `AgentPrism.LocalReference.md` — the exact paths Written **beside each project** that references AgentPrism, on every build, and regenerated rather than merged — so add it to `.gitignore`. It answers the second question an agent asks, "how exactly is this called", by pointing at documentation already on the machine: - one XML documentation file per referenced AgentPrism package, at the version this project restored; - the packaged HTTP API document, when the project references `AgentPrism.AspNetCore`. The paths are machine-specific and version-specific, which is the point: an agent that greps them reads the signatures of the version you actually installed, not a newer or older one from the web. ```bash grep -A 12 "AddToolApprovalPolicy" \ "$(grep -m1 -o '/.*AgentPrism\.Core\.xml' AgentPrism.LocalReference.md)" ``` ### `llms.txt` and `llms-full.txt` — for an agent with no checkout The same capability map, plus one line per documentation page, plus the full text of every page — three sizes for three questions, published on the documentation site: - [`llms.txt`](/llms.txt) — the capability map, then **which page answers what**: one line per hand-written page, with its title, address, and subject. About 17 KB. - [`llms-full.txt`](/llms-full.txt) — every guide, concept, and reference page concatenated, about 400 KB. The middle layer is the one to use. The map names a capability but does not explain it; the index names the one page that does, and reading that page costs a fraction of the full text. The capability map lists both addresses, so an agent that only has the shipped copy still knows they exist. The generated .NET and HTTP API references are deliberately **not** in either file. That surface belongs to the compiler and the XML documentation; putting it in a text file would burn a context window and answer nothing the local reference cannot. ## Keeping the map current Upgrade the package and the map goes stale — it describes the capabilities of the version that wrote it. The refresh is two steps and needs no new tool: ```bash rm AGENTS.md dotnet build ``` `APG0401` tells you when this is due, so you do not have to remember. ## The diagnostics Seven diagnostics in the `AgentPrism.Usage` category. They are **warnings**, not suggestions, for one measured reason: an `Info` diagnostic never appears in `dotnet build` output at any verbosity, and build output is the only channel a coding agent reliably reads. | Id | Fires when | What it teaches | |---|---|---| | `APG0101` | `MapAgentPrism()` is called but `AddAgentPrism()` is not | The mapped endpoints have no catalog to serve; the app fails at startup | | `APG0102` | A model binding names a built-in provider the compilation never registers | Call the matching `Use…()`, or register a custom `IModelProvider` | | `APG0201` | A literal secret is written into a definition | Store the **name of the configuration key**; definitions reach backups, the audit trail, and the console | | `APG0301` | A retry loop is written by hand around a chat client | Hand retries hide failures from the circuit breaker and never reach the binding's fallbacks | | `APG0302` | An agent is wrapped without any `IAgentDecorator` in the compilation | A hand-applied wrapper misses database-defined agents; a decorator does not | | `APG0401` | `AGENTS.md` was generated from an older capability map | Delete it and build again | | `APG0402` | The local reference file is written, and your own `AGENTS.md` never names it | An agent reading it cannot reach the capability map on this machine; add one line | A separate family, `APG0001`–`APG0007`, validates tool registration itself and comes from the source generator. Both families carry a help link into the [capability map](/capabilities/). ### Turning them off One property switches off the whole `AgentPrism.Usage` family by adding it to `$(NoWarn)`: ```xml false ``` To silence a single diagnostic instead, use `.editorconfig` as you would for any analyzer: ```ini [*.cs] dotnet_diagnostic.APG0301.severity = none ``` :::caution With `TreatWarningsAsErrors` enabled, these warnings break the build — which is the intended outcome for `APG0101` and `APG0201`, both of which describe a defect that fails at run time or leaks a secret. Narrow the severity of the one you disagree with rather than switching off the family. ::: ## What this is not It is not a service, an index, or a plugin. Nothing runs outside `dotnet build`, no process listens, and no content is uploaded anywhere. Delete the files and unset the property and the only thing you lose is the map. It also does not make an agent's output correct. The map says what exists; whether a capability suits your case is still a judgement call, and the guides on this site are written for the human making it. ## Read next - [Capability map](/capabilities/) — the source the generated map is built from - [Troubleshooting](/troubleshooting/#build-diagnostics-and-the-agent-map) — when a diagnostic fires and you disagree - [Your first agent](/getting-started/first-agent/) — the template that turns this on --- # Context and memory “Memory” is not one store. AgentPrism separates conversation continuity, context budgeting, working state, fixed resources, and semantic knowledge. Choose each layer for the question it answers. ## Mental model: five different jobs | Layer | Question it answers | Main API | |---|---|---| | Session history | What did this conversation already say? | `AgentRunRequest.SessionId` | | Compaction | Which old context still fits in the next model call? | `CompactionSettings` | | Working memory | What files, todos, or text should this agent manage during work? | `MemorySettings` | | MCP resources | Which fixed remote resources enter every run? | `AgentDefinition.McpResourceUris` | | Knowledge search | Which durable external facts are relevant to this question? | `MemorySettings.EnableVectorSearch` | ```mermaid flowchart LR accTitle: Agent context assembly accDescr: Session history is compacted, then combined with working memory, fixed MCP resources, and knowledge results before the model request. Q["Run request"] --> H["Session history"] H --> C["Compaction"] W["Working memory"] --> X["Model context and tools"] R["MCP resources"] --> X K["Knowledge search tool"] --> X C --> X X --> M["Provider model"] ``` A session preserves continuity. Compaction reduces what the model sees. It does not delete the durable run record. File memory and knowledge search add capabilities; they do not replace session history. ## Session history starts with `sessionId` A run with no `sessionId` is sessionless. The next request does not receive its chat history. Reuse a session id when turns must build on each other: ```bash curl -N -X POST http://localhost:5081/agentprism/api/agents/research/run \ -H "Authorization: Bearer $AGENTPRISM_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"sessionId":"case-4182","message":"Summarize the customer request."}' curl -N -X POST http://localhost:5081/agentprism/api/agents/research/run \ -H "Authorization: Bearer $AGENTPRISM_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"sessionId":"case-4182","message":"Now list the unresolved questions."}' ``` Without a SQL package, session history is in memory. `UsePostgreSql()`, `UseSqlServer()`, or `UseSqlite()` replaces it with the corresponding durable store. ## Add compaction and working memory Compaction and memory work on a plain chat agent. A `HarnessSettings` value is not required. Add the harness only when you also need its execution policy. ```csharp agentPrism.AddAgent(new AgentDefinition { Name = "research", Instructions = "Investigate the request. Keep a concise evidence trail.", Model = new ModelBinding { Provider = OpenAIProviderNames.ChatCompletions, Model = "your-current-model-name", MaxOutputTokens = 2_048, }, Harness = new HarnessSettings { MaxContextWindowTokens = 64_000, MaxOutputTokens = 2_048, MaximumIterationsPerRequest = 12, HarnessInstructions = "Update the todo list before the final answer.", }, Compaction = new CompactionSettings { Strategy = CompactionStrategyKind.Pipeline, TriggerTokens = 48_000, MinimumPreservedTurns = 3, MinimumPreservedGroups = 6, SummarizationModel = new ModelBinding { Provider = OpenAIProviderNames.ChatCompletions, Model = "your-lower-cost-summary-model", MaxOutputTokens = 1_024, }, }, Memory = new MemorySettings { EnableFileMemory = true, EnableTodo = true, EnableTextSearch = true, }, }); ``` The compiler checks conflicts before the run. For example, it rejects `Memory.EnableFileMemory = true` together with `Harness.DisableFileMemory = true`. The same rule applies to compaction and todo tracking. ## Choose a compaction strategy | Strategy | What it does | Required input | |---|---|---| | `None` | Sends no compaction policy | None | | `SlidingWindow` | Drops the oldest turns | At least one trigger | | `Truncation` | Truncates excluded message groups | At least one trigger | | `ToolResult` | Shortens tool-call and tool-result groups | At least one trigger | | `Summarization` | Replaces older groups with a model summary | At least one trigger | | `ContextWindow` | Evicts or truncates against a known window | `MaxContextWindowTokens` | | `Pipeline` | Runs ToolResult, then SlidingWindow, then Summarization | At least one trigger | `TriggerTokens`, `TriggerMessages`, and `TriggerTurns` are combined with **OR**. The first threshold reached starts compaction. `ContextWindow` manages its own trigger and does not require one of those fields. Defaults are explicit: | Setting | Default | |---|---| | `CompactionSettings.Strategy` | `None` | | `MinimumPreservedTurns` | 2 | | `MinimumPreservedGroups` | 4 | | `ContextWindow.MaxOutputTokens` | Agent model limit, then 4096 | | Summarization prompt | MAF default | | Summarization model | Agent setting, then application `UtilityModel`, then the agent model | :::caution[No compaction can become a run failure] When `Compaction` is absent or uses `None`, AgentPrism sends no compaction strategy. A long session eventually exceeds the provider's context window. Set a measured trigger below the real model limit and leave room for output and tool results. ::: MAF currently marks its compaction and `AgentFileStore` APIs as evaluation features. AgentPrism keeps the integration in one compiler boundary, but you should still test context behavior when upgrading MAF packages. ## Understand the memory flags All `MemorySettings` flags default to `false`. `EnableFileMemory` adds MAF file memory. On a plain agent, `EnableTodo` adds todo tracking. A harness already keeps todo tracking on unless `Harness.DisableTodoProvider` is true. `EnableTextSearch` searches the registered `AgentFileStore`. The default file store is in memory. `UsePostgreSql()`, `UseSqlServer()`, or `UseSqlite()` replaces it with the corresponding persistent SQL file store. `EnableVectorSearch` is different. It adds the code-defined `search_knowledge` tool over a persistent semantic knowledge base. It is not MAF's `ChatHistoryMemoryProvider`. See [Knowledge and RAG](/guides/knowledge/). The console agent editor exposes harness settings, all compaction strategies, file memory, todo tracking, and text search. It does not currently expose vector search or MCP resource URIs. Set vector memory through code or the management HTTP API. Set MCP resource URIs in code. ## Add predictable MCP resources Code-defined agents can name resources in `{server}:{uri}` form: ```csharp agentPrism.AddAgent(new AgentDefinition { Name = "release-reviewer", Instructions = "Review the release against the supplied policy.", Model = new ModelBinding { Provider = OpenAIProviderNames.ChatCompletions, Model = "your-current-model-name", }, McpResourceUris = [ "github:https://example.com/release-policy.md", ], }); ``` Call `UseMcp()` before compiling an agent that uses this field. AgentPrism reads these resources at the start of each run. The defaults are 64 KiB per resource and 256 KiB in total. Configure them with `AgentPrismMcpOptions.MaxResourceBytesPerResource` and `MaxResourceBytesTotal`. The current `AgentDefinitionRequest` management contract does not expose `McpResourceUris`. Define this field in code. Remote tools and server registrations remain available through the MCP management surface. Only resources declared by the server are read. An invalid reference, unreachable server, or undeclared resource is skipped and logged. Content above the per-resource budget is truncated; resources after the total budget is exhausted are skipped. ## Validate before saving The validation endpoint compiles the same definition without writing it and without calling a model: ```bash curl -sS -X POST http://localhost:5081/agentprism/api/agents/validate \ -H "Authorization: Bearer $AGENTPRISM_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "name": "research", "instructions": "Investigate the request.", "model": { "provider": "openai", "model": "your-current-model-name" }, "compaction": { "strategy": "Pipeline", "triggerTokens": 48000, "minimumPreservedTurns": 3, "minimumPreservedGroups": 6 }, "memory": { "enableFileMemory": true, "enableTodo": true, "enableTextSearch": true } }' ``` A well-formed request returns `200` even when the report says `valid: false`. That distinguishes an invalid definition from a transport failure. ## Troubleshooting **The second turn forgot the first one.** Reuse the same non-empty `sessionId`. A sessionless run carries no history to a later request. **A compaction strategy fails to compile.** Every strategy except `ContextWindow` needs at least one trigger. `ContextWindow` needs `MaxContextWindowTokens`. **Context still overflows.** Lower the trigger. Reserve space for output, system instructions, tool schemas, tool results, skills, and resources. A provider's nominal window is not all available to conversation history. **Summarization costs more than expected.** It is another model call. Set the agent's `SummarizationModel`, or configure the application-wide `AgentPrismOptions.UtilityModel`. **File memory disappears after restart.** The default `AgentFileStore` is in memory. Register PostgreSQL, SQL Server, or SQLite for a built-in persistent implementation. **Harness compilation reports a conflict.** Do not enable a capability in `Compaction` or `Memory` while the matching `Harness.Disable...` flag is true. **An MCP resource fails to compile or is truncated.** Confirm `UseMcp()` ran, the reference uses `{server}:{uri}`, and the resource stays within the configured byte budgets. **Vector search fails to compile.** It needs both an `IVectorSearchStore` and an `IEmbeddingGenerator>`. The built-in store comes from PostgreSQL. ## In the reference - [Agent management HTTP API](/http-api/agents/) - [`HarnessSettings` API](/api/agentprism.harnesssettings/) - [`CompactionSettings` API](/api/agentprism.compactionsettings/) - [`MemorySettings` API](/api/agentprism.memorysettings/) - [`AgentPrismMcpOptions` API](/api/agentprism.agentprismmcpoptions/) ## Read next - [Sessions and conversations](/concepts/sessions/) - [Agents and definitions](/concepts/agents/) - [Tools, skills, and MCP](/concepts/tools/) --- # Connect and expose agents AgentPrism supports three different external-agent directions. Keep them separate: | Direction | Purpose | Registration | HTTP surface | |---|---|---|---| | MCP client | Bring remote tools, prompts, and resources into AgentPrism | `UseMcp()` | Managed through `/api/mcp-servers/*` | | MCP server | Publish an AgentPrism agent as an MCP tool | `UseMcpServer()` | `/agentprism/mcp` by default | | A2A server | Publish an agent through the agent-to-agent protocol | `UseA2A()` | `/agentprism/a2a/{agent}` by default | ```mermaid flowchart LR accTitle: The three external-agent directions accDescr: As an MCP client AgentPrism calls remote servers to gain tools. As an MCP server and as an A2A server AgentPrism is called by outside callers, and each of those directions has its own allowlist, budget, and credential requirement. subgraph Inbound["Who can call your agents"] MCPC["MCP caller"] --> MCPS["UseMcpServer
allowlist · run budget · ExternalInvoke key"] A2AC["A2A caller"] --> A2AS["UseA2A
one agent card per exposed agent"] end MCPS --> AGENT["Your agent"] A2AS --> AGENT AGENT --> CLIENT["UseMcp
discovery, refreshed on an interval"] CLIENT --> REMOTE["Remote MCP server
tools · prompts · resources"] ``` The first direction expands what your agents can call. The other two expand who can call your agents. They have different trust boundaries and must be enabled separately. ## Consume a remote MCP server Add the non-AOT `AgentPrism.Mcp` package and register discovery: ```csharp var agentPrism = builder.AddAgentPrism() .UseOpenAI(builder.Configuration.GetSection(OpenAIProviderOptions.SectionName)) .UsePostgreSql(connectionString) .UseMcp(options => { options.RefreshInterval = TimeSpan.FromMinutes(5); options.ConnectionTimeout = TimeSpan.FromSeconds(30); options.MaxToolsPerServer = 100; }); ``` Create server records through the console or management API. Only the configuration key name is persisted; the credential value stays in your secret provider: ```json { "endpoint": "https://mcp.example.com/mcp", "authorizationConfigurationKey": "AgentPrism:Mcp:ExampleToken" } ``` ```bash dotnet user-secrets set "AgentPrism:Mcp:ExampleToken" "Bearer ..." ``` Discovery is asynchronous and does not block startup. Refresh immediately after a configuration change with `POST {prefix}/api/mcp-servers/refresh`. An unreachable server loses its discovered tools and produces a warning; other servers continue. ### The MCP client security boundary - Only HTTP and HTTPS transports are accepted. AgentPrism does not start MCP `stdio` processes on the host. - Discovered tool names are `{server}_{tool}`. A code-defined tool with the same name wins, so a remote server cannot replace it. - New MCP tools require approval by default. - Discovery is tenant-scoped and limited to 100 tools per server by default. - OAuth tokens live in memory. Plan for reauthorization after a process restart. An agent can also receive static MCP resources at run start through `AgentDefinition.McpResourceUris`. Each entry is `{server}:{uri}`. The limits are 64 KB per resource and 256 KB total. `UseMcp()` is required. This field is currently code/HTTP-only; the console editor does not preserve it. ## Publish agents as MCP tools Choose the allowlist before the application is built, then map the management API before the MCP endpoint: ```csharp var agentPrism = builder.AddAgentPrism() .UseOpenAI(builder.Configuration.GetSection(OpenAIProviderOptions.SectionName)) .UsePostgreSql(connectionString) .UseMcpServer(options => { options.ExposedAgents.Add("support"); options.ToolNamePrefix = "acme"; options.Budget = new AgentRunBudget { MaxDepth = 1, MaxTotalRuns = 4, MaxTotalTokens = 40_000, }; }); var app = builder.Build(); app.MapAgentPrism("/agentprism", options => { options.AllowRemoteAccess = true; options.RequireRolePolicies = true; }); app.MapAgentPrismMcpServer(); ``` No agent is exposed by default. `ExposeAllAgents` exists, but an allowlist is safer for a catalog that operators can edit. Each MCP call creates a fresh run budget; the default maximum depth is one, so an external caller cannot open an unbounded agent tree. The endpoint inherits the loopback, bearer, and authorization settings from `MapAgentPrism`. Remote exposure also requires at least one active, unexpired API key with the exact `ExternalInvoke` scope. A static bearer token alone is rejected at startup. Create the key before enabling remote access. An exposed agent cannot contain an approval-required tool. The application waits until the catalog is queryable, checks the selected agents, and prevents MCP requests from running if an approval boundary would be crossed. An external protocol caller cannot act as the missing human. ## Publish agents through A2A A2A exposes a distinct identity and agent card for each selected agent: ```csharp var agentPrism = builder.AddAgentPrism() .UseOpenAI(builder.Configuration.GetSection(OpenAIProviderOptions.SectionName)) .UsePostgreSql(connectionString) .UseA2A(options => { options.ExposedAgents.Add("support"); options.Budget = new AgentRunBudget { MaxDepth = 1, MaxTotalTokens = 40_000, }; }); var app = builder.Build(); app.MapAgentPrism("/agentprism", options => { options.AllowRemoteAccess = true; options.RequireRolePolicies = true; }); app.MapAgentPrismA2A(); ``` The invocation URL is `/agentprism/a2a/support`; its card is under `/agentprism/a2a/support/.well-known/agent-card.json`. A2A names are frozen during service registration because the underlying hosting API registers one server per name. The agent implementation is still resolved from the catalog on every call, so updating a database definition changes later behavior, but adding a new name requires an application restart and registration change. There is no expose-all switch. AgentPrism declares streaming, push notifications, and background A2A runs as unsupported. The same `ExternalInvoke`, approval, tenant, and budget boundaries as the MCP server apply. Both surfaces are configured in code, never from the console: `AgentPrismMcpServerOptions` for the MCP server and `AgentPrismA2AOptions` for A2A. Each starts empty — `ExposedAgents` names the agents you publish, and `ExposeAllAgents` opts out of naming them one by one. `ToolNamePrefix` keeps the published tool names from colliding with another server's, and `Budget` bounds what an external caller may spend. ## Production checklist - [ ] Expose only names whose input contract is safe for another system. - [ ] Create a tenant-bound `ExternalInvoke` key and test revocation and expiry. - [ ] Keep approval-required and high-impact tools out of exposed definitions. - [ ] Set token, depth, and child-run budgets for externally initiated work. - [ ] Terminate HTTPS at a trusted proxy and preserve the request path. - [ ] Alert on external run error rate, token use, latency, and rejected credentials. - [ ] Test catalog availability when migrations are applied outside the process. MCP server and A2A routes are protocol surfaces, not management endpoints. They do not appear in the generated OpenAPI operation count. Their runs still use the normal recording, tenancy, trace, cost, quota, and audit infrastructure. ## Read next - [Tools, skills, and MCP](/concepts/tools/) — the other direction: consuming an MCP server rather than publishing one - [Securing the endpoints](/getting-started/security/) — an exposed agent is a public surface, and its budget is the only limit - [Compatibility matrices](/reference/compatibility/) — which protocol revisions and transports are supported --- # Inbound triggers An inbound trigger is the reverse of an [outbound webhook](/concepts/governance/#webhooks): instead of AgentPrism notifying another system, another system starts a run in AgentPrism. A Slack slash command, a support-desk ticket event, or a queue consumer can all become the start of an agent or workflow run without holding an AgentPrism API key. The accept endpoint is always queued and always returns `202 Accepted` — there is no synchronous mode. A caller that needs the model's answer inline should use the normal run endpoint instead; see [Jobs, schedules, and queues](/guides/background-work/) for how queued runs execute. ```mermaid flowchart TD accTitle: What a signed inbound event passes before a run starts accDescr: The accept endpoint carries no bearer token. The tenant comes from the URL, the trigger must exist and be enabled, the timestamp must be inside the tolerance window, and the HMAC signature must verify. The signature is then reserved as the replay key, so an identical retry gets 409 rather than a second run. Every rejection returns the same generic 401. REQ["POST /api/triggers/tenant/name
no Authorization header"] --> TEN["Tenant from the URL only"] TEN --> TRG["Trigger exists and is enabled"] TRG --> TS["Timestamp inside TimestampTolerance"] TS --> SIG["HMAC signature verifies"] SIG --> IDEM{"Signature already reserved?"} IDEM -->|yes| CONF["409 Conflict
never a second run"] IDEM -->|no| Q["202 Accepted
queued run"] TEN -.->|any failure| GEN["One generic 401
names cannot be enumerated"] TRG -.->|any failure| GEN TS -.->|any failure| GEN SIG -.->|any failure| GEN ``` ## Define a trigger ```bash dotnet user-secrets set "AgentPrism:TriggerSecrets:Slack" "whsec_..." \ --project samples/AgentPrism.Api ``` ```bash curl -sS -X PUT \ https://agents.example.com/agentprism/api/triggers/slack \ -H "Authorization: Bearer $AGENTPRISM_API_KEY" \ -H 'Content-Type: application/json' \ -d '{ "targetKind": "agent", "targetName": "support", "signingSecretConfigurationName": "AgentPrism:TriggerSecrets:Slack", "payloadMode": "path", "payloadPath": "event.text" }' ``` `signingSecretConfigurationName` carries only the configuration **key's name** — never the secret value. AgentPrism reads the value from `IConfiguration` at request time, the same rule tenant provider bindings and MCP server credentials follow. The name must be under `AgentPrismInboundTriggerOptions.AllowedConfigurationPrefix`, which defaults to `AgentPrism:TriggerSecrets:`. `targetKind` is `agent` or `workflow`. `payloadMode` controls how the request body becomes the run's message: | Mode | Behavior | |---|---| | `wholeBody` (default) | The whole request body becomes the message, as JSON text | | `path` | A single field, selected by a dotted `payloadPath` (for example `event.text`), becomes the message | There is no template language here — the same rule that keeps tool approval conditions free of expression evaluation. A consumer that needs to reshape the payload does so before it reaches AgentPrism. ## Send a signed event ```bash curl -sS -i -X POST \ https://agents.example.com/agentprism/api/triggers/default/slack \ -H 'Content-Type: application/json' \ -H "X-AgentPrism-Timestamp: $(date +%s)" \ -H "X-AgentPrism-Signature: sha256=$SIGNATURE" \ -d '{"event":{"text":"Reset my password"}}' ``` The signature is the same HMAC-SHA256 contract [outbound webhooks](/concepts/governance/#webhooks) use, in the reverse direction — sign `{unixTimestamp}.{rawBody}` with the trigger's secret: ```csharp var signature = WebhookSigner.Sign(rawBody, DateTimeOffset.UtcNow, secret); ``` A valid request returns `202 Accepted` with a `Location` header, exactly like a queued agent run. For an agent target, the response also carries `runId`; poll `GET /api/runs/{runId}` for the outcome. For a workflow target, `runId` is `null` until the queued job runs — poll `GET /api/jobs/{jobId}` instead. ```json { "runId": "01a01890-1652-7183-9d50-6efd430644a3", "jobId": "01a01890-1652-7183-9d50-6efd430644a3", "location": "/agentprism/api/runs/01a01890-1652-7183-9d50-6efd430644a3", "eventsLocation": "/agentprism/api/runs/01a01890-1652-7183-9d50-6efd430644a3/events" } ``` ## No bearer token, by design The accept endpoint (`POST /api/triggers/{tenantId}/{name}`) carries no `Authorization` requirement — an external system cannot present an AgentPrism API key or the static `AuthToken`. Its entire authentication story is the HMAC signature: a request without a valid, in-window signature never reaches the queue. - The tenant comes from the URL, not from an ambient header or claim; a segment that does not match a saved trigger never falls back to a default tenant. - An unknown tenant, an unknown or disabled trigger name, and every signature/timestamp failure all return the **same** generic `401` body. A caller without a valid secret cannot enumerate trigger names, or even learn that a tenant exists, by comparing responses. - The timestamp must fall within `TimestampTolerance` (default five minutes) of the server's clock. This is the first line of defense against a captured request being replayed. - The signature itself is also the replay key: AgentPrism reserves it in the idempotency store on the first accepted request, so an identical replay — even inside the timestamp window — gets `409 Conflict`, never a second run. Every trigger definition write (`PUT`/`DELETE`) enters the audit trail **before** the mutation is applied — the same rule the approval-decision endpoint follows: a write that cannot be audited is not applied. ## Limits | Setting | Default | Effect | |---|---:|---| | `TimestampTolerance` | 5 minutes | Requests outside this window are rejected (`401`) regardless of signature validity | | `MaxBodyBytes` | 256 KB | A larger body is rejected (`413`) before it is fully read | | `MaxRequestsPerMinute` | 60 | Per trigger, per process (`429` beyond the limit) | | `AllowedConfigurationPrefix` | `AgentPrism:TriggerSecrets:` | The only prefix a signing secret's configuration key name may start with | ```json { "AgentPrism": { "InboundTriggers": { "TimestampTolerance": "00:05:00", "MaxBodyBytes": 262144, "MaxRequestsPerMinute": 60, "AllowedConfigurationPrefix": "AgentPrism:TriggerSecrets:" } } } ``` :::caution[The rate limit is per process] Like AgentPrism's general rate limiter, the trigger limit lives in process memory — there is no distributed counter. In a multi-instance deployment the limit applies per instance, not per trigger across the whole deployment. ::: ## Manage triggers | Operation | Endpoint | |---|---| | List a tenant's triggers | `GET /api/triggers` | | Read, replace, or delete one trigger | `GET`, `PUT`, or `DELETE /api/triggers/{name}` | | Accept an event (no bearer token) | `POST /api/triggers/{tenantId}/{name}` | The management console's **Triggers** screen covers the same list-and-edit flow; the editor shows the exact accept URL to paste into the external system's webhook configuration. ## Troubleshooting | Symptom | Check | |---|---| | Every request returns `401` | Confirm the tenant segment and trigger name are both correct and the trigger is enabled — a missing trigger, a disabled trigger, and a wrong signature are all reported as this SAME response; confirm the secret's configuration key actually has a value (`dotnet user-secrets list`); confirm the signed string is exactly `{unixTimestamp}.{rawBody}` with no re-serialization in between | | A retried request returns `409` | This is by design — the signature is the replay key. A genuine retry from the external system carries a fresh timestamp and therefore a fresh signature | | The request returns `400` with a payload-path detail | `payloadMode` is `path` and the field named by `payloadPath` was not found in this request's body | | The trigger stopped accepting requests after a burst | `MaxRequestsPerMinute` was exceeded; the caller should back off and retry, honoring the response | ## Read next - [Jobs, schedules, and queues](/guides/background-work/) — how a queued run actually executes - [Runs and recording](/concepts/runs/) — the four ways a run starts - [Security](/getting-started/security/) --- # Knowledge and RAG AgentPrism knowledge is retrieval, not hidden prompt injection. Operators ingest documents into a named collection. An agent gets a code-defined `search_knowledge` tool for one collection and asks for relevant chunks when needed. ## Mental model: administration and retrieval are separate ```mermaid flowchart LR accTitle: Knowledge ingestion and retrieval accDescr: Documents become embeddings in PostgreSQL with pgvector, while an agent independently calls search_knowledge to retrieve relevant chunks. D["Document text or chunks"] --> E["Embedding generator"] E --> V["PostgreSQL and pgvector"] Q["Diagnostic search HTTP API"] --> V A["Agent with vector search enabled"] --> T["search_knowledge tool"] T --> V V --> H["Nearest chunks
smaller distance is closer"] ``` The HTTP API owns ingestion, listing, deletion, and diagnostic search. The agent does not manage the knowledge base. It receives only the search tool. ## Register the two required dependencies Knowledge becomes functional only when both dependencies exist: 1. An `IVectorSearchStore`. The built-in implementation comes from `UsePostgreSql()`. 2. An `IEmbeddingGenerator>`. The host chooses and registers it. The sample below uses the OpenAI embedding adapter already used by the repository sample host: ```csharp title="Program.cs" using AgentPrism; using Microsoft.Extensions.AI; using OpenAI; var openAiKey = builder.Configuration["AgentPrism:Providers:OpenAI:ApiKey"] ?? throw new InvalidOperationException("The OpenAI API key is missing."); var agentPrism = builder.AddAgentPrism() .UsePostgreSql(builder.Configuration.GetSection( AgentPrismPostgreSqlOptions.SectionName)) .UseOpenAI(builder.Configuration.GetSection( OpenAIProviderOptions.SectionName)); builder.Services.Configure(options => { options.Dimensions = 1_536; options.ChunkSize = 1_000; options.ChunkOverlap = 100; options.MaxResults = 5; }); builder.Services.AddSingleton>>( new OpenAIClient(openAiKey) .GetEmbeddingClient("text-embedding-3-small") .AsIEmbeddingGenerator()); ``` A third setting is required: `AgentPrismPostgreSqlOptions.EnableKnowledge` is `false` by default (a managed PostgreSQL instance without permission to install extensions should never see `pgvector` unless it asked for it). Turn it on wherever `PostgreSql` is configured: ```json title="appsettings.json" { "AgentPrism": { "PostgreSql": { "EnableKnowledge": true } } } ``` With it off, `IVectorSearchStore` never resolves and an agent definition that sets `EnableVectorSearch` fails compilation with a clear error — see [Persistence](/getting-started/persistence/#pick-one). The embedding model in this example produces 1,536 dimensions. If you choose another model, set `Dimensions` to its actual output size **before the knowledge migration set first runs**. :::caution[Dimensions are schema, not a live tuning knob] PostgreSQL creates `document_embeddings.embedding` as `vector({dimension})`. Changing `AgentPrismKnowledgeOptions.Dimensions` after the knowledge set has applied does not alter the existing column. Plan a new database migration and re-embed every document. ::: Turning `EnableKnowledge` on applies one additional migration set: it runs `CREATE EXTENSION IF NOT EXISTS vector`, creates the `document_embeddings` table, and adds an HNSW cosine-distance index. The database role that applies migrations must be allowed to create the `vector` extension, or an operator must install it first — while the option stays off, none of this runs and no permission is needed. ## Give an agent access to one collection ```csharp agentPrism.AddAgent(new AgentDefinition { Name = "support", Instructions = "Use knowledge search before answering policy questions. Cite the source id.", Model = new ModelBinding { Provider = OpenAIProviderNames.ChatCompletions, Model = "your-current-model-name", }, Memory = new MemorySettings { EnableVectorSearch = true, VectorCollection = "support-policies", }, }); ``` The compiler adds `search_knowledge` automatically. Do not add it to `ToolNames`. When `VectorCollection` is empty, the agent name is used. The tool returns at most `AgentPrismKnowledgeOptions.MaxResults` chunks. The console does not currently provide a knowledge-management screen or vector-memory fields in the agent editor. Use the knowledge HTTP API for ingestion and calibration, and use code or the agent management API to enable vector search. This feature is separate from session history, file memory, and MAF's `ChatHistoryMemoryProvider`. See [Context and memory](/guides/context-and-memory/). ## Ingest raw text The server accepts either `text` or `chunks`, never both and never neither. Raw text is split and embedded on the server: ```bash curl -sS -X POST \ http://localhost:5081/agentprism/api/knowledge/support-policies/documents \ -H "Authorization: Bearer $AGENTPRISM_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "sourceId": "refund-policy-v3", "text": "Refunds are available within 30 days when the order is unused..." }' ``` The response contains `sourceId` and `chunkCount`. Uploading the same `sourceId` again replaces all old chunks in one PostgreSQL transaction. For a pre-chunked pipeline, send `chunks`: ```json { "sourceId": "refund-policy-v3", "chunks": [ { "index": 0, "content": "Refunds are available within 30 days.", "metadata": { "section": "eligibility" } } ] } ``` When a chunk has no `embedding`, the server embeds its `content`. When an embedding is supplied, AgentPrism writes it as-is after checking its length against the store dimension. ## Calibrate retrieval before blaming the prompt The diagnostic search endpoint performs the same embedding and vector lookup used by the agent tool: ```bash curl -sS -X POST \ http://localhost:5081/agentprism/api/knowledge/support-policies/search \ -H "Authorization: Bearer $AGENTPRISM_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"query":"Can an unused order be returned after two weeks?","top":3}' ``` Each hit contains `sourceId`, `chunkIndex`, `content`, `distance`, and optional metadata. Results are ordered by ascending cosine distance. Smaller is closer. If the right chunk is absent here, the agent never received it. Fix ingestion, chunking, the embedding model, or collection routing before changing the prompt. Every diagnostic search and every tool query makes an embedding call, so it has the latency and cost of that provider call. ## Manage sources ```bash # Source ids only. An unknown collection returns an empty array. curl -H "Authorization: Bearer $AGENTPRISM_TOKEN" \ http://localhost:5081/agentprism/api/knowledge/support-policies/documents # Deletes all chunks of the source. Repeating it still returns 204. curl -X DELETE -H "Authorization: Bearer $AGENTPRISM_TOKEN" \ http://localhost:5081/agentprism/api/knowledge/support-policies/documents/refund-policy-v3 ``` Deletion is immediate. Restoring a source requires another upload and another round of embedding calls. ## Defaults and limits | Setting or rule | Default or behavior | |---|---| | `Dimensions` | 1536 | | `ChunkSize` | 1000 characters | | `ChunkOverlap` | 100 characters | | `MaxResults` | 5 | | Chunking bounds | `ChunkSize > 0` and `0 <= ChunkOverlap < ChunkSize` | | Raw text chunking | Character-based, then every chunk is embedded | | Collection name | Letters, digits, underscores, and hyphens only | | Source replacement | Same `sourceId` replaces all chunks in that collection | | Distance | Cosine distance; smaller is closer | | Tenant boundary | Every write and search is scoped to the current tenant | | Built-in vector store | PostgreSQL only | The HTTP search request can set `top`. Use a positive value; this endpoint does not apply the `1..200` clamp used by paged list endpoints. The agent tool always uses `MaxResults`. The HTTP contract does not expose a distance threshold; use returned distances to calibrate relevance in your own ingestion and evaluation process. A SQL Server or SQLite host can provide a custom `IVectorSearchStore`. Without a store and an embedding generator, every knowledge endpoint returns `501`. An agent with `EnableVectorSearch = true` fails compilation instead of receiving an empty tool. ## Production checklist - Use the same embedding model and dimensions for document and query embeddings. - Treat an embedding-model change as a data migration. Re-embed the full collection. - Tune character chunk size and overlap against real documents and retrieval evals. - Use stable, URL-safe source ids. Re-upload is replacement, not an appended version. - Separate collections when access or retrieval domains differ. Tenant scoping is automatic, but collection design is yours. - Keep raw source documents outside AgentPrism if you need document version history. The knowledge table stores chunks and embeddings, not an immutable source archive. ## Troubleshooting **Knowledge endpoints return `501`.** Register `UsePostgreSql()` with `EnableKnowledge = true` and an `IEmbeddingGenerator>`. Any one missing is not enough. **PostgreSQL startup fails around `vector`.** `EnableKnowledge = true` is set but the server has no pgvector. Install the extension first, or turn `EnableKnowledge` off until it is available — the core migration set never touches `vector`. **Upload says the embedding length is wrong.** The generator output and the migrated column dimension differ. Do not change only the option. Migrate the schema and re-embed the collection. **The agent compiles without `search_knowledge` in `ToolNames`.** This is expected. `EnableVectorSearch` adds the code-defined tool during compilation. **Search returns irrelevant chunks.** Run the diagnostic endpoint. Check collection, source content, chunk boundaries, embedding consistency, and distance distribution. Prompt changes cannot recover a chunk that retrieval did not return. **A collection name returns `400`.** Use only letters, digits, underscores, and hyphens. Spaces, slashes, and other punctuation are rejected. **Deleting and re-uploading is expensive.** Both replacement and restoration require fresh embeddings. Avoid unstable source ids that cause unnecessary full replacement. ## In the reference - [Knowledge HTTP API](/http-api/knowledge/) - [`AgentPrismKnowledgeOptions` API](/api/agentprism.agentprismknowledgeoptions/) - [`MemorySettings` API](/api/agentprism.memorysettings/) - [`IVectorSearchStore` API](/api/agentprism.ivectorsearchstore/) - [`UploadDocumentRequest` API](/api/agentprism.uploaddocumentrequest/) - [`SearchKnowledgeRequest` API](/api/agentprism.searchknowledgerequest/) - [`UsePostgreSql` API](/api/agentprism.agentprismpostgresqlbuilderextensions/) ## Read next - [Context and memory](/guides/context-and-memory/) - [Persistence](/getting-started/persistence/) --- # Model providers A provider registration opens a model route in the host. An agent definition selects that route by name. The definition never contains the credential. ## Mental model: register once, select per agent ```mermaid flowchart LR accTitle: Model provider selection flow accDescr: Host configuration registers a named provider, each agent binds that provider and model, and the resolved chat client performs the request. C["Host configuration
credential and endpoint"] --> R["Use... registration"] R --> N["Provider registry
stable provider name"] D["Agent definition
ModelBinding"] --> N N --> P["IChatClient pipeline"] P --> M["Selected provider and model"] ``` This separation has two useful effects. You can register several providers in one process. You can also move an agent to another provider without moving a secret into the database or the console. | Package | Registration | Provider name in `ModelBinding` | Surface | |---|---|---|---| | `AgentPrism.OpenAI` | `UseOpenAI()` | `openai` | OpenAI Chat Completions | | `AgentPrism.OpenAI` | `UseOpenAI()` | `openai-responses` | OpenAI Responses | | `AgentPrism.OpenAI` | `UseOpenAICompatible(name, …)` | your `name` | Compatible Chat Completions | | `AgentPrism.Anthropic` | `UseAnthropic()` | `anthropic` | Anthropic Messages | | `AgentPrism.Google` | `UseGoogle()` | `google` | Gemini Developer API | | `AgentPrism.Azure` | `UseAzureOpenAI()` | `azure-openai` | Azure OpenAI Chat Completions | The `AgentPrism` meta package includes `AgentPrism.OpenAI`. Add the Anthropic, Google, or Azure package only when the host uses it. ## Register providers Read credentials from configuration. Put their values in user-secrets, environment variables, or a secret manager. ```bash dotnet user-secrets set "AgentPrism:Providers:OpenAI:ApiKey" "" dotnet user-secrets set "AgentPrism:Providers:Anthropic:ApiKey" "" dotnet user-secrets set "AgentPrism:Providers:Google:ApiKey" "" dotnet user-secrets set "AgentPrism:Providers:AzureOpenAI:ApiKey" "" ``` Register only the providers for which your host has complete configuration: ```csharp title="Program.cs" using AgentPrism; var agentPrism = builder.AddAgentPrism() .UseOpenAI(builder.Configuration.GetSection(OpenAIProviderOptions.SectionName)) .UseAnthropic(builder.Configuration.GetSection(AnthropicProviderOptions.SectionName)) .UseGoogle(builder.Configuration.GetSection(GoogleProviderOptions.SectionName)) .UseAzureOpenAI(builder.Configuration.GetSection(AzureOpenAIProviderOptions.SectionName)); ``` Each options type validates at startup. Missing required configuration stops the host before the first run. A named compatible endpoint can be keyless, and Azure can use a credential factory instead of an API key. Then bind an agent to one stable provider name: ```csharp agentPrism.AddAgent(new AgentDefinition { Name = "support", Instructions = "Resolve support requests. State uncertainty clearly.", Model = new ModelBinding { Provider = AnthropicProviderNames.Anthropic, Model = "your-current-model-name", MaxOutputTokens = 2_048, }, }); ``` Use a current model name from the provider. AgentPrism does not pin one for you. Provider registration is host configuration, not a console operation. The console never accepts or displays provider secrets. Its Models screen shows the catalog and cached health for providers that the host already registered. The agent editor can select a provider and model, but it does not currently edit `ProviderSettings`; set vendor-specific keys in code or through the management HTTP API. ## OpenAI and compatible endpoints `UseOpenAI()` always registers both official OpenAI routes. The `OpenAIProviderOptions.EnableResponsesSurface` option does not change this behavior. That option applies only to compatible endpoints. ```csharp agentPrism.UseOpenAICompatible("ollama", options => { options.Endpoint = new Uri("http://localhost:11434/v1"); // A local server can run without an API key. }); ``` A compatible registration creates only the Chat Completions route by default. Set `EnableResponsesSurface = true` only if the server implements `/v1/responses`. The second provider is then named `{name}-responses`. The name must match `[a-z0-9][a-z0-9-]{0,31}`. The names `openai` and `openai-responses` are reserved. An absolute `Endpoint` is required. A compatible server with no key is valid; AgentPrism supplies only the fixed placeholder required by the OpenAI client library. :::caution[Compatibility is measured per server and model] A successful health check proves reachability. It does not prove tool calling, structured output, streaming usage, or Responses API compatibility. Some compatible servers omit usage from streamed responses. In that case token count and cost stay `null`; AgentPrism does not fabricate them. ::: ## Provider-specific settings Portable settings live directly on `ModelBinding`: `Temperature`, `TopP`, `MaxOutputTokens`, `ReasoningEffort`, and `ResponseFormat`. Vendor-only settings live in `ProviderSettings`. Unknown keys fail compilation instead of being ignored. ```csharp using System.Text.Json; var anthropicBinding = new ModelBinding { Provider = AnthropicProviderNames.Anthropic, Model = "your-current-claude-model", MaxOutputTokens = 4_096, ProviderSettings = new Dictionary( StringComparer.OrdinalIgnoreCase) { [AnthropicProviderNames.PromptCachingSetting] = JsonSerializer.SerializeToElement(true), [AnthropicProviderNames.ThinkingBudgetTokensSetting] = JsonSerializer.SerializeToElement(2_048), }, }; var googleBinding = new ModelBinding { Provider = GoogleProviderNames.Google, Model = "your-current-gemini-model", ProviderSettings = new Dictionary( StringComparer.OrdinalIgnoreCase) { [GoogleProviderNames.SafetyHarassmentSetting] = JsonSerializer.SerializeToElement("BLOCK_ONLY_HIGH"), [GoogleProviderNames.ThinkingBudgetTokensSetting] = JsonSerializer.SerializeToElement(512), [GoogleProviderNames.ThinkingIncludeThoughtsSetting] = JsonSerializer.SerializeToElement(true), }, }; ``` Anthropic supports `anthropic.promptCaching` and `anthropic.thinking.budgetTokens`. Prompt caching is off by default. When thinking is enabled, its budget must be smaller than the effective output limit. Temperature must be absent or `1`. Google supports five `google.safety.*` thresholds plus `google.thinking.budgetTokens` and `google.thinking.includeThoughts`. The thinking budget range is `-1..65535`; `-1` lets the model decide and `0` disables thinking. Valid safety values are `BLOCK_LOW_AND_ABOVE`, `BLOCK_MEDIUM_AND_ABOVE`, `BLOCK_ONLY_HIGH`, `BLOCK_NONE`, and `OFF`. A safety-filtered empty response becomes a failed run with error type `content_filtered`. Azure OpenAI supports no `ProviderSettings` keys. The package deliberately rejects them. ## Azure OpenAI uses deployments For `azure-openai`, `ModelBinding.Model` is the **deployment name**, not the base model name. A wrong deployment usually produces `404` even when provider health is good. Use an API key, or supply an Azure Core credential factory. Managed identity wins if both are present. ```csharp // Add Azure.Identity to the consumer project for DefaultAzureCredential. using Azure.Identity; agentPrism.UseAzureOpenAI(options => { options.Endpoint = new Uri("https://my-resource.openai.azure.com/"); options.CredentialFactory = static () => new DefaultAzureCredential(); options.DefaultDeployment = "support-production"; }); ``` `AgentPrism.Azure` depends on `Azure.Core`, not `Azure.Identity`. The consumer chooses the credential implementation. Azure OpenAI Responses, On Your Data, and Azure AI Foundry Agents are not exposed by this provider. ## Per-tenant credentials (BYOK) By default every tenant shares the credential a `Use...()` call registered at startup. A multi-tenant host can instead let each tenant bring its own key — its usage and its bill stay separate from every other tenant's. A tenant's binding stores only the **name** of a configuration key, never the value: ```bash dotnet user-secrets set "AgentPrism:ProviderKeys:Acme:OpenAI" "" ``` ```bash curl -X PUT "http://localhost:5081/agentprism/api/tenants/acme/providers/openai" \ -H "Authorization: Bearer $AGENTPRISM_TOKEN" \ -H "Content-Type: application/json" \ -d '{"apiKeyConfigurationName": "AgentPrism:ProviderKeys:Acme:OpenAI"}' ``` The name must sit under the configured prefix (default `AgentPrism:ProviderKeys:`); a name outside it is rejected with `400` both when it is saved and again when it is resolved. `GET /api/tenants/acme/providers` reports whether the name currently resolves to a value (`resolved: true`/`false`) — never the value itself. A tenant with no binding for a provider keeps using the global setup-time credential; nothing changes until a binding is written. A binding that exists but resolves to no value does not fall back to the global credential silently — the run fails with a clear error instead, so a misconfigured tenant is never billed to the wrong account. Restrict which providers a tenant's agents may call with an egress policy: ```bash curl -X PUT "http://localhost:5081/agentprism/api/tenants/acme/egress" \ -H "Authorization: Bearer $AGENTPRISM_TOKEN" \ -H "Content-Type: application/json" \ -d '{"allowedProviders": ["openai", "anthropic"]}' ``` A tenant with no saved policy is unrestricted — saving one is an additive restriction, not a default wall. An agent definition naming a provider outside the saved list is rejected **at compile time**, before any request reaches the network; the same check also protects `PUT .../providers/{provider}` itself, so both surfaces agree. The console's Settings screen exposes both panels; no field there accepts a credential value, only a configuration key name and an optional endpoint override. ## A provider without a package `AddModelProvider()` registers a provider AgentPrism does not ship a package for. Implement `IModelProvider` — a stable `Name`, a `Models` catalog, and `CreateChatClient(ModelBinding, ModelProviderCredential?)` returning a raw `IChatClient` — and register it: ```csharp public sealed class ContosoModelProvider(HttpClient httpClient) : IModelProvider { public string Name => "contoso"; public IReadOnlyList Models { get; } = [new ModelDescriptor { Name = "contoso-large", SupportsTools = true }]; public IChatClient CreateChatClient(ModelBinding binding, ModelProviderCredential? credential = null) => new ContosoChatClient(httpClient, binding.Model, credential?.ApiKey); } agentPrism.AddModelProvider(services => new ContosoModelProvider(services.GetRequiredService())); ``` `credential` carries a resolved per-tenant key when the host and the requesting tenant both opt into BYOK (see [Per-tenant credentials](#per-tenant-credentials-byok) above); it is `null` for every call that does not, and a provider that ignores the parameter keeps working exactly as before — it simply never honors a tenant's own key. `ModelProviderRegistry` wraps every provider — built-in or custom — with the same pipeline: function invocation, OpenTelemetry, the content guard, and the circuit breaker. Do not build that ring inside `CreateChatClient`; a second, nested function-invocation loop hides tool calls from the outer one. ## Model catalog is metadata, not permission Every provider options type has a `Models` collection. It drives the console model picker, capability hints, and cost calculation. It is not an allow list. A definition can use a model that is absent from the catalog. This also means the catalog must be accurate. If a listed model leaves `SupportsStructuredOutput` at its default `false`, an agent that requests JSON or JSON Schema output fails compilation. See [Structured output](/guides/structured-output/). ## Check a prompt against the context window before running it The pre-flight check is `AgentPrismPreflightOptions`, bound from `AgentPrism:Preflight`. It is off until `Enabled` is set, and `ReserveRatio` decides how much of the window is held back for the answer. Outgoing concurrency is `AgentPrismModelConcurrencyOptions`, bound from `AgentPrism:ModelConcurrency`: `MaxConcurrentCallsPerProvider` caps how many calls AgentPrism has in flight against one provider at a time. `ContextWindowTokens` on a catalog `ModelDescriptor` powers two features: derivation for `ContextWindow` compaction, and an optional pre-flight check on `POST /api/agents/{name}/run` that rejects an oversized prompt **before** any provider is called. ```json { "AgentPrism": { "Preflight": { "Enabled": true, "ReserveRatio": 0.2 } } } ``` `Preflight.Enabled` is off by default: a wrong estimate stops a run that would have succeeded, and that risk needs an explicit opt-in. `ReserveRatio` (default `0.2`) sets aside a share of the window for the answer; a prompt estimated above the remaining budget returns `400` with the counted and allowed token numbers, and no provider is contacted. The count is **approximate** — it uses a single fixed OpenAI encoding regardless of the bound provider, because Anthropic and Google publish no equivalent offline tokenizer. Diagnose the estimate for any agent, independent of whether the check is enabled, with: ```bash curl -X POST "http://localhost:5081/agentprism/api/agents/support/estimate" \ -H "Authorization: Bearer $AGENTPRISM_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"message":"..."}' ``` `contextWindowTokens` and `allowedPromptTokens` come back `null` when the agent's model is not in the catalog — a missing catalog entry is never treated as a rejection, since there is nothing to compare the prompt against. ## Defaults and operational behavior | Setting | Default or rule | |---|---| | Model fallback | Provider options can define a default model or Azure deployment for programmatic use; the management HTTP API still requires `model.model` | | Sampling fields | `null` uses the provider default | | Anthropic output limit | `DefaultMaxOutputTokens = 4096` because Anthropic requires `max_tokens` | | Compatible Responses route | Off | | Health cache | 60 seconds | | Background health checks | Off; checks run on request unless an interval is configured | | Circuit breaker | On; 5 consecutive failures; one half-open attempt after 30 seconds | | Model catalog | Empty until the host supplies entries | | Pre-flight context-window check | Off; `POST /api/agents/{name}/estimate` still works when off | | Fallback chain | Empty; an unavailable primary throws, same as before this feature existed | | Tenant provider binding | None; every tenant uses the global setup-time credential until one is saved | | Tenant egress policy | Unrestricted; saving one is an additive restriction, never a default wall | | Allowed configuration prefix for a binding | `AgentPrism:ProviderKeys:`; a name outside it is rejected with `400` | Force a current, cost-free reachability check with: ```bash curl -H "Authorization: Bearer $AGENTPRISM_TOKEN" \ "http://localhost:5081/agentprism/api/models/health/openai?refresh=true" ``` The health call reads a model list. It does not run a completion. ## Troubleshooting **“Provider is not registered.”** Confirm that the matching `Use...()` call ran and that `ModelBinding.Provider` uses the exact stable name from the table above. **The host fails during startup.** Check the provider's configuration section. Keep the secret value out of `appsettings.json`, but make sure the environment or secret manager supplies it. Azure also requires an absolute resource endpoint. **The model does not appear in the console.** Add it to the provider's `Models` collection. The absence does not stop a definition from using it. **Azure health is good, but a run returns `404`.** Health verifies the resource and credential, not a deployment. Check the deployment name in `ModelBinding.Model`. **A compatible run has no token count or cost.** The upstream server probably omitted streaming usage. Configure no estimate unless you can label it as an estimate. **A compatible provider returns `402`.** Some gateways reserve credit against the maximum possible output. Set a realistic `ModelBinding.MaxOutputTokens` value. **Anthropic rejects a thinking request.** Keep the thinking budget below the output limit. Remove temperature or set it to `1`. **A working prompt gets rejected by the pre-flight check.** The token estimate is approximate. Raise `ReserveRatio` toward zero, or call `/estimate` to see the counted value against the model's real `ContextWindowTokens` before deciding. ## In the reference - [Model health HTTP API](/http-api/models/) - [`ModelBinding` API](/api/agentprism.modelbinding/) - [`UseOpenAI` API](/api/agentprism.openaiproviderextensions/) - [`UseOpenAICompatible` API](/api/agentprism.openaicompatibleproviderextensions/) - [`UseAnthropic` API](/api/agentprism.anthropicproviderextensions/) - [`UseGoogle` API](/api/agentprism.googleproviderextensions/) - [`UseAzureOpenAI` API](/api/agentprism.azureopenaiproviderextensions/) ## Read next - [Reliable runs](/guides/reliability/) — provider fallback chains and outgoing concurrency limits - [Choosing packages](/packages/) - [Agents and definitions](/concepts/agents/) --- # Multimodal input AgentPrism treats binary input as a stored attachment, not as JSON inside a message. The run carries an attachment id. The model receives the verified bytes only when the provider call is made. ## Mental model: bytes out of history ```mermaid flowchart LR accTitle: Attachment data flow accDescr: A verified upload becomes an attachment id, the run resolves it to model content, and conversation history stores only the reference. U["Upload
multipart file"] --> S["Attachment store
verified bytes"] S --> I["Attachment id"] I --> H["Session history
small URI reference"] H --> R["Resolver before model call"] S --> R R --> D["DataContent
bytes and MIME type"] D --> M["Provider model"] ``` This shape keeps session payloads small and makes attachment ownership enforceable. It also avoids asking a provider to fetch a private URL it cannot reach. ## Upload, then run Upload with a multipart field named `file`. `sessionId` is optional. When present, it groups the attachment with that session for listing and lifecycle cleanup. ```bash curl -sS -X POST \ "http://localhost:5081/agentprism/api/attachments?sessionId=case-4182" \ -H "Authorization: Bearer $AGENTPRISM_TOKEN" \ -F 'file=@invoice.png' ``` The response is `201 Created` with an `AttachmentDescriptor`. Copy its `id`, then put that UUID in `attachmentIds`: ```bash curl -N -X POST \ http://localhost:5081/agentprism/api/agents/invoice-reader/run \ -H "Authorization: Bearer $AGENTPRISM_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "sessionId": "case-4182", "message": "Read this invoice and identify missing fields.", "attachmentIds": ["6c825f61-85e1-4e5e-8ed4-247391e2a9bd"] }' ``` The run endpoint checks every id before it starts the SSE stream. An unknown id, or an id owned by another tenant, returns `400` without contacting the model. A request can contain attachments without text, because one of `message`, `attachmentIds`, or `approvals` is sufficient. The console Playground implements the same flow behind its attachment button. It uploads first, sends the returned ids with the next turn, and fetches protected image or audio bytes into object URLs for preview. ## What reaches the model The stored message contains a `UriContent` reference. Immediately before each model call, `AttachmentResolvingChatClient` loads the tenant-owned bytes and replaces that reference with `DataContent`. The resolved bytes are not written back into chat history. :::caution[Upload support is not model support] Passing the upload guard means AgentPrism can store and transport the file. It does not mean the selected model understands that image, PDF, or audio format. Verify the exact provider, model, and API surface. A text-only model can ignore the content or reject the request. ::: ## Default types and limits One attachment is limited to 20 MiB by default. The default allow list is: - `image/png`, `image/jpeg`, `image/webp`, and `image/gif` - `application/pdf` - UTF-8 `text/plain` - `audio/*`; the built-in detector recognizes WAV, Ogg, and MP3 The client-provided `Content-Type` and file extension are not trusted. AgentPrism derives the type from magic bytes. Plain text is the exception: when the first 1 KiB is valid UTF-8 and has no NUL or disallowed control byte, it is treated as `text/plain`. Empty files, unknown signatures, files above the byte limit, and detected types outside the allow list return `400`. Change the limit and narrow the list through options: ```csharp builder.Services.Configure(options => { options.Attachments.MaxBytes = 8 * 1024 * 1024; options.Attachments.AllowedMediaTypes.Clear(); options.Attachments.AllowedMediaTypes.Add("image/png"); options.Attachments.AllowedMediaTypes.Add("application/pdf"); }); ``` Register this configuration after `AddAgentPrism()` when code values must override the values read from `AgentPrism:Attachments`. ## List, download, and delete ```bash # Descriptors only. Defaults: skip=0, take=50. take is clamped to 1..200. curl -H "Authorization: Bearer $AGENTPRISM_TOKEN" \ "http://localhost:5081/agentprism/api/attachments?sessionId=case-4182" # Raw bytes with the stored MIME type and a SHA-256 ETag. curl -OJ -H "Authorization: Bearer $AGENTPRISM_TOKEN" \ "http://localhost:5081/agentprism/api/attachments/{attachmentId}" # Immediate hard delete. curl -X DELETE -H "Authorization: Bearer $AGENTPRISM_TOKEN" \ "http://localhost:5081/agentprism/api/attachments/{attachmentId}" ``` Downloads send `Content-Disposition: attachment` and `X-Content-Type-Options: nosniff`. A browser must fetch with the authorization header and create an object URL for preview. A protected download URL cannot be used directly as an `` or `