# Tracon — full documentation Every hand-written page of the Tracon documentation, concatenated. The generated API and HTTP references are not included; use the compiler, the XML documentation, and https://tracon.dev/http-api/ for those. --- # Capability map Tracon is a .NET package family that adds a control plane on Microsoft Agent Framework. 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: Tracon 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 `AddTracon()` 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 | `ITraconBuilder.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 Tracon 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 `MapTracon()` | Code-defined agents are visible but read-only | | Custom agent source | Lists agents from a repository or external runtime | `AddAgentSource()`, instance, or factory | Custom agents are visible but read-only in the management API | | 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; an opt-in, fail-closed `IStructuredResponseValidator` seam can check the response before the run closes; a rejected response can get a bounded number of model-driven repair turns (`MaxRepairAttempts`), non-streaming runs only | | Agent graph | One agent can call registered agents as tools | `CallableAgentNames` | Shared limits bound call depth, total child runs, total token/cost spend, and wall-clock time — the token, cost, and time limits cut a run off mid-run, between model turns. Each call also has a two-layer wait limit (`SubAgentSettings`, or the installation-wide default): a cooperative deadline, then a hard cutoff that abandons a child that ignores cancellation | | Harness mode | Context and iteration limits plus todo, file-memory, web-search, skill, and mode providers | `AgentDefinition.Harness` | The harness extends the agent; it does not replace MAF types. Its providers are on by default — each has its own `Disable...` flag | | Harness loop | The agent is re-invoked until a declared stop criterion says the work is finished | `HarnessSettings.Loop`, plus `AddLoopEvaluator(kind, evaluator)` for a criterion written in code | Off unless the definition sets it. Four built-in criterion kinds take data only; an unknown kind is refused at compile time. The iteration ceiling is never open — an unset `MaxIterations` takes Tracon's own default — and every iteration writes a `LoopIterationCompleted` run event | | Context compaction | Trigger-based truncation or summarization with preserved turns and an optional utility model | `AgentDefinition.Compaction` and `Tracon: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 | | Response caching | A tenant-, provider-, and tool-set-aware cache; a hit spends no tokens and opens no new trace span | `ModelBinding.ResponseCache` | Needs a registered `IDistributedCache`, or the agent fails to compile | | Concurrent tool calls | Independent tool calls in one turn run at the same time instead of one after another | `ModelBinding.AllowConcurrentToolCalls` | Off by default; each call still gets its own authorization, result, and metric | | Parameterized instructions | Named `{{name}}` placeholders bound to a run's own values | `AgentDefinition.Parameters` and the run's `parameters` field | Value substitution only — no expression, condition, loop, or field access | | Shared instructions blocks | One definition's instructions prepended to another's at compile time | `AgentDefinition.SharedInstructionsName` | A block cannot reference another block | Tracon 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 | |---|---|---|---| | `Tracon.OpenAI` | `UseOpenAI()` | `openai`, `openai-responses` | Chat Completions and Responses clients | | `Tracon.OpenAI` | `UseOpenAICompatible(name, ...)` | `name`, and optionally `name-responses` | OpenRouter, Groq, Ollama, LM Studio, vLLM, and other compatible endpoints | | `Tracon.Anthropic` | `UseAnthropic()` | `anthropic` | Claude, prompt caching, and extended-thinking settings | | `Tracon.Google` | `UseGoogle()` | `google` | Gemini safety thresholds and thinking settings | | `Tracon.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. Tracon does not invent model names or prices. `ModelBinding.Fallbacks` decides whether a failure moves to the next provider link, and `runs.error_class` decides how a failed run is classified afterward. Both decisions can be overridden or composed with your own rules — see [Write your own error classifier](/guides/write-your-own-error-classifier/). ## Tools, skills, and context | Capability | Registration or source | What is enforced | |---|---|---| | Generated tools | `[TraconTool]` and `AddGeneratedTools()` | Compile-time discovery without reflection or dynamic code; `minimum`/`maximum`/length/`pattern` constraints from standard `DataAnnotations` attributes reach the schema; a parameter may be a supported object (a public record or class with a single public constructor), up to 3 nested levels deep, bound through the tool's own `JsonSerializerContext` | | Direct tools | `AddTool(AIFunction, configure)` | Exact tool instance and its approval, effect, permission, timeout, repeatability, and output 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 | | Scoped tools | `AddScopedTool` | Every call gets its own DI scope, closed when the call ends | | Argument validation | `IToolArgumentsValidator` | Runs before every call, code-defined or MCP; a rejection skips the real body | | Tool approval | `RequiresApproval`, the registration flag, or `AddToolApprovalPolicy()` | A sensitive call cannot execute until a person or standing rule decides it | | Tool output size limit | `MaxOutputBytes` (per tool) or `Tools.DefaultMaxOutputBytes` (installation-wide) | A result over the byte limit is trimmed into a JSON envelope before the model sees it; unlimited by default | | Client-side tools | `AddClientTool(...)` | 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`. An agent carrying one cannot be replayed in any tool mode — its call was never recorded, so no mode can answer it | | 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; Tracon 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 | | Custom run events | `RunEventType.Custom` and `AgentRunScope.Writer` | A tool writes its own named event into the stream a consumer already carries; the console draws it with a generic card, no code change required | | Custom agent decorator | `AddAgentDecorator()`, instance, or factory | Joins the built-in decorators; `Order` decides where | | 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, console, or a custom `IRunJudge` | Compare two runs, attach human scores, or score completed runs automatically; a score carries a name, so one reviewer can rate the same run on several criteria; an online judge uses its provider setup credential and still obeys tenant egress policy | | Sessions | `AgentSessionManager` and session endpoints | Durable conversation identity and readable history when the store supports it | | Session ownership | `Tracon:SessionOwnership` | Off by default (`false`); records which user opened a session, narrows the session list to that user **before** paging, and answers `404` for another user's session — sessions written before it was turned on stay unowned and appear only in the management listing, and `RefuseUnownedSessions` refuses those too once they no longer matter | | 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 | | Document channel | A run's `documents` field | Reference text kept apart from instructions in the message list and the run record; a convention and an audit trail, not a security guarantee | | Multimodal messages | MAF content types plus stored attachments | Providers receive supported image, audio, document, and text content without a new Tracon message abstraction | | Image generation | `UseOpenAIImages()`, `UseAzureOpenAIImages()`, or `UseGoogleImages()` plus `Tracon:Images` | Optional `generate_image` tool stores a verified attachment and records image or token usage | | Speech tools | `Tracon.Voice` and `UseVoice()` | ElevenLabs synthesis and transcription, or consumer implementations of the speech contracts | | Live voice conversation | `UseVoiceConversation()` plus `MapTracon()` | A long-lived WebSocket joins transcription, an agent session, and synthesis; it is absent until registered | | Provider-hosted live voice | `UseLiveVoice()` plus a provider such as `UseOpenAILive()` | The provider hosts the conversation and carries the media directly to the browser; Tracon creates the session so the key never leaves the server, and turns the work the model delegates into ordinary runs | 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 | `Tracon.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 `AddTracon()` | Leases, retries, items, status, cancellation, and handler dispatch | | Custom jobs | `IServiceCollection.AddJobHandler(handlerKey)` | A string handler key adds a new job type without changing the core queue; dispatch is an exact key match, so registration order never decides the winner, and execution is at-least-once. See [Write your own job handler](/guides/write-your-own-job-handler/) | | Queue work from code | `IJobDispatcher.EnqueueAsync()` | Queues a job for a registered handler key and refuses one nobody serves | | 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 | `Tracon:SingletonExecution` | A distributed lease selects one active executor for singleton services | | Run reconciliation | `Tracon:RunReconciliation` | Heartbeats let a scanner fail orphaned runs after process loss | | Run continuation | `Tracon:RunContinuation` | An orphaned, session-bound run resumes as a new run; completed tool calls replay, a destructive or external one blocks continuation unless the tool declares `SafeToRepeat` | | Workflow node retry | `AddWorkflowFunction(..., retryPolicy: ...)` | A transient provider error retries a single node without failing the run or costing an extra super-step | | Graceful drain | `Tracon:Drain` | A stop signal waits for in-flight runs and refuses new ones instead of cutting execution off | 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` via `AddRunJudge()`, instance, or factory, or the built-in `AddModelRunJudge()` | Manual or automatic scores with named criteria | | Calibrated evaluator catalog | `AddEvaluatorJudge(name, evaluator)` with a `Microsoft.Extensions.AI.Evaluation` `IEvaluator` | Each metric the evaluator reports becomes its own `{judge}.{metric}` score row | | Suite grading seam | `AddEvalEvaluatorFactory()` or an instance | Replaces the MAF `LocalEvaluator` an eval suite is graded with | | Online evaluation | Judge registration plus enabled sampling | A bounded sample of live runs is scored in the background | | Eval run comparison | `GET /api/evals/runs/{id}/diff` or `IEvalStore.DiffRunsAsync` | Two runs of a suite align case by case into six buckets; added and dropped cases are never counted as regressions | | Relative CI gate | `tracon eval --baseline --max-regressions ` | A build fails on the cases that broke against an earlier run, not only on an absolute pass rate | | 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 | Tracon 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 | `MapTracon()` 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 | | Session ownership | `Tracon:SessionOwnership` | A second boundary drawn under the tenant; off by default (`false`), and while off nothing changes. The owner comes from `IRunAttributionContext` and is never read from a request body. `RefuseUnownedSessions` extends it to the rows that predate it, while a session that does not exist yet is still opened by its first turn | | Quotas | Run admission | Enabled with an empty rule set, so no run is rejected until a rule exists; a crossed threshold can also be written into the triggering run's own event stream, off by default | | 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 | `Tracon: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 | | At-rest content protection | `AddContentProtection(...)` | Off by default; AES-256-GCM encrypts session state, chat history, run inputs and events, tool arguments/results, agent files, and attachments before they reach the database | | Retention and archive | Stored operational data | Deletion defaults are off (`false`); 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 | `TraconEndpointOptions.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; Tracon can also persist a sample | | Metrics | Run counts, duration, tokens, cost, tools, errors, judges, background-job executions and attempt duration, plus optional quota and job-queue-depth gauges | Standard .NET metrics; high-cardinality tags are bounded and store-backed gauges are opt-in and cached | | Cost attribution | Per model, agent, run, child run, voice, and image usage | Prices come from a model catalog or explicit configuration; image prices are never inferred | | Provider health | Cached status and optional background polling | On-demand by default; a provider without a health check reports `Unknown` | | Health checks | `AddTraconHealthChecks()` | 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 | `AddTracon()` and `IAgentCatalog`, tuned with `ITraconBuilder.Configure(...)` and extended through `ITraconBuilder.Services` | Application code that wants direct MAF objects | | Management HTTP API | `MapTracon()` | The embedded console, automation, or your own client | | Typed management client | `Tracon.Client`'s `AddTraconClient()` | .NET code calling a running instance from outside the process that hosts it; every SSE endpoint also has a `...StreamAsync` method yielding one raw frame at a time | | Typed TypeScript client | `@tracon/client`'s `createTraconClient()` | Browser or Node.js code calling a running instance from outside the process that hosts it; `parseAs: 'stream'` plus the exported `readSse` decoder reads an SSE endpoint | | CLI | `tracon` global tool (`Tracon.Cli`) | Deployment pipelines: `migrate`/`migrate status` apply schema without starting the application, `state-check` reports read-only whether this build can still read the stored session and checkpoint state before an upgrade, `health` checks model provider health, `eval` gates a build on agent quality, absolutely or against an earlier run | | OpenAPI | Your application's `AddOpenApi()` setup | Client generation and API exploration | | OpenAI compatibility | Included in `MapTracon()` | Existing Chat Completions, Responses, and Conversations clients | | Embedded console | `Tracon.UI` and `UseUI()` | Operators, developers, evaluators, and security administrators | | Embeddable chat widget | `Tracon.UI`'s `embed.js` asset (served once `UseUI()` is registered) | A page you embed the widget in, running under its own origin | | MCP client | `Tracon.Mcp` and `UseMcp()` | Agents that consume tools from remote MCP servers | | MCP server | `UseMcpServer()` and `MapTraconMcpServer()` | External MCP clients that invoke explicitly exposed agents as tools | | A2A server | `UseA2A()` and `MapTraconA2A()` | External agents that invoke an explicit allowlist of Tracon agents | | Voice WebSocket | `UseVoiceConversation()` and `MapTracon()` | Browser or native real-time audio clients | | Live voice sessions | `UseLiveVoice()`, a live provider, and `MapTracon()` | Browsers that speak to a provider-hosted model over WebRTC while Tracon supervises the session | `MapTracon()` 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 168-operation HTTP reference. ## Embedding points | Capability | What it gives you | Enable or bind it | Boundary | |---|---|---|---| | Tenant resolution | Resolves the current tenant from your own identity layer | `ITenantContext`, `ITenantStore` | `AmbientTenantScope` carries the tenant into background work outside an HTTP request | | Run attribution | Attributes a run to your own user and job labels | `IRunAttributionContext` | Unset by default; the columns stay `NULL` until you register one | | Tool authorization | Decides whether a caller may invoke a specific tool | `IToolAuthorizationHandler` | Allows every call by default; a thrown exception denies the call | | Run and session authorization | Decides whether a caller may start a run, reach an existing run's resources, or read/list/delete/branch/speak into a session | `IRunAuthorizationHandler` | Allows every call by default; a thrown exception denies the call. Called explicitly at all six run-starting endpoints and at every run-resource endpoint, not one shared filter | | Run event bridge | Bridges run events to your own channel or message bus | `IRunEventSink` | Queue the event and return; a slow sink degrades on its own, never the model stream | | Attachment storage | Stores attachment content in your own object store | `IAttachmentStorage` | Content stays in the database until you register one | | Tool-approval presentation | Turns a pending approval's raw arguments into a resolved entity name | `IToolApprovalPresenter` | Best-effort: not registered, throws, or times out all publish the approval request unchanged | Each contract is registered with `TryAdd`, so a registration made before `AddTracon()` wins over Tracon's built-in default, and `GET /api/diagnostics` reports which of the seven are still built-in. `RequireCustomBinding()` turns a missed binding into a failed startup. A tool body reads the same identity (including `UserId`) through `TraconRunContext`, since it cannot reach `AgentSession` directly. ## 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 | `TraconWriteAgentsFile` | Writes `AGENTS.md` at the repository root during build; an existing file is never overwritten | | Local reference file | `TraconWriteLocalReference`, on by default with the map | Writes `Tracon.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 `Tracon.Core`; `TraconUsageDiagnostics` turns the family off | The `Tracon.Usage` category reports absent wiring, a literal secret, hand-written substitutes for shipped behaviour, instructions that leave the map unreachable, an ambient write that does not survive a streaming loop, and a discarded ambient scope | | Tool diagnostics | Automatic with `Tracon.Core` | The `Tracon.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 project template sets the property, so a project generated from it 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 `Tracon.LocalReference.md` instead, which is what `TRC0402` asks for and what the first section of that file answers. The map names every entry point; it explains none of them. `Tracon.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 `Tracon.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 Tracon; `TryAdd*` preserves the consumer registration | | Provider-free tests | `Tracon.Testing.FakeModelProvider` scripts deterministic model turns | | Integrated tests | `TraconTestHost` 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. 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 definition 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. ## Parameters `Parameters` declares named placeholders an agent's instructions may reference as `{{name}}`: ```csharp new AgentDefinition { Instructions = "You help {{customer}}. Use a {{tone}} tone.", Parameters = [ new AgentParameter { Name = "customer", Kind = AgentParameterKind.Text, Required = true }, new AgentParameter { Name = "tone", Kind = AgentParameterKind.Text, DefaultValue = "formal" }, ], } ``` A run supplies values on the `parameters` field of `POST /api/agents/{name}/run` (and `/estimate`, which checks the same values without calling a model). A required parameter with no value and no `DefaultValue` stops the run before it starts, and names the missing parameter; a value for a name the schema does not declare is rejected too, not silently dropped. This is **value substitution, not a template engine**. There is no expression, condition, loop, or field access (`{{a.b}}`) — a template language is a security surface once it is in a library, and every consumer eventually wants their own dialect. Substitution is single-pass: a value that itself contains `{{name}}` is inserted literally, never substituted again. A placeholder written as a full JSON string value in the instructions (`"customer": "{{customer}}"`) has its value JSON-escaped so the produced text stays valid JSON; anywhere else, the value is inserted as-is. Binding runs once per request, after culture resolution and before compilation, and the compiled agent is never cached for that run — two runs with different parameter values never share a compiled instance, the same rule culture resolution follows. Kind (`Text`, `Number`, `Boolean`) only labels the value's expected shape for the console's own input form; every value travels as a string. An agent with an empty `Parameters` list is entirely unaffected by this feature — `{{...}}` in its instructions stays plain, coincidental text, exactly as before this feature existed. ## Shared instructions blocks A shared instructions block is an ordinary `AgentDefinition` — there is no separate type or table for it. Point another definition at it with `SharedInstructionsName`, and its `Instructions` text is prepended to the referencing definition's own resolved text at compile time: ```csharp // The block: any definition works, even one nobody runs directly. new AgentDefinition { Name = "house-rules", Instructions = "Always cite your source." } // The reference: new AgentDefinition { Name = "support", SharedInstructionsName = "house-rules", Instructions = "Answer billing questions." } ``` Because it is saved through the same store as any other definition, a block gets versioning, tenancy, and the audit trail for free. A block cannot reference another block — the reference is a single hop, checked and rejected at compile time, not a cycle-detecting walk. ## Where agents come from The catalog merges registered sources, in priority order: ```mermaid flowchart LR accTitle: Agent catalog sources accDescr: Code registrations, database definitions, and custom sources merge into one catalog, with lower priority values winning name collisions. C["Code
AddAgent(definition) or AddAgent(name, factory)"] --> CAT["IAgentCatalog"] D["Database
definitions written through the API"] --> CAT X["Custom
IAgentSource registration"] --> 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 Tracon's decorators (recording, telemetry, approval) when it resolves the agent. An application can add an `IAgentSource` for definitions stored outside Tracon or for agents owned by another runtime. Custom-source agents are visible in the console but remain read-only in the management API. See [write your own agent source](/guides/write-your-own-agent-source/) for the lifecycle, priority, tenancy, and contract-test rules. 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"] ``` Five 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 - A child call has a two-layer wait limit: a cooperative deadline that cancels a child reading its token, and a hard cutoff for one that does not. Past the hard cutoff the tree keeps going; the child keeps running in the background and its eventual result is discarded. Set `SubAgentSettings` on the calling agent to override the installation-wide default: ```csharp new AgentDefinition { Name = "router", CallableAgentNames = ["researcher"], SubAgents = new SubAgentSettings { ChildDeadline = TimeSpan.FromSeconds(10), WaitTimeout = TimeSpan.FromSeconds(20), }, // ... }; ``` A timed-out call writes a `ChildRunTimedOut` event to the root run's stream, naming which layer cut it. A sub-call the **provider** broke off is a different thing and is reported as one: no `ChildRunTimedOut` event is written, and the caller is told the model provider did not answer rather than that the sub-agent ran past its limit. So the wait-limit event counts wait limits only — if it moved, one of the two layers above really did fire. The whole tree is readable with `GET /api/runs/{runId}/tree`, from any member. ## Run until the work is done A single agent invocation answers once. `HarnessSettings.Loop` re-invokes the agent until a declared stop criterion says the work is finished, and records every iteration in the run: ```csharp new AgentDefinition { Name = "researcher", Harness = new HarnessSettings { Loop = new LoopSettings { Criteria = [new LoopCriterion { Kind = "completionMarker", Marker = "ALL DONE" }], MaxIterations = 5, }, }, // ... }; ``` The loop is off while `Loop` is null, which is the default. **`MaxIterations` is not `MaximumIterationsPerRequest`.** The two bound different loops. `MaximumIterationsPerRequest` bounds the harness's *inner* tool-calling loop inside one invocation. `MaxIterations` bounds the *outer* loop that invokes the agent again after a criterion says the work is not finished. Four criterion kinds are built in, and each one takes data only: | Kind | What stops the loop | Fields it reads | |---|---|---| | `completionMarker` | The answer contains a marker text | `marker` (required) | | `todoCompletion` | The todo list has no open item left | `modes` | | `aiJudge` | A judge model decides the work is finished | `judgeCriteria` (required), `judgeInstructions` | | `backgroundTaskCompletion` | No background task is still running | — | A criterion whose logic is code is registered in code and referenced by name, the same boundary tools and eval checks live behind: ```csharp // Microsoft Agent Framework marks the loop types for evaluation only, so // naming one in your own code needs this suppression. #pragma warning disable MAAI001 tracon.AddLoopEvaluator("hasCitations", new DelegateLoopEvaluator((context, ct) => new ValueTask( context.LastResponse?.Text?.Contains("[1]", StringComparison.Ordinal) == true ? LoopEvaluation.Stop() : LoopEvaluation.Continue("Add a numbered citation for every claim.")))); #pragma warning restore MAAI001 ``` A definition can then use `Kind = "hasCitations"`. A kind that is neither built in nor registered is refused when the definition is saved, with `400`. It is never ignored: a stop criterion that is silently dropped leaves a loop with no stop criterion. Four more rules are worth knowing before you turn the loop on: - **Criteria are evaluated in order, and the first one that asks for another iteration wins.** The rest are not evaluated that iteration, so the loop stops only when every criterion is satisfied. Put the cheapest criterion first — an `aiJudge` placed after a `completionMarker` costs nothing on the iterations the marker already keeps going. - **The iteration ceiling is never open.** An unset `MaxIterations` takes Tracon's own default of 10. A criterion that can never be satisfied then ends as a bounded run, not as an invoice. - **`aiJudge` calls a model on every iteration it reaches.** It runs on the judge binding configured with `AddModelRunJudge(...)`, never on the agent's own model. Without that binding the definition does not compile. - **A criterion that fails to evaluate stops the loop; it does not fail the run.** The work already finished is returned, and the iteration event names the criterion that failed. Each evaluated iteration writes a `LoopIterationCompleted` run event carrying the iteration number, whether a criterion asked for another iteration, and which one. The criterion's feedback text is not carried in the event. The event marks an *evaluated* iteration, not a model turn. The turn that reaches `MaxIterations` is never evaluated — there is nothing left to decide — so a loop that ends at its ceiling writes one event fewer than it takes turns. Its last event carries `ceilingReached`, which is how the run record tells "the criterion was finally satisfied" apart from "we ran out of iterations". ## Read next - [Runs and recording](/concepts/runs/) — inspect recorded status, events, tokens, and recording limits. - [Tools, skills, and MCP](/concepts/tools/) — choose code tools, instruction skills, or remote MCP integration. --- # Evaluation and experiments Use evaluation suites, judges, experiments, and regression checks to compare agent behavior against explicit criteria. 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/tracon/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 `ITraconBuilder.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/tracon/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, so reordering re-numbers the cases — but a sequence number is only display order. Runs are compared by case **identifier** (see below), so reordering does not line a past result up against a different question. `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. For an agent that declares [parameters](/concepts/agents/#parameters), a case's own `parameters` field supplies the values that run's instructions bind against. A case missing a value the agent requires fails outright, with a reason naming which parameter is missing — the same check `POST /api/agents/{name}/run` applies, so a case that would fail in production fails here too, before any model call is made. 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/tracon/api/evals/support/run curl http://localhost:5081/tracon/api/evals/support/runs ``` ## Comparing two runs A pass rate is a poor regression signal. A suite that slides from 95% to 90% still clears a `0.85` threshold, and nothing in the run's own summary says which five cases stopped working. `GET /api/evals/runs/{id}/diff?baseline={runId}` aligns two runs of the same suite case by case instead: ```bash curl "http://localhost:5081/tracon/api/evals/runs/$SECOND/diff?baseline=$FIRST" ``` Every case lands in exactly one bucket: | Bucket | Meaning | |---|---| | `Regressed` | Passed on the baseline, fails now | | `Fixed` | Failed on the baseline, passes now | | `StillFailing` | Failed on both | | `Unchanged` | Passed on both | | `Added` | Only in the run being judged | | `Removed` | Only in the baseline run | `Added` and `Removed` are deliberately their own buckets. Adding a case to a suite moves the pass rate without anything having broken, and a gate that cannot tell those apart cries wolf every time someone extends a suite. Each entry names both sides' agent run, so a regression is one click from the two conversations that produced it. Two answers are refusals rather than results: - **`409`** — one of the runs holds results for fewer cases than its summary counts, because they aged out of the `eval_case_results` retention window. Retention deletes in batches and can leave a run partly trimmed, so the check counts rather than merely looking for emptiness: comparing the survivors would report every deleted case as `Removed` and quietly narrow the regression count to the rows that happen to remain. - **`400`** — the two runs measure different suites, or one of them never completed. Cases are aligned by identifier, and a case's content is not snapshotted per run. The shipped API makes that safe: `PUT /api/evals/{name}/cases` assigns fresh identifiers, so editing a case shows up as a `Removed` plus an `Added` entry rather than as a silent comparison of two different questions. Calling `IEvalStore.ReplaceCasesAsync` directly while preserving identifiers is the one path that can defeat this, and nothing detects it. ### As a CI gate `tracon eval` turns the same comparison into an exit code: ```bash tracon eval --url http://localhost:5081/tracon --suite support \ --baseline previous --max-regressions 0 ``` `--baseline` takes an eval run id or the word `previous`, which means the newest completed run of that suite before this one. `--max-regressions` says how many cases may break. Three rules keep the gate honest: - `--max-regressions` without `--baseline` is an **argument error** (exit `1`), never a silent no-op. A pipeline must not read a green exit code as "no regressions" when nothing was compared. - A comparison that cannot be made exits **`4`**, not `3`. A lost history needs a different fix than a broken case. - On a suite's first run there is nothing to compare against. That is written to stderr and the gate is **skipped**, not failed — otherwise every new suite's first CI run goes red for no reason. The absolute thresholds still apply: `--min-pass-rate` and `--max-failures` are checked first, and the relative gate only ever adds a check. The eval run screen carries the same comparison, with a baseline picker and the `Regressed` group open by default. 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 a number that survives a restart, use the [persistent score summary](#persistent-score-summary) below instead. 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. For lifecycle, concurrency, timeout, tenant, and retry requirements of a custom judge, see [Write your own judge](/guides/write-your-own-judge/). ### Calibrated evaluators A judge does not have to be a prompt you wrote. `AddEvaluatorJudge` binds any `IEvaluator` from `Microsoft.Extensions.AI.Evaluation` — including the calibrated catalog in `Microsoft.Extensions.AI.Evaluation.Quality`, which grades relevance, coherence, completeness, task adherence, and tool-call accuracy without you writing a scoring prompt at all. Tracon does **not** reference that catalog. Add the package when you want it, so a consumer who does not carries none of it: ```bash dotnet add package Microsoft.Extensions.AI.Evaluation.Quality ``` ```csharp builder.AddTracon() .AddEvaluatorJudge( "relevance", new RelevanceEvaluator(), options => options.Model = new ModelBinding { Provider = "openai", Model = "gpt-5.4-mini" }); ``` Every metric the evaluator reports becomes its **own** score row, named `{judge}.{metric}` — `relevance.Relevance`, `relevance.Coherence`. The judge-name prefix is what lets two evaluators report a metric of the same name without one overwriting the other, because a score name is part of what makes a score unique. A metric the evaluator could not measure is stored with a **null** value, not a zero, and its diagnostics are kept in the comment so the gap is explained. Metrics map onto score kinds by shape: a numeric metric to `Numeric`, a boolean metric to `Binary`, a string metric to `Categorical`. Two limits are worth knowing before you turn this on: - **Each evaluator costs a model call per sampled run.** Registering four of them multiplies the judging cost of online evaluation by four. - **A bridged metric never enters the 0-100 online average.** The calibrated evaluators grade on their own scales, and mixing a 1-5 score into a 0-100 average would fire the low-score alarm on healthy runs. Only a judge's *headline* score — the one named exactly after the judge, as the built-in judge writes — feeds that window. Bridged scores are stored, returned, and aggregated by the [persistent score summary](#persistent-score-summary), which groups by name and kind and so keeps the scales apart. A judge sees the run's input, its output text, and the **names** of the tools it called — not their arguments or results. An evaluator that grades tool calls has nothing to grade there and reports no measurement. ### Replacing what grades a suite Eval suites are graded by MAF's `LocalEvaluator`, built from the checks the suite declares. `AddEvalEvaluatorFactory` replaces that choice. The factory receives the suite's compiled checks, so it can wrap the built-in behaviour rather than discarding what the suite asked for. Registering none changes nothing. ## 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. Every score carries a **name** — `helpfulness`, `accuracy`, `severity` — and the name is part of what makes a score unique. One reviewer can therefore score the same run several times over, once per name, and writing the same name again updates that row instead of opening another. A request that sends no name gets `overall`, so a client that never asks for names keeps a single score per reviewer. A name is a low-cardinality label matching `[A-Za-z0-9._-]{1,64}` — the same rule a judge name follows, because a judge writes its own name onto the score it produces. It is used as a metric tag, so a run id or a timestamp does not belong there. A score carries one of four shapes: | Kind | Carries | Example | |---|---|---| | `Binary` | `value` 0 or 1 | thumbs down / thumbs up | | `Stars` | `value` 1 to 5 | a star rating | | `Numeric` | `value` 0 to 100 | a judge's score, or a similarity of `0.87` | | `Categorical` | `textValue` | `minor`, `major`, `blocking` | `value` is a decimal, so `0.87` is stored as `0.87`. A **null** `value` means no measurement was made — not zero. Zero is a measurement; the absence of one is not, which is the same rule a judge follows: a judge that cannot decide returns an empty `RunJudgment` and writes no row at all. Deleting a score is written to the audit trail: removing a judgement is itself traceable. ## Persistent score summary `GET /api/evaluation/scores/summary` aggregates the scores already written — human and judge scores together, since they live in the same list. Unlike the [online evaluation](#online-evaluation) summary, this one reads persisted rows, so the same request made before and after a restart returns the same result. Every breakdown groups by **name and kind together**: a 1-5 star rating and a 0-100 numeric score sharing a name never average into one number. `byName` is always returned; `byAuthor`, `bySource`, and `byAgent` narrow the same data by a different dimension. A `Categorical` group reports a count per category instead of an average. ``` GET /api/evaluation/scores/summary?bucket=day&from=2026-09-01T00:00:00Z ``` ```json { "byName": [ { "key": "overall", "kind": "Binary", "count": 42, "noValueCount": 0, "average": 0.83 } ], "series": [ { "bucketStart": "2026-09-01T00:00:00Z", "groups": [ /* one entry per (name, kind) scored that day */ ] } ] } ``` `bucket` (`hour`, `day`, or `week`, UTC) adds the `series` field — a trend over time, one entry per bucket that actually has a score. A bucket with nothing scored in it is left out; the series is sparse, not filled. Leaving `bucket` out costs nothing extra and returns an empty `series`. A `bucket` given with no `from` defaults the whole query (breakdowns included) to the last 90 days — a series has no other bound the way a breakdown does; pass `from` explicitly for an unbounded breakdown alongside it. `messageId` is never a breakdown dimension — its cardinality is unbounded. Use `target` (`run`, `message`, or `any`, the default) to narrow to run-level or message-level scores instead. Every breakdown, and the categories inside one `Categorical` group, is capped at `maxRows` (default 20, max 500). A group's `truncatedCategoryCount` reports how many categories did not fit, so a long tail never silently disappears. ## 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/) — configure approvals, quotas, guards, and audit behavior. --- # 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 `AddTracon()`; 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: Tracon 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. Isolation lives in the application layer, and that is a deliberate choice. Every query carries the resolved tenant; Tracon does not create database row level security policies, and it does not assume your database has them. Two reasons: the coverage gate above already makes an untested store method a build failure, and SQLite has no row level security at all, so adding it would make the three providers behave differently. You are free to add such policies in your own database. If you do, keep the tenant that Tracon resolves and the tenant your policy binds to the connection in agreement — they are two separate mechanisms. Rate limits are not an isolation boundary. `Tracon:RateLimit` and the inbound trigger limit count in the memory of one process, so a `Tenant` partition splits that instance's own window per tenant rather than a window shared across the deployment. What binds a tenant's total consumption is a quota, and quotas are counted in the database. ### 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**. Tracon 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 — Tracon 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. ::: ### Below the tenant: session ownership The tenant is the data boundary, and it is the only one Tracon draws by default: inside a tenant, every `Reader` sees every session. Turning on `Tracon:SessionOwnership` adds a second, narrower line under it — a session records which user opened it, and the session list narrows to that user. It governs sessions only, and it never crosses the tenant: the same person in two tenants still has two independent data spaces. Ownership is not retroactive, so rows written before you turned it on belong to nobody. They fall out of every user's list immediately; `RefuseUnownedSessions` refuses them outright once the conversations they hold no longer matter. See [Sessions: session ownership](/concepts/sessions/#session-ownership) for the behaviour and the migration notes, and [Embedding](/guides/embedding/#6--run-and-session-authorization) for how it composes with `IRunAuthorizationHandler`. Every one of these handlers has a permissive built-in default, and a host that binds nothing starts silently on it. Where that silence is unacceptable, declare the binding required with [`RequireCustomBinding()`](/guides/embedding/#make-a-binding-required): the host then refuses to start while Tracon's default is what resolves. It gates composition, not the decision the handler goes on to make. ## 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, skills, MCP servers, tenants, approval rules, API keys, quotas, retention policies, egress policies, provider bindings, triggers, webhooks, and approval decisions all land in it. Skills are in that list for the same reason agent definitions are: a skill carries the instructions the model is given **and** the scripts that run on your server, so `skill.create`, `skill.update`, and `skill.delete` are governance changes, not content edits. 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. Most writes happen in store **decorators** rather than in endpoints, so no code path can change one of those entities without an entry. The rest are written by the endpoint itself, where the audited unit is the operation and not the row it touches — branching a session, erasing a data subject, or rotating an API key. 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/tracon/api/audit?action=agent.update' curl 'http://localhost:5081/tracon/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/tracon/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. By default an approver sees the raw call: `{ "orderId": "ORD-1001" }`. Register `IToolApprovalPresenter` to turn that into "Cancel order for Priya Shah" — implement `PresentAsync`, reading whatever the call's arguments name, and return a `ToolApprovalPresentation` (an entity type, id, name, and a free-form message; every field is optional). ```csharp public sealed class OrderApprovalPresenter(IServiceScopeFactory scopes) : IToolApprovalPresenter { public async ValueTask PresentAsync( ToolApprovalContext context, CancellationToken cancellationToken = default) { if (context.GetString("orderId") is not { } orderId) { return null; } using var scope = scopes.CreateScope(); var orders = scope.ServiceProvider.GetRequiredService(); var order = await orders.FindAsync(orderId, cancellationToken); return order is null ? null : new ToolApprovalPresentation { EntityType = "order", EntityId = orderId, EntityName = $"Order {orderId}", Message = $"Cancel order {orderId} for {order.CustomerName}.", }; } } services.AddSingleton(); ``` Register it as a singleton and reach a scoped dependency, such as a `DbContext`, through an injected `IServiceScopeFactory` — the same rule as [the empty service provider mistake](/getting-started/tools/#the-rule-that-trips-people-up), because this runs on the same tool-call path. Unlike authorization and validation, a presenter is not a gate: it fails **open**. Not registered, resolves nothing, throws, or runs past its timeout (`TraconToolOptions.ApprovalPresentationTimeout`, 2 seconds by default) — the approval request publishes either way, with the raw arguments still there. A presentation is decoration for a decision a human still has to make from the real call, never a replacement for it. The resolved presentation reaches every surface a pending request does: the mailbox list, the single-request read, the `RunAwaitingInput` run event, and the console's own approval card. :::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-validation-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. You are told **before** the wall, not only at it. As the counter crosses each percentage in `Quotas:ThresholdPercents` (`80` and `100` by default) Tracon posts a [`quota.threshold`](#webhooks) webhook carrying the metric, the period, the limit, the consumption, the threshold crossed, and when the counter resets. Each threshold fires **once per period**, so a counter that keeps climbing past `80` does not re-notify. Set the list to empty to turn the notifications off. The threshold event is a notification, not a decision: it stops nothing, and `429` remains the only thing that refuses a run. Turning on `Quotas:PublishThresholdToRunStream` (off by default) also writes the crossed threshold into the *triggering run's own* event stream — a `custom` frame carrying `tracon.quota.threshold` before the run's terminal event, so a client already watching that one run's SSE stream sees the warning without a separate webhook subscription. The frame's payload carries a `noticeId` (stable across a reconnect, for dedup), the run and user the threshold belongs to, and the same metric/period/limit/consumption fields the webhook carries. A threshold is claimed for notification **once per period** durably — a restart or a second worker process never re-announces one that already fired. Only the root run of a call tree ever carries the notice — consumption is counted once at the tree's root, the same scope [runs](/concepts/runs/) are already counted and billed at. :::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 during execution, so brief overshoot is possible by design. ::: This is a *different* budget from the one every root run's call tree carries (`AgentGraph.MaxTotalTokens`/`MaxTotalCost`/`MaxDuration`, see [Reliable runs](/guides/reliability/#bound-multi-agent-trees)): a quota is scoped to a tenant or agent over a day or month and never interrupts a run in progress; the call-tree budget is scoped to one run's tree and is checked between model turns, so it *does* cut a long tool loop off mid-run. ## 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, Tracon 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/tracon/api/retention/preview' curl -X POST 'http://localhost:5081/tracon/api/retention/run' curl 'http://localhost:5081/tracon/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"). Tracon 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/tracon/api/data-subjects/user-42/export' curl -X DELETE 'http://localhost:5081/tracon/api/data-subjects/user-42' curl -X DELETE 'http://localhost:5081/tracon/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 protection `AddContentProtection(...)` encrypts session state, chat history, run inputs and events, tool arguments/results, agent files, and attachments with AES-256-GCM before they reach the database, and decrypts them transparently on read. Off by default, like the content guards below — turning it on is a deliberate call, and only new writes are protected: a row's own content, not configuration, decides whether it needs decrypting. See [at-rest content protection](/getting-started/security/#at-rest-content-protection) for the key configuration and its limits. ## 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. ### Source-aware decisions `ContentGuardContext` carries a `Source`: `UserMessage`, `ToolResult`, `Document`, `ModelOutput`, or `SkillResource`. A user message and a tool result used to enter a guard the same way — both are `Direction.Input` — even though the trust level is not the same. A user can only poison their own session; a tool result can carry text a different tenant's data wrote into a shared system, which is the most common prompt-injection path. A guard reads `Source` to apply a stricter rule to `ToolResult` than to `UserMessage`, or to skip a check that only makes sense for one of them. When `Source` is `ToolResult`, `ToolName` carries the tool's name if it can still be resolved from the same message list — a many-turn conversation can drop the earlier tool call from context, leaving `ToolName` `null` even though `Source` still reads `ToolResult`. A security decision keys off `Source`, never off whether `ToolName` happened to resolve. `Source` defaults to `Unknown` for content a guard cannot classify. `Unknown` is never a reason to relax a check — a guard should treat it at least as strictly as its most sensitive known source. The built-in pattern guard does not read `Source` at all: the same denied-term and PII patterns apply everywhere, including `Unknown`. :::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. Tracon does not retain a hidden raw copy for later inspection. ::: ## The document channel `documents` on `POST /api/agents/{name}/run` attaches reference text to a run, wrapped in a delimiter and marked apart from the agent's instructions: ```json { "message": "Summarize the attached policy.", "documents": [{ "name": "policy.md", "content": "Refunds within 30 days." }] } ``` :::caution[Not a security guarantee] This is a **convention and an audit trail, not a security guarantee**. No provider gives a hard promise that content wrapped this way is never treated as an instruction — a capable-enough model can still be steered by content inside a document. The value is in keeping the data channel visibly separate in the transcript and in the run record, and in the boundary marker surviving content that tries to imitate it: a document containing the literal delimiter has that occurrence defanged, so it can never forge the end of the document and make the model treat what follows as a new set of instructions. ::: The run record keeps the document's **name and size**, in its own event — never the content, which already lives with the rest of the run's recorded input, subject to the same [content protection](#content-protection) and retention settings as everything else. ## Webhooks Subscribe to events and Tracon posts them to your endpoint. | Event | Fires when | |---|---| | `run.completed` | A run completed successfully | | `run.failed` | A run ended in an error | | `approval.pending` | A tool call is awaiting approval | | `workflow.request.pending` | A workflow is awaiting human input | | `job.completed` | A queued job completed successfully | | `job.failed` | A queued job ended in an error | | `eval.completed` | An evaluation run completed | | `quota.threshold` | A quota counter crossed a percentage in `Quotas:ThresholdPercents` — once per period | | `run.score.low` | The online-evaluation window's average score dropped below `OnlineEvaluation:LowScoreThreshold`, once the minimum sample count is met | | `test.ping` | You sent a test delivery to verify the subscription | 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/) — bind endpoint authorization and tenant resolution to your host. - [The HTTP API](/http-api/) — apply authentication, pagination, and error conventions to HTTP calls. --- # Architecture Tracon 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: Tracon 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["Tracon.UI
embedded React console"] HTTP["Tracon.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["Tracon.Core
catalog · compiler · tool registry
run recording · session manager · in-memory stores"] ABS["Tracon.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 These conventions explain the default setup and the extension model. ### Explicit infrastructure `AddTracon()` supplies in-memory store defaults, so the runtime comes up without a database behind it. Configure a model provider or a custom agent source for execution, and choose persistence when data must survive a restart. The console is a separate step: add `Tracon.UI`, call `UseUI()`, and map the endpoints with `MapTracon()` — see [the console guide](/ui/). No particular model vendor is required either — OpenAI, Anthropic, Google, Azure OpenAI, and any OpenAI-compatible endpoint (including a self-hosted engine like Ollama or vLLM) can be registered 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 Tracon 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. Tracon is a *control plane*, not an *abstraction layer*. ### Replaceable services Default service registrations use `TryAdd`, so an implementation your application registers before `AddTracon()` is the one that stays. The same seam covers MAF's own hosting types, which is why interfaces like conversation storage can be swapped out. Each extension guide states the lifetime and registration contract its seam expects. ## Where things live | | | |---|---| | Contracts, records, enums | `Tracon.Abstractions` | | Catalog, compiler, recording, in-memory stores | `Tracon.Core` | | Endpoints, access layers, OpenAI compatibility | `Tracon.AspNetCore` | | Schema, migrations, vector search | `Tracon.PostgreSql` and friends | | The console | `Tracon.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 Tracon 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` applies the registered decorators. The four built-in decorators have this outer-to-inner order: ```mermaid flowchart LR accTitle: Agent execution decorator order accDescr: Run recording wraps telemetry, tool approvals, structured response validation, and the compiled agent. Custom decorators can participate through their configured order. REC["RunRecordingAgent
order 0 — outermost"] --> OTEL["OpenTelemetryAgent
order 10"] OTEL --> APR["ToolApprovalAgent
order 20"] APR --> VALIDATE["StructuredResponseValidatingAgent
order 30"] VALIDATE --> AGENT["the compiled AIAgent"] ``` Recording is outermost among the built-in decorators, so its duration includes the inner layers. Structured response validation is innermost and checks the final agent response. Tool approval sits between validation and telemetry. A custom `IAgentDecorator` can change the surrounding chain through its `Order`. 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. `modelProvider` names the provider that actually answered — the same provider `modelId`'s model came from. `cost` is a **price snapshot**: it also carries the unit price (per million tokens) that was applied for input, output, and any prompt-cache read, computed once when the run ends. A later change to your pricing catalog or configuration never rewrites a run's already-known cost — `POST /api/stats/recalculate-costs` only fills in runs whose price was unknown at the time, it never re-prices a run that already has one. 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. A run ends **once**, and which ending it gets is decided by what actually happened rather than by the exception type that surfaced. `Canceled` means somebody asked for the run to stop — the caller's request went away, or a cancel request reached the process that owns the run. A model call that simply never came back is `Failed` with the `Timeout` error class, even though .NET reports an `HttpClient` timeout as a `TaskCanceledException`. The distinction matters when you alert on these: cancellations are user behaviour, timeouts are an outage. ```bash curl -N http://localhost:5081/tracon/api/runs/{runId}/events ``` ### Two SSE contracts, not one This is a **different** stream from the one `POST /api/agents/{name}/run` returns, and the two do not share a frame-name contract. Both report `Content-Type: text/event-stream`, so the `event:` name is the only way to tell them apart: | Stream | Frame names | |---|---| | `POST /api/agents/{name}/run` (and the workflow and OpenAI-compatible equivalents) | `run` · `update` · `approvals` · `done` · `error` — one per step of that single live call | | `GET /api/runs/{runId}/events` | `run.started` · `message.delta` · `tool.invoking` · `tool.invoked` · `tool.failed` · `run.completed` · `run.failed` and one more per event type in the diagram above — the recorded, append-only log | A client written against one contract will not decode the other; pick the endpoint that matches what you are building — a live turn, or a run's full recorded history. 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**. ### Writing your own event The event types above are a closed set — the console maps every one of them to a specific visual, and a client can rely on that never changing shape. `RunEventType.Custom` is the one deliberate escape hatch: your own tool writes it directly, through the writer your run already carries: ```csharp [TraconTool("mark_preview_ready", "Marks an order's preview as ready to review.")] public static async Task MarkPreviewReady(string orderId) { var writer = TraconRunContext.Current?.Writer; if (writer is not null) { await writer.AppendAsync(new RunEventDraft(RunEventType.Custom) { CustomType = "contoso.preview-ready", Payload = $$"""{"orderId":"{{orderId}}"}""", }); } return $"Preview for order {orderId} is ready to review."; } ``` `CustomType` names your event — 1-128 characters, lowercase ASCII letters, digits, `.`, `_`, or `-`. It is required on a `Custom` event and rejected (`ArgumentException`) on every other type: a caller who sets it on a built-in event type gets told immediately, rather than having it silently dropped by every store. The `tracon.` prefix is reserved, so a future built-in custom type can never collide with your own — `tracon.quota.threshold` (the [quota threshold notice](/concepts/governance/#quotas-and-rate-limits)) is the one built-in use of it today; `AppendAsync` rejects any value under that prefix, so a `Custom` event carrying it can only have come from Tracon itself. `Payload` is yours too — Tracon makes no claim about its shape and never reads it. The console draws an unrecognized `CustomType` with a single generic card — its own name as the title, `Payload` pretty-printed as the body — so a new custom type never needs a console change to show up. When a built-in event type already fits what happened, use that instead; `Custom` is for events Tracon has no name for. ## 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 : IRunEventSink { // Bounded and non-blocking: a full channel drops the oldest event rather // than holding up the run. Your own background reader drains it. private readonly Channel _pending = Channel.CreateBounded( new BoundedChannelOptions(1024) { FullMode = BoundedChannelFullMode.DropOldest }); public ChannelReader Pending => _pending.Reader; public ValueTask OnEventAsync(RunEvent runEvent, CancellationToken cancellationToken = default) { _pending.Writer.TryWrite(runEvent); return ValueTask.CompletedTask; } } services.AddSingleton(); ``` A sink runs on the hot path — Tracon awaits `OnEventAsync` directly and holds no queue of its own in front of it, so the buffer above is yours to own. Queue and return; do not publish to a message bus inline. 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 AddTracon(); Tracon 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**. Tracon 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 `tracon.tokens` or `tracon.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/tracon/api/runs?userId=user-42" curl "http://localhost:5081/tracon/api/runs?label=team:payments" curl "http://localhost:5081/tracon/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. ## Who is allowed to start it Attribution answers "who did this, for the cost report"; it does not by itself stop anyone from starting a run. Tracon draws ownership at the **tenant** level, so by default any caller with the `Operator` role in a tenant can start any agent in that tenant, regardless of `UserId`. Bind `IRunAuthorizationHandler` to enforce your own per-user rule. It is called at every endpoint that starts a run — the agent run endpoint, the workflow run endpoint, the inbound trigger accept endpoint, the OpenAI-compatible `/v1/responses` and `/v1/chat/completions` endpoints, and `POST /api/runs/{id}/replay` — **before** the quota check, so a denied call never consumes the tenant's quota. The same method is asked again for every access to an existing run's resources: reading it, canceling it, scoring it, and reaching its attachments and approval requests. Which question is being asked is on `request.Access`, and a resource question carries `request.RunId`: ```csharp public sealed class YourRunAuthorizationHandler(IYourOwnershipService ownership) : IRunAuthorizationHandler { public async ValueTask AuthorizeRunAsync( RunAuthorizationRequest request, CancellationToken cancellationToken = default) => request.RunId is { } runId ? await ownership.OwnsRunAsync(request.TenantId, request.UserId, runId, cancellationToken) ? RunAuthorizationResult.Allow() : RunAuthorizationResult.Deny("This run belongs to a different user.") : await ownership.CanStartAsync(request.TenantId, request.UserId, request.AgentName, cancellationToken) ? RunAuthorizationResult.Allow() : RunAuthorizationResult.Deny("This user cannot run this agent."); public ValueTask AuthorizeSessionAsync( SessionAuthorizationRequest request, CancellationToken cancellationToken = default) => new(RunAuthorizationResult.Allow()); } builder.Services.AddSingleton(); ``` Register nothing and nothing changes: every run starts and every run stays readable, exactly as before this binding existed. If the handler throws, the call is denied (fail-closed). A denied single resource answers `404`, with a body identical to a run that does not exist — a `403` there would confirm the run exists; a denied list answers `403`. See [Embedding: run and session authorization](/guides/embedding/#6--run-and-session-authorization) for the full `RunAccess` table, the session half of the same contract (list, read, delete, branch, voice), and the exact response shape each denial produces. ### Starting a run from .NET with explicit identity `TraconRunOptions` 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 Tracon 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 | | `ContinuedFromRunId` | The interrupted run this one continues, set automatically — not something you set by hand | | `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: Tracon 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. ## 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, a rejected structured response. 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. Both the class and the clustering fingerprint come from a classifier you can replace or compose with your own rules — see [Write your own error classifier](/guides/write-your-own-error-classifier/). ### The error message is safe to display, not safe to debug from `error.message` is deliberately shallow. When the failure is Tracon's own — a content filter, a blocked guard, a quota — the message is the same stable text you'd write in a UI. When the failure comes from somewhere else (a provider SDK, a webhook target, an MCP connection), the message carries only the exception's type name and a correlation id, for example `HttpRequestException failed. (ref: 7f3a9c21)`: a foreign exception's own text can carry a request detail, an internal address, or a partial credential, and none of that belongs in a persisted field or an HTTP response. The full detail, matched to the same correlation id, goes to your server's own log — that is where you debug a specific failure from, not from the run record. This applies everywhere a run, job, or webhook delivery can fail: the `error`/`error_message` field on a run, job, or webhook delivery, and the error body of the HTTP, SSE, and MCP endpoints, all follow the same rule. A failure the queue itself produces — rather than a handler — carries a stable code instead of prose. A job queued for a handler key nobody registered fails with `tracon.job.unknown-handler-key`, and the key itself is written to the log rather than to `errorMessage`, for the same reason a foreign exception's text is: the key may have come from an untrusted source, and that field is read back over HTTP. Match on the code, never on the sentence around 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. An agent carrying a [client-side tool](/guides/client-side-tools/) cannot be replayed in any mode — its call was never recorded on the server - `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. ## Continuing an interrupted run Replay is something you ask for, on a run you choose, sessionless. Continuation is automatic: when reconciliation closes a run a process never finished, a session-bound run can be picked up again in that same session, as a new run whose `continuedFromRunId` points back at the interrupted one. The run tree shows the link, so an operator sees "this run continued that one" rather than two unrelated rows. Continuation is off by default and does not run every tool call again — see [Continue an interrupted run automatically](/guides/reliability/#continue-an-interrupted-run-automatically) for what gets replayed, what runs live, and which tools it refuses to continue. ## Read next - [Sessions and conversations](/concepts/sessions/) — retain conversation state and understand session lifetime. - [Evaluation and experiments](/concepts/evaluation/) — compare agent versions using cases, judges, and experiments. --- # 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, Tracon 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) alt first save of this session Manager->>Store: TryCreateAsync(record) else every later save Manager->>Store: TryUpdateAsync(record, versionRead) end ``` The caller drives it. Tracon 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. ## Two turns at once Neither save is unconditional, and both refuse rather than overwrite. | Situation | What the store is asked | If another writer got there first | |---|---|---| | First save of a new session | `TryCreateAsync` | `TraconSessionConflictException` | | Every later save of that session | `TryUpdateAsync(record, versionRead)` | `TraconSessionConflictException` | `SessionRecord.Version` is the record's write generation. A save replaces the exact generation it read; if another turn advanced it meanwhile, the write is refused and the caller is told. The alternative — overwriting — loses a turn that the run record already reports as successful, with nothing anywhere saying so. Over HTTP that surfaces as `409 Conflict` on both `/api/agents/{name}/run` (a problem document whose `errorType` is `session_conflict`) and the OpenAI-compatible `/v1/responses` (an error envelope whose `error.type` is the same value). **Retry the turn**; the conflict means the conversation moved on, not that anything is broken. On a streaming request the response headers are already sent, so the same condition arrives as an `error` event in the stream instead of a status code. Saving a session under a **different** id than it was read from — what `/v1/responses` does when it chains with `previous_response_id` — writes a new record rather than replacing the source one, so no generation applies and the write is unconditional. ## Persisted payload compatibility `SessionRecord.State` is the Microsoft Agent Framework's own serialized format; Tracon does not interpret it. `SessionRecord.StateSchemaVersion` (Tracon's own envelope generation) and `StateMafVersion` (the Microsoft Agent Framework package version that wrote `State`) are stamped on every save so that a failed restore can report exactly what was recorded instead of guessing. See [Versions and upgrades](/reference/versioning/#persisted-session-and-checkpoint-state) for the full compatibility policy and what to do when a session cannot be restored after an upgrade. ## 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. A provider-hosted live voice session writes into this same history: when it closes, the conversation's transcript is appended as ordinary user and assistant messages, so `GET /api/sessions/{sessionId}` shows what was actually said. That write is a retention decision and can be switched off — see [voice privacy and retention](/guides/voice/#privacy-and-retention). ## 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. ## Who can access a session By default everything above is tenant-scoped: any caller with the `Reader`/`Operator` role in a tenant can list, read, delete, and branch every session in that tenant, regardless of which user opened it. There are two ways to draw a narrower line, and they compose. **Session ownership** is built in and needs no code. **`IRunAuthorizationHandler`** hands the decision to your own policy. ### Session ownership Turning ownership on makes Tracon record which user a session belongs to, and narrow the session list to that user: ```json { "Tracon": { "SessionOwnership": { "Enabled": true } } } ``` The owner comes from `IRunAttributionContext` — the same interface that names the user on a cost report — and is read at the moment a session is **opened**. It is never read from a request body: a `userId` field there would let any client open a session under someone else's name. | With ownership on | What happens | |---|---| | `GET /api/sessions` | Only the caller's own sessions, narrowed **before** paging, so `take=3` returns three of *their* sessions | | `GET`/`DELETE`/`POST …/branch` on another user's session | `404`, byte for byte identical to a session that does not exist | | Starting a run against another user's session | `403` — continuing a conversation reads its whole history back, so the run surface is guarded too | | A branch of your own session | The copy inherits **your** ownership; branching is a copy, not a handover | | No identity can be resolved | The session is not opened at all: `403` with `errorType` `session_owner_required` | Three properties are worth knowing before you turn it on: - **It is not retroactive.** Sessions written before you enabled it have no owner. Tracon cannot invent one for a conversation it did not watch being opened. Those sessions stay readable by id, so nothing that was live at the moment of the flip breaks — but they no longer appear in any user's list. Once they no longer matter, `RefuseUnownedSessions` closes that door too; see below. - **Someone still needs the whole list.** A caller who satisfies `Tracon:SessionOwnership:ManagementPolicy` (default: the `Operator` role policy) gets the unfiltered tenant listing, including those unowned rows. If the policy is not registered, *nobody* gets the unfiltered list — the failure direction is deliberate. Set it to `""` to state that outright. - **Ownership is drawn under the tenant, never across it.** The same person in two tenants still has two independent data spaces. `RequireAuthenticatedOwner` (default `true`) is what turns an unresolvable identity into a refusal. Turning it off lets unowned sessions be opened again, which is only useful while migrating: such a session is invisible in its own caller's list from the moment it is written. Sessionless runs are unaffected — there is nothing to own. #### Refusing the unowned rows too An unowned row is not discoverable — it appears in no user's list — but by default it is still readable by anyone in the tenant who knows its id. `RefuseUnownedSessions` (default `false`) turns that into a refusal: ```json { "Tracon": { "SessionOwnership": { "Enabled": true, "RefuseUnownedSessions": true } } } ``` | With strict mode on | What happens | |---|---| | `GET`/`DELETE`/`POST …/branch` on an unowned session | `404`, byte for byte identical to a session that does not exist | | `GET`/`DELETE` `/v1/conversations/{id}` on an unowned session | The same `404` — the OpenAI-compatible routes reach the same sessions | | A voice socket on an unowned session | Refused with `404` | | Starting a run against an unowned session | `403` with `errorType` `session_owner_required` | | A caller who satisfies `ManagementPolicy` reading one | Still `200` — support keeps the access it already had in the management listing | | A caller who satisfies `ManagementPolicy` starting a run on one | `403` — that exemption covers reading a conversation, never appending to it | | A session that **does not exist yet** | Unchanged: the first turn opens it and claims it | The last row is the one to hold on to. "Never created" and "created without an owner" are different rows and get different answers; if they were folded together, the first turn of every new conversation would be refused. The setting does nothing on its own — with `Enabled` off, no owner is ever read. Default `false` because turning it on strands every conversation that was live at the moment you enabled ownership. Turn it on once those conversations no longer matter, or from day one in a deployment that has no unowned rows at all. ### Your own authorization handler Ownership answers "which user", and only for sessions. For anything else — per-project rules, shared conversations, an external policy service — bind `IRunAuthorizationHandler`: ```csharp public sealed class YourRunAuthorizationHandler(IYourOwnershipService ownership) : IRunAuthorizationHandler { public ValueTask AuthorizeRunAsync( RunAuthorizationRequest request, CancellationToken cancellationToken = default) => new(RunAuthorizationResult.Allow()); public async ValueTask AuthorizeSessionAsync( SessionAuthorizationRequest request, CancellationToken cancellationToken = default) { // request.SessionId is null only for SessionAccess.List. if (request.Access == SessionAccess.List) { return RunAuthorizationResult.Allow(); } return await ownership.OwnsAsync(request.TenantId, request.UserId, request.SessionId!, cancellationToken) ? RunAuthorizationResult.Allow() : RunAuthorizationResult.Deny("This session belongs to a different user."); } } ``` The response shape follows the same "don't confirm what shouldn't be seen" rule the OpenAI-compatible conversations above already use: a denied **read**, **delete**, or **branch** returns `404` with the exact same body a genuinely missing session gets, and a denied **list** returns `403`. Note the difference from ownership: a handler answers a yes/no question, so a "no" on a list rejects the whole call rather than quietly returning fewer rows. Ownership is a filter and does narrow the list. See [Embedding: run and session authorization](/guides/embedding/#6--run-and-session-authorization) for the run-starting half of the same contract. With ownership on, a handler no longer has to reject a whole listing just to keep users apart — the listing arrives already narrowed, and the handler is free to answer the questions ownership cannot. ## 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/) — inspect recorded status, events, tokens, and recording limits. - [Attachments and multimodal input](/guides/multimodal/) — attach validated files, images, and audio to a conversation. - [Workflows](/concepts/workflows/) — coordinate multiple agents with checkpoints and human input. --- # 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. ### Who owns what Six concerns belong to the registry and its pipeline, never to a tool body: | Concern | Owned by | |---|---| | Authorization (can this caller call this tool at all) | `IToolAuthorizationHandler` in the pipeline | | Approval (does a person need to say yes this time) | The registry's approval wrapper | | Timeout | The registry's timeout wrapper | | Output truncation | The registry's truncation wrapper | | Tenant scoping | `TraconRunContext.Current` | | Audit (what ran, with what result) | Run recording, driven from the registry | A tool body reads `TraconRunContext.Current` when it needs the tenant, run, or session — it never re-implements any of the other five; they already ran before the body was ever invoked. ### Lifecycle and concurrency The registry is a **startup snapshot**. It is built once, from every `AddTool*` call made during startup, and never mutates afterward — there is no runtime `AddTool`. MCP tools are the one deliberate exception: they are discovered from a remote server and can appear or disappear as that server's own catalog changes, which is why MCP has its own, separate dynamic model instead of sharing the code registry's snapshot guarantee. Every tool instance is a **singleton**, shared by every tenant, every run, and every thread that happens to call it. Keep no per-call state in an instance field — read [the empty service provider rule](/getting-started/tools/#the-rule-that-trips-people-up) for the related mistake of resolving a scoped dependency the same way. When a dependency genuinely has to be fresh per call, register the tool with `AddScopedTool` instead of `AddTool` — see [scoped dependencies](/guides/write-your-own-tool/#scoped-dependencies). Because a tool is shared, the same tool can already run **concurrently across different runs** today, whether or not `AllowConcurrentToolCalls` is set — that setting only governs whether one run's own turn calls its several tools one after another or at the same time. A tool body that keeps no mutable instance state handles both cases for free. ### Authorization, validation, and timeout Three more wrappers apply next to approval, in a fixed order: **authorization** (outermost), **validation**, **timeout**, then **approval** (innermost), then the real method. The same order applies whether the tool is code-defined or MCP-sourced — one composition point builds both. 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. Validation asks a narrower question, right after authorization: "are these specific arguments acceptable". Binding already rejects a type mismatch or a missing `required` field — including a required member of a generated tool's **object** parameter (a public record or class with a single public constructor, see [Write your own tool](/guides/write-your-own-tool/)) — and register `IToolArgumentsValidator` for anything binding does not catch: an extra field the schema does not declare, for example, or a generated `minimum`/`maximum`/length/`pattern` constraint, which the schema carries but binding never enforces on its own; a validator that wants to enforce one reads `tool.JsonSchema`. Unlike a denial, a **rejected** call is recorded as `ToolFailed`, not as an ordinary result — see [argument validation](/guides/write-your-own-tool/#argument-validation) for the full mechanism, including the fail-closed behavior on a throwing validator. `[TraconTool]` also carries an effect class and a per-tool timeout: ```csharp [TraconTool( "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. By default the approval card an operator sees carries only this raw call; register [`IToolApprovalPresenter`](/concepts/governance/#approvals) to show a resolved entity name instead. 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. Timeout is **not cooperative**: it never forcibly stops the body, and it never hands the body a linked, timeout-aware token either — the body only ever sees the *caller's own* `CancellationToken`. A tool that never reads that token keeps running to completion, possibly with a real side effect, after the model has already moved on; only the *wait* is cut short. The timeout applies to execution only, never to a pending approval, which can wait indefinitely. A tool body can also run **more than once for the same logical call**, on two different timelines that are easy to conflate: - **Within one turn:** Microsoft Agent Framework retries a throwing tool call up to `MaximumConsecutiveErrorsPerRequest` (3 by default) times before giving up and re-throwing to the caller. A tool that is not safe to call twice in a row needs its own idempotency guard, regardless of any Tracon setting. - **Across an interrupted run:** `SafeToRepeat` (only read for `Destructive`/`External` tools) tells Tracon whether resuming a run that was cut off mid-call may repeat that call. It says nothing about the in-turn retries above. The one case that does **not** repeat a completed call is a provider fallback: if a call already finished before the primary provider failed, the fallback model asking the same question again is answered from that result instead of running the tool's body a second time — see [Fall back to a secondary provider](/guides/reliability/#fall-back-to-a-secondary-provider). ### Result representation and persistence Whatever a tool returns, Tracon turns it into one **canonical text form** before anything else — a content guard, the output limit — inspects it. `null`, `string`, and `JsonElement` each have one stable text form; a primitive, `Guid`, or date value is serialized the same, culture-independent way every time. A collection, record, or class result is only canonicalized when the tool's generated declaration carries a consumer `JsonSerializerContext` for it — code-defined tools using the `[TraconTool]` attribute (see [Write your own tool](/guides/write-your-own-tool/)) require one for any result type beyond a plain string or primitive. That context, not reflection, is what turns it into JSON, which is also what keeps the guard AOT-safe. A raw CLR object returned directly from a hand-written `AIFunction` with no such context attached is **not** serialized by guessing: it is treated as an unsupported result and replaced with a generic, secret-free failure text rather than passed through unguarded or serialized with reflection. This closes a real gap: inspecting a type name or a placeholder instead of the actual data would let a guard approve content it never really looked at, and silently trusting an unknown object would let it bypass the guard and the output limit entirely. That same canonical text is what gets **persisted**: a tool's arguments and result are written into the run's permanent record and streamed to the console over SSE. Neither one is written into a telemetry span. A tool that would otherwise return a secret — a connection string, an access token — must redact it before returning, because both of those surfaces keep it in full. A tool exception is treated more carefully: only Tracon's own exception types keep their message; everything else is replaced with a generic `Tool failed with .` before it reaches the record or the stream, and the original message goes only to your log. ### Output size limit A tool's result is unbounded by default and goes straight into the model's context. Set a byte limit — per tool, or once for every tool that does not set its own — and a result over it is trimmed before the model ever sees it. The limit is measured against the same canonical text form described above, for every result type it applies to — a large complex object is bounded exactly like a large string, not skipped because it is not one. The one exception is `AIContent` (an attachment, such as the id `generate_image` returns): it is never inline output and is never subject to this limit. ```csharp services.AddSingleton(new TraconToolRegistration( AIFunctionFactory.Create(GetReport, "get_report", "Fetches a report."), maxOutputBytes: 4096)); services.Configure(o => o.Tools.DefaultMaxOutputBytes = 4096); ``` No change is required to keep today's behavior: the default is unlimited, and a tool that never sets a limit is never touched. Bounding a tool's output inside its own body is always better — the tool knows its data, this only counts bytes. Treat the limit as the last line of defense for the day that bound is forgotten, not a substitute for it: a single runaway tool can otherwise spend a tenant's whole token budget on one call. A result over the limit is trimmed and wrapped in an envelope, so the trimmed text can never break the JSON the model reads: ```json {"truncated": true, "omittedBytes": 1830, "content": "..."} ``` `truncated` and `omittedBytes` are always present on a trimmed result — a silently shortened answer would lead the model to a confident, wrong conclusion. A result that already fits is returned exactly as the tool produced it, never wrapped. ### Concurrent tool calls By default, when a model turn calls several independent tools at once, Tracon runs them one after another. Set `ModelBinding.AllowConcurrentToolCalls` to run them at the same time instead — each call still gets its own authorization decision, its own recorded result, and its own entry in the tool-usage metrics; none of that mixes up between calls that happen to overlap. Off by default: with no change, tool calls run one at a time exactly as they do today. Turn it on only for tools whose bodies are safe to run concurrently with themselves — a tool that shares mutable state across calls without its own synchronization should not opt in. See [Model providers](/guides/model-providers/#concurrent-tool-calls) for where this setting lives on `ModelBinding`. ### Built-in image generation When `Tracon:Images:Enabled` is true and a supported image provider is registered, Tracon adds `generate_image` as an `External` tool. It accepts a prompt and returns attachment ids, never base64 image data. Generation can spend money and sends a prompt to an external provider, so the normal authorization, timeout, run recording, and continuation rules apply. In particular, an interrupted run does not automatically repeat an image-generation call. See [Multimodal input and generated images](/guides/multimodal/) for registration, storage, and price configuration. ## 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 Tracon 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[Tracon 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; Tracon 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/) — coordinate multiple agents with checkpoints and human input. --- # 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 `Tracon.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. Each participant call also carries the same two-layer wait limit a callable agent's sub-call does (see [Agents calling agents](/concepts/agents/#agents-calling-agents)): a cooperative deadline, then a hard cutoff for a participant that ignores it. There is no per-participant override here — the installation-wide default applies. ```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 tracon.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. ::: ### Retry a node on a transient error `AddWorkflowFunction` takes an optional retry policy: ```csharp tracon.AddWorkflowFunction, List>( "call-shipping-api", services => (messages, context, cancellationToken) => CallShippingApiAsync(messages, cancellationToken), "Looks up a shipment.", retryPolicy: new WorkflowNodeRetryPolicy { MaxAttempts = 3, InitialDelay = TimeSpan.FromSeconds(1), BackoffMultiplier = 2.0, }); ``` Only a transient failure is retried — a provider error, an open circuit breaker, a rate limit, or a timeout. Anything else (a bad argument, a permanent downstream error) is thrown on the first attempt, exactly as without a policy. The retry loop runs entirely inside the node's own call: Microsoft Agent Framework invokes the node once per routed message either way, so retrying costs nothing against `TraconWorkflowOptions.MaxSuperSteps` — a node that succeeds on its third attempt still counts as exactly one super-step. 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/tracon/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. A workflow run's `error` follows the [same redaction rule as an agent run](/concepts/runs/#the-error-message-is-safe-to-display-not-safe-to-debug-from): a node failure from your own code keeps its message, a failure from a provider, library, or transport carries only its exception type and a correlation id. A workflow run ends `Canceled` only when the run was actually cancelled — the caller's request going away, or the workflow's own `MaxDuration` elapsing. A node whose model or tool call never came back ends the run `Failed` instead, even though .NET surfaces an `HttpClient` timeout as a `TaskCanceledException`. Alert on the two separately: one is a person stopping, the other is an outage. A workflow's root run shares the same call-tree budget every agent run does (`AgentGraph.MaxTotalTokens`/`MaxTotalCost`/`MaxDuration` — see [Reliable runs](/guides/reliability/#bound-multi-agent-trees)): every agent node the workflow invokes spends against the same tree, so a workflow with a long chain of agent steps is bounded the same way a single agent's own tool loop is. ## Checkpoints A workflow writes checkpoints as it goes, controlled by `Tracon: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. A checkpoint's state is the Microsoft Agent Framework's own serialized graph; Tracon does not interpret it and makes no promise that a checkpoint written by one Microsoft Agent Framework version can be resumed by a different one. See [Versions and upgrades](/reference/versioning/#persisted-session-and-checkpoint-state) for the compatibility policy. `tracon state-check` counts stored checkpoints by the Tracon envelope generation stamped on them and says which of those the build running the command understands. That is as far as it can go for a checkpoint: the state inside has no decoder outside a running workflow, so the command reports a sampled checkpoint as checked for structure only and never claims to have resumed it. ## 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/) — compare agent versions using cases, judges, and experiments. --- # Your first agent Build an ASP.NET Core host, register a model-backed agent, then inspect its run in the embedded console. MAF executes the agent; Tracon supplies the catalog, HTTP endpoints, and default-on recording around it. :::caution[Repository access required] Tracon packages and templates are not published yet. This guide requires an existing source checkout that you are authorized to access. If you do not have access, start with the [capability map](/capabilities/) and [architecture](/concepts/) to evaluate the design. ::: ## Prerequisites - An authorized checkout of the Tracon repository. - The .NET SDK selected by the repository's `global.json` and Node.js for the embedded console build. See the checkout's README for development prerequisites. - An OpenAI API key and a chat model available to that account. The model request is sent to your configured provider and can incur provider charges. ## Build from source Run these commands from the root of the Tracon checkout. They create a sibling application and reference the three source projects it needs. ```bash dotnet new web -o ../MyAgents cd ../MyAgents dotnet add reference ../Tracon/src/Tracon.AspNetCore/Tracon.AspNetCore.csproj dotnet add reference ../Tracon/src/Tracon.OpenAI/Tracon.OpenAI.csproj dotnet add reference ../Tracon/src/Tracon.UI/Tracon.UI.csproj dotnet user-secrets init ``` The commands assume the checkout directory is named `Tracon`. Use its actual relative path if you named it differently. The console assets build with the UI project; no separate console server is needed. Store your provider settings in the application's development secrets. Replace the example values with your key and a model identifier available to your account. ```bash dotnet user-secrets set "Tracon:Providers:OpenAI:ApiKey" "YOUR_API_KEY" dotnet user-secrets set "Tracon:Providers:OpenAI:DefaultModel" "YOUR_CHAT_MODEL" ``` ## Register the agent Replace the generated `Program.cs` with the following: ```csharp title="Program.cs" using Tracon; var builder = WebApplication.CreateBuilder(args); var tracon = builder.AddTracon() .UseOpenAI(builder.Configuration.GetSection(OpenAIProviderOptions.SectionName)) .UseUI(); tracon.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 = builder.Configuration["Tracon:Providers:OpenAI:DefaultModel"] ?? throw new InvalidOperationException("Configure an OpenAI chat model."), }, }); var app = builder.Build(); app.MapTracon("/tracon"); app.Run(); ``` This first agent uses a chat model only. To let a later agent generate stored image attachments, add `UseOpenAIImages(...)`, set `Tracon:Images:Enabled`, and choose an image model explicitly. An image model is not inferred from this agent's chat model; see [image generation providers](/guides/model-providers/#image-generation-providers). ```bash dotnet run --urls http://localhost:5081 ``` The host listens on `http://localhost:5081`. Open `http://localhost:5081/tracon` for the console. Provider model names are explicit configuration: Tracon does not ship a built-in model list. The source template remains available in the repository for readers who want to inspect its generated application. A published-template install command will be added when a release is available. ## Run it **In the console.** Open `/tracon`, 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/tracon/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 Tracon, provide its authentication, and use the agent name as the `model`: ```bash curl -X POST http://localhost:5081/tracon/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/tracon/api/runs curl http://localhost:5081/tracon/api/runs/{runId} curl -N http://localhost:5081/tracon/api/runs/{runId}/events ``` No additional recording registration is needed for this catalog-resolved agent. 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/) — register a C# method the model can call. - [Persistence](/getting-started/persistence/) — retain definitions and run records across restarts. - [Securing the endpoints](/getting-started/security/) — configure authorization before exposing the host. --- # What Tracon is Tracon is a **.NET package family** that adds a **control plane** on Microsoft Agent Framework (MAF). MAF executes the agent and its model/tool loop. Tracon adds agent definitions, configurable execution controls, HTTP endpoints, default-on run recording, and an optional embedded console. The packages run inside your host process. You choose the model providers, persistence, identity integration, and operational policies. Recording is best-effort: a recording-store failure is logged while agent execution continues. :::note[Evaluate through the documentation] Packages and templates are not published yet. You can explore the capabilities, API contracts, and limitations here. Running the source requires authorized repository access; the [source build guide](/getting-started/first-agent/) makes that prerequisite explicit. ::: ## 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** | 168 generated operations, plus OpenAI-compatible Responses and Chat Completions surfaces | | **Console** | 30 screens embedded when you add `Tracon.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.** Tracon does not provide a managed hosting account. Your host controls outbound connections: configured model providers, exporters, webhooks, and integrations can send data outside the process. You can use a cloud model API or a compatible self-hosted engine such as Ollama or vLLM — see [picking a model provider](/packages/#picking-a-model-provider). ## Four rules it will not break These conventions explain the default setup and the extension model. 1. **Explicit infrastructure.** `AddTracon()` supplies in-memory store defaults. Configure a model provider or a custom agent source for execution, and choose persistence when data must survive a restart. 2. **Tools are code only.** The console selects from registered tools; it never defines them. 3. **MAF objects are passed through, not wrapped.** 4. **Replaceable services.** Default service registrations use `TryAdd` so a consumer registration can supply the implementation. Follow each extension guide for its lifetime and registration contract. ## 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 - [Capability map](/capabilities/) — match a system requirement to a feature and its limits. - [Architecture](/concepts/) — understand MAF and Tracon responsibilities. - [Your first agent](/getting-started/first-agent/) — build a working host with repository access. --- # 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. :::caution[In production, Tracon says so out loud] Start a host in the `Production` environment while storage is still in memory and Tracon writes one warning at startup, naming the stores that do not survive a restart. In-memory storage stays a supported mode — the warning never fails startup and there is no switch to silence it, because a production installation losing its runs on the next deployment should not be a quiet fact. The same judgement is what `GET /api/meta` reports as `storage.persistent`, and what the console's settings screen shows. ::: ## Pick one ```csharp builder.AddTracon() .UsePostgreSql(connectionString); // Tracon.PostgreSql ``` | Package | Choose it when | |---|---| | `Tracon.PostgreSql` | The default. The only one with vector search for knowledge | | `Tracon.SqlServer` | You already run SQL Server | | `Tracon.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(TraconPostgreSqlOptions.SectionName)) ``` ```json title="appsettings.json" { "Tracon": { "PostgreSql": { "ConnectionString": "", "SchemaName": "tracon", "AutoApplyMigrations": true, "CommandTimeoutSeconds": 30, "EnableKnowledge": false, "EnableReadViews": false } } } ``` ## How each provider isolates its tables | Provider | Namespace | Migration coordination | |---|---|---| | PostgreSQL | Separate `tracon` schema by default | `pg_advisory_lock`, scoped to the schema | | SQL Server | Separate `tracon` schema by default; your `dbo` objects stay untouched | `sp_getapplock`, scoped to the schema | | SQLite | No schema support; `tracon_` 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/). ::: :::note[Read views are opt-in on all three providers] `EnableReadViews` publishes `runs_v1`, a versioned, read-only SQL view over run data — off by default, so a deployment that never turns it on never sees the object. Query it with your own SQL or map it as an EF Core keyless entity. See [Read contract views](/reference/read-views/). ::: ## Migrations run at startup The SQL files ship embedded in the assembly and are applied when the application starts, unless that responsibility is moved to its own deployment step: ```mermaid flowchart TD accTitle: Two ways a migration is applied accDescr: With AutoApplyMigrations true, the application applies pending migrations itself at startup. With it false, a separate tracon migrate step applies the schema first, and the application only verifies it before starting. START["Application starts"] --> CHECK{"AutoApplyMigrations"} CHECK -->|"true (default)"| APPLY["Applies pending migrations itself
provider lock serializes concurrent instances"] CHECK -->|"false"| SEPARATE["tracon migrate
runs as its own deployment step, no app needed"] SEPARATE --> APP2["Application starts, verifies the schema, does not write"] APPLY --> READY["Ready"] APP2 --> READY ``` Two properties make in-process application 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. ::: :::caution[A migration can be one-way] Most migrations only add. Some rewrite or drop a column, and while Tracon is in preview a release may contain one: the data is carried across by the migration itself, but there is no downgrade path back to the older schema. Take a backup before upgrading a database you cannot lose, and roll a version back by restoring that backup rather than by pointing an older build at the newer schema — the checksum check will refuse it anyway. The `run_scores` migration is the current example. It widens the score value from an integer to a nullable double, adds the score's name and its categorical value, and rewrites the uniqueness index. Existing rows are carried across: a score written by a judge keeps that judge's name, every other row is named `overall`. On SQLite the value column is rebuilt in place, so the table is rewritten — size that step against your own row count before upgrading a large database. ::: Set `AutoApplyMigrations = false` when schema changes are their own deployment step. Tracon 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.MapTracon("/tracon", options => { options.EnableDiagnosticsEndpoint = true; }); ``` After that opt-in, `GET /tracon/api/diagnostics` is an Admin surface and still passes through the configured access layers. Something still needs to **apply** the schema before the application starts with `AutoApplyMigrations = false`. The `tracon` CLI does that as its own step, against the database directly — no running application required: ```bash tracon migrate --provider postgres --connection "$TRACON_CONNECTION" ``` Running it again applies nothing (`0 applied`) and exits `0`; `tracon migrate status` lists pending migration names without writing. See the [typed client and CLI guide](/guides/cli/) for setup and the rest of the commands. ## Giving your own data source instead of a connection string Each provider's `Options.DataSource` field accepts a `DbDataSource` you built yourself instead of `ConnectionString` — most useful when your host already owns one, for example an EF Core `DbContext` configured with an `NpgsqlDataSource`: ```csharp .UsePostgreSql(options => options.DataSource = yourDataSource) ``` Tracon never disposes an instance it did not build; ownership stays with whoever created it. Building two separate data sources from the identical connection string does **not** share a connection pool — see [Two data planes, one connection pool or two](/guides/embedding/#two-data-planes-one-connection-pool-or-two) and, for the full EF Core pattern, [Two connection planes: EF Core and Tracon](/guides/ef-core/). ## 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, quotas, and the pending-approval mailbox all become usable, since they depend on state outliving a request. A resolved [approval presentation](/concepts/governance/#approvals) is persisted next to the raw call arguments, so it survives a restart the same way the arguments do. It is also what makes recovering from a crash possible at all: a run's recorded tool calls are what an [automatically continued run](/guides/reliability/#continue-an-interrupted-run-automatically) replays instead of repeating, and orphan reconciliation itself only has a stale `Running` row to find because that row, and every tool call it already made, outlived the process that opened it. Session and workflow checkpoint rows carry a version stamp of their own, separate from the schema migrations above: Tracon's envelope around the row, and the Microsoft Agent Framework version that wrote the opaque state inside it. See [Versions and upgrades](/reference/versioning/#persisted-session-and-checkpoint-state) for what that stamp promises and what happens when an old row can no longer be read. Durable state is also state an upgrade has to keep being able to read, and the stamp is what makes that answerable ahead of time rather than in production: ```bash tracon state-check --provider postgres --connection "$TRACON_CONNECTION" ``` Run with the **new** version of the tool, it counts your stored rows by stamp, says which of them the new build understands, and decodes a sample of each. It writes nothing, so it is safe against the live database. See [the supported upgrade window](/reference/versioning/#the-supported-upgrade-window). ## 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 `Tracon: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/tracon/api/retention/preview' curl -X POST 'http://localhost:5081/tracon/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). Both endpoints accept an optional `tenantId` filter; leaving it out never means "every tenant" — it resolves to the caller's own ambient tenant. A custom `IAuditLog` implementation must apply this same fallback (see [Write your own store](/guides/write-your-own-store/)). Durable rows are also what [content protection](/getting-started/security/#at-rest-content-protection) encrypts. A durable `sessions` row is also what [session ownership](/concepts/sessions/#session-ownership) writes its owner into. The column is added by a migration, is nullable, and stays NULL until you turn ownership on — so enabling persistence costs nothing here, and enabling ownership later is a configuration change rather than a data change. The listing filter is a `WHERE` clause on that column, applied before paging, which is precisely what an in-memory setup cannot offer. ## Read next - [Securing the endpoints](/getting-started/security/) — required reading before this leaves your machine. - [Write your own store](/guides/write-your-own-store/) — implement `IRunStore` (or another store interface) against a persistence engine none of the three built-in providers cover. --- # Securing the endpoints Configure who can reach Tracon, what they can do, and which tenant they can access. Protected endpoints restrict non-loopback callers by default. This restriction does not replace host authorization or correct proxy configuration; the metadata endpoint is an explicit exception. ## 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 protected routes, non-loopback requests receive `403` by default. Review the host and proxy boundary before allowing remote access: ```csharp app.MapTracon("/tracon", 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["Tracon: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 Tracon a policy name and it runs inside your own authentication pipeline: ```csharp options.RequireAuthorization("TraconAdmin"); ``` ## 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 | Tracon 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 :::caution[Package availability] Tracon packages and templates are not published yet. The `dotnet new tracon-api` line below describes the release form and does not currently resolve from public registries. With authorized repository access, use the [source build instructions](/getting-started/first-agent/). ::: 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 Tracon ships all three. Tracon 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. `TraconWriteLocalReference` 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 tracon-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["Tracon.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**, about 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 Tracon: read Tracon.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. `TRC0402` 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. ### `Tracon.LocalReference.md` — the exact paths Written **beside each project** that references Tracon, 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 Tracon package, at the version this project restored; - the packaged HTTP API document, when the project references `Tracon.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 '/.*Tracon\.Core\.xml' Tracon.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 20 KB. - [`llms-full.txt`](/llms-full.txt) — every guide, concept, and reference page concatenated, about 700 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 ``` `TRC0401` tells you when this is due, so you do not have to remember. ## The diagnostics Seven diagnostics in the `Tracon.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 | |---|---|---| | `TRC0101` | `MapTracon()` is called but `AddTracon()` is not | The mapped endpoints have no catalog to serve; the app fails at startup | | `TRC0102` | A model binding names a built-in provider the compilation never registers | Call the matching `Use…()`, or register a custom `IModelProvider` | | `TRC0201` | A literal secret is written into a definition | Store the **name of the configuration key**; definitions reach backups, the audit trail, and the console | | `TRC0301` | 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 | | `TRC0302` | An agent is wrapped without any `IAgentDecorator` in the compilation | A hand-applied wrapper misses database-defined agents; a decorator does not | | `TRC0401` | `AGENTS.md` was generated from an older capability map | Delete it and build again | | `TRC0402` | 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, `TRC0001`–`TRC0008`, 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 `Tracon.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.TRC0301.severity = none ``` :::caution With `TreatWarningsAsErrors` enabled, these warnings break the build — which is the intended outcome for `TRC0101` and `TRC0201`, 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. Tracon 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. These five layers all shape what already made it into the conversation. A tool's [output size limit](/concepts/tools/#output-size-limit) works earlier, at the source: it bounds a single tool result before that result ever becomes context to compact. The two are complementary, not competing — a tool limit caps one call's contribution, compaction manages the accumulated history afterward. ## 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/tracon/api/agents/research/run \ -H "Authorization: Bearer $TRACON_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"sessionId":"case-4182","message":"Summarize the customer request."}' curl -N -X POST http://localhost:5081/tracon/api/agents/research/run \ -H "Authorization: Bearer $TRACON_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 tracon.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`, Tracon 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. Tracon 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. ## Know the agent modes A harness agent always runs in a mode. The mode provider is on unless `Harness.DisableAgentModeProvider` is true. A plain chat agent has no modes. Two modes come from MAF, and a new session starts in the first one: | Mode | Behavior | |---|---| | `plan` | Interactive. The agent asks clarifying questions, discusses options, and waits for your approval before it proceeds. **This is the starting mode.** | | `execute` | Autonomous. The agent carries the work out without stopping for approval. | The provider adds two tools, `mode_set` and `mode_get`, so the model can read and change its own mode. It also injects the current mode's instructions on every invocation. Those instructions apply to every substantive request, including short factual questions — a harness agent in `plan` mode can answer a simple question with questions of its own. You cannot define your own modes today. Tracon passes no mode options to MAF, so you get these two. Set `Harness.DisableAgentModeProvider = true` to turn the provider off, together with both of its tools. ## Add predictable MCP resources Code-defined agents can name resources in `{server}:{uri}` form: ```csharp tracon.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. Tracon reads these resources at the start of each run. The defaults are 64 KiB per resource and 256 KiB in total. Configure them with `TraconMcpOptions.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/tracon/api/agents/validate \ -H "Authorization: Bearer $TRACON_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 `TraconOptions.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. **A harness agent asks questions instead of answering.** A new session starts in `plan` mode, which is interactive by design. Tell the agent to switch, or set `Harness.DisableAgentModeProvider = 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/tracon.harnesssettings/) - [`CompactionSettings` API](/api/tracon.compactionsettings/) - [`MemorySettings` API](/api/tracon.memorysettings/) - [`TraconMcpOptions` API](/api/tracon.traconmcpoptions/) ## Read next - [Sessions and conversations](/concepts/sessions/) — retain conversation state and understand session lifetime. - [Agents and definitions](/concepts/agents/) — configure and version the definitions that the catalog resolves. - [Tools, skills, and MCP](/concepts/tools/) — choose code tools, instruction skills, or remote MCP integration. --- # Two connection planes — EF Core and Tracon Tracon does not use Entity Framework Core internally, and does not ship an EF Core package. Its store layer talks to the database through raw ADO.NET (`DbDataSource`, `DbCommand`) on purpose — that keeps `Tracon.PostgreSql` AOT compatible and keeps the store contract at the same abstraction level EF Core itself sits at, so there is nothing an EF integration would add. If your application already uses EF Core for its own schema, this page is about the connection plane the two share, not about making Tracon use EF Core. ## One data source, two consumers Give both sides the exact same `NpgsqlDataSource` instance and they share one real connection pool — building two data sources from an identical connection string does **not** do this; see [Two data planes, one connection pool or two](/guides/embedding/#two-data-planes-one-connection-pool-or-two) for the measurement: ```csharp var dataSource = new NpgsqlDataSourceBuilder(connectionString) // Token refresh, client certificates, custom type mappings: anything not // expressible in a connection string now applies to Tracon's traffic too. .Build(); builder.Services.AddDbContext(o => o.UseNpgsql(dataSource)); builder.AddTracon() .UsePostgreSql(o => o.DataSource = dataSource); ``` `TraconPostgreSqlOptions.ConnectionString` is not required when `DataSource` is set — giving both at once is a startup error, not a silent preference. Tracon never disposes an instance it did not build: ownership stays with whoever created it (your host, in this example), and it keeps working after Tracon's own `ServiceProvider` shuts down. The same seam exists on `TraconSqlServerOptions.DataSource` and `TraconSqliteOptions.DataSource`, for symmetry. Neither `Microsoft.Data.SqlClient` nor `Microsoft.Data.Sqlite` ships a `DbDataSource` implementation of its own today (measured against `Microsoft.Data.SqlClient 7.0.2`), so there is no equivalent "give EF Core's data source to Tracon" call for those two providers yet — the field is ready for the day the ecosystem catches up, or for a `DbDataSource` adapter you write yourself. ## There is no shared transaction `YourDbContext.SaveChangesAsync()` and a Tracon run write through two independent connections, even when they share one pool. A run's recording never becomes atomic with your own schema's writes, and this is deliberate: Tracon's stores are singletons that outlive any one request, and "observability must never block the run" is a standing rule — a run's own stores never wait on your transaction to commit, and yours never waits on Tracon's. ## Reference a run by `RunId`, not by copying its data Point your own entity at the run instead of duplicating what Tracon already recorded: ```csharp public sealed class SupportTicket { public Guid Id { get; set; } public required string RunId { get; set; } // reference, not a copy // ... your own domain fields } ``` Read the run's own data back through `IRunStore`/`GET /api/runs/{id}` when you need it; do not copy fields across at write time — the two writes are not atomic (above), so a copy can drift the moment either side fails after the other commits. If your own write can retry, protect it with `Idempotency-Key` (see [Make supported HTTP submissions idempotent](/guides/reliability/#make-supported-http-submissions-idempotent)) the same way any other retryable write in front of Tracon would be. ## Two migration steps, one order that matters less than you think Your own `dotnet ef database update` and Tracon's `tracon migrate` apply to two schemas with no foreign key between them (`tracon` versus yours), so the order they run in does not matter. What does matter is running both with automatic migration off, so exactly one mechanism touches the database at deploy time: ```bash dotnet ef database update tracon migrate --provider postgres --connection "$TRACON_CONNECTION" ``` See [Choose a migration strategy](/guides/production/#choose-a-migration-strategy) for `AutoApplyMigrations` and the rest of the deployment-pipeline shape. ## Reading through a keyless entity instead The two connection planes above are about your own writes. When you only need to **read** run data next to your own entity — a cost column in your own report, a status filter in your own dashboard — a third, narrower option exists: `runs_v1`, a versioned, read-only SQL view opt in with `EnableReadViews`. Map it as a keyless entity and query it with LINQ, without giving Tracon's internal `runs` table shape a dependency on your code: ```csharp builder.AddTracon() .UsePostgreSql(o => { o.ConnectionString = connectionString; o.EnableReadViews = true; }); ``` ```csharp modelBuilder.Entity() .HasNoKey() .ToView("runs_v1", "tracon"); ``` See [Read contract views](/reference/read-views/) for the full column list, the `total_cost` null-preserving rule, and why the view is not a tenant boundary. ## What you do not get - **No EF Core global query filter** reaches Tracon's rows — its own tenant isolation runs through its own `tenant_id` column and `ITenantContext`, a separate mechanism from EF Core's. - **`dotnet ef migrations` never sees Tracon's schema.** It is not an EF model; there is no `DbSet` to scaffold it from, and none should be added. - **No `DbContext` owns a Tracon table.** The `tracon` schema is written only by Tracon's own store layer, from either connection plane. ## Read next - [Read contract views](/reference/read-views/) — the full `runs_v1` column list and compatibility rule - [Embedding into a host application](/guides/embedding/) — the two data planes and connection pool sharing measurement - [Production deployment](/guides/production/) — the migration strategy this page's CI snippet belongs to --- # Embedding into a host application `AddTracon()` plus `MapTracon()` is a complete, working setup on its own — that two-line promise is what [Your first agent](/getting-started/first-agent/) shows. Embedding Tracon into an application that already has its own tenants, users, permissions, event bus, or object storage is a different job: it means replacing six built-in defaults with bindings into systems you already run. All six are wired the same way, are all optional, and can be added one at a time. ## The six points | Contract | What you give it | Built-in default when unbound | |---|---|---| | `ITenantContext`, `ITenantStore` | The current tenant, resolved from your own identity layer | A single fixed tenant | | `IRunAttributionContext` | The current user and job labels a run belongs to | `UserId` and `Labels` are always `null` | | `IToolAuthorizationHandler` | A decision for every tool call: allowed or denied | Every call is allowed | | `IRunAuthorizationHandler` | A decision for every run start, every access to a run's resources, and every session access: allowed or denied | Every run starts, every run is readable, and every session is reachable | | `IRunEventSink` | A bridge that receives every run event as it is written | No bridge; events reach only `IRunStore` | | `IAttachmentStorage` | A place to write attachment bytes outside the database | Content is stored as `bytea` in the database | Each interface is registered with `TryAdd`, so a registration made **before** `AddTracon()` wins over the built-in default. A registration made after it is where module order starts to matter: a `TryAdd` registration is dropped, because Tracon's default already holds the slot, while a plain `Add` still wins the resolve and leaves Tracon's unused registration behind it. Register first and neither case can bite you. `GET /api/diagnostics` (once you turn it on) reports which of the six are still built-in and which your application replaced — see [Extension points](#extension-points-in-diagnostics) below; to turn a missed binding into a failed startup instead of a report nobody reads, see [Make a binding required](#make-a-binding-required). ```csharp var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); var tracon = builder.AddTracon() .UseOpenAI(builder.Configuration.GetSection(OpenAIProviderOptions.SectionName)) .UsePostgreSql(builder.Configuration.GetSection(TraconPostgreSqlOptions.SectionName)); ``` The order above — bindings first, `AddTracon()` second — is the only order that works. `AddTracon()` calls `TryAdd*` for all six; called first, it claims every slot and your registrations that follow do nothing. ### 1 — Tenant resolution ```csharp public sealed class YourTenantContext(IYourIdentityService identity) : ITenantContext { public string TenantId => identity.CurrentTenantId; } ``` `YourTenantContext` replaces the built-in resolver entirely — the claim/header resolution chain [Governance](/concepts/governance/#multi-tenancy) describes only applies when you leave the default in place and turn it on with `UseTenancy()`. Bind your own `ITenantContext` instead when the tenant already lives in a service, a claim shape, or a header your identity layer owns. Bind `ITenantStore` alongside it only if you also want Tracon's tenant admin endpoints (`/api/tenants`) to read and write your own tenant records instead of its in-memory default. For work that runs outside an HTTP request — a queued job, a scheduled task — no `HttpContext` exists for `ITenantContext` to read. Open an ambient scope for the duration of that work instead. It composes with [`AmbientRunAttributionScope`](/concepts/runs/#who-ran-it-and-for-what) — a background job binds both together, since neither has a request to read from: ```csharp using (AmbientTenantScope.Begin(job.TenantId)) using (AmbientRunAttributionScope.Begin(job.RequestedByUserId, labels: null)) { await agent.RunAsync(job.Message); } ``` **Open the scope inside the method that starts the run**, in that method's own body — not in a helper it calls — and reopen it before every `MoveNextAsync` on a streaming path. `AmbientTenantScope` is an `AsyncLocal`; a value set upstream of an `await` boundary does not flow back down through one opened later. A dropped scope reads as "empty tenant", not as an exception, so it fails silently. ### 2 — Run attribution Attributes a run to a user and, optionally, job labels — see [Runs: who ran it and for what](/concepts/runs/#who-ran-it-and-for-what) for the full contract, including why the value is never read from the run request body. ### 3 — Tool authorization Decides whether a caller may invoke a specific tool at all, separately from approval — see [Tools](/concepts/tools/#authorization-validation-and-timeout) for the binding pattern and how authorization and approval order relative to each other. If your widget renders its own approval card instead of the built-in console's, register [`IToolApprovalPresenter`](/concepts/governance/#approvals) too — it turns a raw `{ "orderId": "ORD-1001" }` into a name your widget can show directly, and it reaches your widget the same way the built-in one reads it: an `approvals` frame on the streaming run endpoint, keyed by the pending request's `requestId`. ### 4 — Run event bridge Bridges every run event to your own queue or bus, in addition to the run store — see [Runs: observing events beyond the store](/concepts/runs/#observing-events-beyond-the-store) and [Troubleshooting a slow sink](/guides/observability/#troubleshooting) for the full contract. **The one rule that matters most:** `OnEventAsync` must queue the event and return. Tracon awaits it directly on the run's own hot path, before the response keeps streaming to its caller — a sink that does its own network I/O inline ties the model's response speed to that network call's latency. Tracon holds **no queue of its own** in front of your sink, so the buffer is yours to own: write to a bounded channel and return. Size that channel to drop the event and log it when it is full rather than block, so a slow consumer of yours never slows the run down to match its queue depth. ### 5 — Attachment storage ```csharp public sealed class YourAttachmentStorage(IYourBlobClient blobs) : IAttachmentStorage { public async ValueTask WriteAsync( string tenantId, Guid id, Stream content, string mediaType, CancellationToken cancellationToken = default) => await blobs.PutAsync($"{tenantId}/{id}", content, mediaType, cancellationToken); public ValueTask ReadAsync(Uri uri, CancellationToken cancellationToken = default) => blobs.OpenReadAsync(uri, cancellationToken); public ValueTask DeleteAsync(Uri uri, CancellationToken cancellationToken = default) => blobs.DeleteAsync(uri, cancellationToken); } ``` `IAttachmentStore` keeps the metadata row either way; `IAttachmentStorage` only decides where the bytes live. Tracon takes no dependency on any cloud SDK — you write this class against whichever client your object store already uses. ### 6 — Run and session authorization Tracon draws ownership at the **tenant** level by default; without this binding, every caller with the `Operator` role in a tenant can start a run as, read, cancel, score, and delete every other user's runs, attachments, approvals, and sessions in the same tenant. :::note[Sessions have a built-in answer too] Turning on [session ownership](/concepts/sessions/#session-ownership) makes Tracon record which user opened a session and narrow the session list to that user, with no handler at all. It covers **sessions only**; runs, attachments, approvals, and scores still need the handler below. The two compose — with ownership on, a handler no longer has to reject a whole session listing just to keep users apart. ::: #### Ownership and attribution are not the same promise Both read the user from `IRunAttributionContext`, and it is worth being exact about how differently they treat a failure there: | | Attribution | Session ownership | |---|---|---| | What the value does | Names the user on a cost report | Decides who may reach a session | | If your implementation throws | The run continues; the column stays `NULL` | The session is not opened: `403` | | If it returns an over-long value | Dropped whole; the run continues | Treated as no identity: `403` | | When it is read | Every run | Only when a session is **opened** | Same service, two contract strengths. The rule "observability never breaks functionality" holds for attribution and deliberately does **not** hold once a deployment has asked for that value to be an authorization input. If you bind `IRunAttributionContext` and later turn ownership on, re-read your implementation with that in mind: a path that used to degrade quietly now refuses. `AuthorizeRunAsync` answers two different questions, told apart by `request.Access`. `RunAccess.Start` asks whether a run may **begin**; every other value asks whether an existing run's **resource** may be reached, and carries `request.RunId` so you can look that run up in your own records: | `RunAccess` | What the caller is asking to do | |---|---| | `Start` | Start a run — including a replay, which carries the source run's `RunId` | | `Read` | Read the run: summary, tree, event stream, recorded input, span tree, tool calls, scores | | `Cancel` | Request cancellation of the run | | `Feedback` | Write or delete a score for the run | | `Attachment` | Upload, download, list, or delete an attachment | | `Approval` | List, read, or decide an approval request | ```csharp public sealed class YourRunAuthorizationHandler(IYourOwnershipService ownership) : IRunAuthorizationHandler { public async ValueTask AuthorizeRunAsync( RunAuthorizationRequest request, CancellationToken cancellationToken = default) { // Starting a run: there is no run yet, so the question is about the agent. if (request.Access == RunAccess.Start && request.RunId is null) { return await ownership.CanStartAsync(request.TenantId, request.UserId, request.AgentName!, cancellationToken) ? RunAuthorizationResult.Allow() : RunAuthorizationResult.Deny("This user cannot run this agent."); } // Everything else is about an existing run. RunId is null only for a // list, and for an attachment uploaded before any run existed. if (request.RunId is not { } runId) { return RunAuthorizationResult.Allow(); } return await ownership.OwnsRunAsync(request.TenantId, request.UserId, runId, cancellationToken) ? RunAuthorizationResult.Allow() : RunAuthorizationResult.Deny("This run belongs to a different user."); } public async ValueTask AuthorizeSessionAsync( SessionAuthorizationRequest request, CancellationToken cancellationToken = default) { // request.SessionId is null only for SessionAccess.List, which has no // single session identity to check ownership of. if (request.Access == SessionAccess.List) { return RunAuthorizationResult.Allow(); } return await ownership.OwnsAsync(request.TenantId, request.UserId, request.SessionId!, cancellationToken) ? RunAuthorizationResult.Allow() : RunAuthorizationResult.Deny("This session belongs to a different user."); } } ``` Both methods are called explicitly at every endpoint concerned — there is no single filter they all share, because these endpoints have no route shape in common, so each one calls this binding in its own body: - **Six run-starting endpoints:** the agent run endpoint, the workflow run endpoint, the inbound trigger accept endpoint, the OpenAI-compatible `/v1/responses` and `/v1/chat/completions` endpoints, and `POST /api/runs/{id}/replay`. The check runs **before** the quota check, so a denied call never consumes the tenant's quota. - **Every run resource:** the run summary, its tree, its event stream, its recorded input, its span tree, its tool calls, its scores, its cancellation, its attachments, and its approval requests. - **Every session access:** list, read, delete, branch, and opening a real-time voice conversation (`SessionAccess.Voice`) — including the OpenAI-compatible routes that reach the same sessions under another name, `GET`/`DELETE /v1/conversations/{id}` and `GET /v1/conversations/{id}/items`. `POST /v1/conversations` is not among them: it reserves an identifier and writes nothing, so there is no session yet to authorize. **How a denial answers depends on what was asked for.** A denied single resource — a run, a session, an attachment, an approval request — returns `404`, with a body **identical** to the one that resource gets when it genuinely does not exist. A `403` there would confirm the resource exists to a caller who should not even know it, and wording that differed between "denied" and "missing" would leak the same thing through a side channel. A denied **list** returns `403` instead: a list is an operation, not a single resource, so there is no existence to leak, and the response is never silently filtered — filtering there would break the `skip`/`take` paging contract. A denied **run start** and a denied **attachment upload** also return `403`: neither addresses an existing resource. A denied voice handshake is refused before the socket upgrades, with the same `404` an unreachable session already gives. A voice session that does not exist yet is **not** an error: the first turn opens it, and the handler is still asked, so you decide for yourself whether a caller may open a conversation under that id. If this handler throws, the call is denied (fail-closed); a gate that fails open on an exception is not a gate. When it denies a single resource, the `Reason` you supply is deliberately **not** returned — the response has to stay identical to a missing resource's. ## Reading identity inside a tool body A tool cannot reach `AgentSession`, so it cannot read `ITenantContext` or `IRunAttributionContext` through the normal request pipeline. It reads the same values from `TraconRunContext.Current` instead — a static, `AsyncLocal`-backed snapshot the run pipeline populates before every tool call: ```csharp [TraconTool("current_account", "Returns the tenant, run, session, and caller identity of the current run.")] public static string CurrentAccount() { var scope = TraconRunContext.Current; return scope is null ? "no run in progress" : $"tenant={scope.TenantId} run={scope.RunId} session={scope.SessionId} user={scope.UserId}"; } ``` This is the only place a tool can read the run's identity — there is no parameter Tracon injects for it. `scope.UserId` is the same value `IRunAttributionContext` resolved for the run record, not a new concept — just a second place to read it from. `AgentRunScope` also carries `RootRunId` (the top of an agent-calls-agent tree) and `Budget` (the shared token/depth/count ceiling for that tree). ## Two data planes, one connection pool or two Your application's own schema and Tracon's tables can live in the same PostgreSQL database. Tracon writes only inside its own schema (`SchemaName`, default `tracon`; see [Choose a migration strategy](/guides/production/#choose-a-migration-strategy)) and never reads or writes yours. **Giving both sides the same connection string does not share a pool.** Npgsql pools a `NpgsqlDataSource` **instance**, not a connection string — two separate `NpgsqlDataSource` objects built from an identical string open two separate pools (measured: with 5 concurrent commands held open on each of two data sources built from the same string, the server showed 10 simultaneous backends, not 5). If your application uses Entity Framework Core (or any other Npgsql consumer) and you want Tracon sharing its actual pool, build **one** `NpgsqlDataSource` and give the same instance to both sides: ```csharp var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build(); builder.Services.AddDbContext(o => o.UseNpgsql(dataSource)); builder.AddTracon() .UsePostgreSql(o => o.DataSource = dataSource); ``` See [Two connection planes: EF Core and Tracon](/guides/ef-core/) for the full pattern, including startup/shutdown ownership. Without a shared `DataSource`, pointing `Tracon:PostgreSql:ConnectionString` at the same string your application uses is still fine — the two sides simply keep independent pools against the same database, exactly as if they pointed at two different databases. Give Tracon a **separate** connection string (or data source) — same server, different database, or a fully different server — when you want its connection ceiling, credentials, or failure blast radius kept independent of your application's own database traffic. Nothing in Tracon requires this; it is purely an operational choice, and it can be changed later since only the connection string moves. `AutoApplyMigrations` (default `true`) applies to Tracon's own schema only. In an embedded setup where your application already owns a controlled migration step for its own schema, set it to `false` and call the registered `MigrationRunner.ApplyAsync()` from that same step — Tracon's schema then migrates alongside yours instead of at every instance's startup. See [Choose a migration strategy](/guides/production/#choose-a-migration-strategy) for the fleet-deployment version of this same setting. ## Make a binding required Reporting a missed binding is not the same as refusing to run without it. An application that means to enforce its own rule can declare the binding required, and the host then does not start while Tracon's built-in default is what resolves: ```csharp builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.AddTracon() .RequireCustomBinding() .RequireCustomBinding(); ``` The check runs while the host starts, and the message names the contract, the type that resolved instead, and how to fix it: ```text IRunAuthorizationHandler was declared as a required custom binding, but Tracon's built-in default AllowAllRunAuthorizationHandler is what resolved. Register your own IRunAuthorizationHandler on IServiceCollection BEFORE the AddTracon() call. ``` Four properties are worth knowing before you rely on it: - **It is off by default.** An application that never calls `RequireCustomBinding` behaves exactly as it did before, and the call resolves nothing extra at startup. - **It is not an HTTP concern.** The check runs at host start, so an embedded host that never calls `MapTracon()` gets the same guarantee. - **`IRunEventSink` and `IAttachmentStorage` are judged by absence.** Tracon registers nothing for those two, so "still on the default" means no registration at all rather than a particular type. - **It is a composition gate, not a security proof.** It tells you your implementation is the one bound. It cannot tell you that your implementation decides correctly — that is what your own tests are for. Any type that is not one of the seven contracts also stops the host, with a message listing the seven that are accepted. ## Extension points in diagnostics `GET /api/diagnostics` (off by default; turn it on with `TraconEndpointOptions.EnableDiagnosticsEndpoint`) reports an `extensionPoints` array: one entry per contract above, naming the bound implementation's type and whether it is still Tracon's built-in default. ```mermaid flowchart TD accTitle: Binding and verification order accDescr: Register the six implementations, then call AddTracon so TryAdd claims whatever is still unbound, then read the diagnostics endpoint to confirm each binding actually took. A["Register ITenantContext, IRunAttributionContext,
IToolAuthorizationHandler, IRunAuthorizationHandler,
IRunEventSink, IAttachmentStorage"] --> B["AddTracon call
TryAdd claims any still-open slot"] B --> C["GET /api/diagnostics
reads extensionPoints"] C --> D{"isBuiltInDefault?"} D -->|false| E["binding is active"] D -->|true| F["registered too late, or against
the wrong interface"] ``` ```json { "extensionPoints": [ { "contract": "ITenantContext", "implementation": "YourTenantContext", "isBuiltInDefault": false }, { "contract": "IRunAttributionContext", "implementation": "DefaultRunAttributionContext", "isBuiltInDefault": true } ] } ``` A fresh installation shows all seven (the six above, plus `IToolApprovalPresenter` — see [Approvals](/concepts/governance/#approvals)) as built-in. Read this endpoint right after adding a binding to confirm it actually took — `isBuiltInDefault: true` on a contract you meant to replace means the registration ran too late, or against the wrong interface. ## Verification checklist - [ ] Every binding you need is registered **before** `AddTracon()` - [ ] Bindings your deployment must not run without are declared with `RequireCustomBinding()` - [ ] `GET /api/diagnostics` shows `isBuiltInDefault: false` for each contract you bound - [ ] A background job opens `AmbientTenantScope.Begin(tenantId)` in the method that starts the run, and the scope covers every `await` on that path - [ ] `IRunEventSink.OnEventAsync` never performs blocking I/O inline — it queues and returns - [ ] `Tracon:PostgreSql:SchemaName` (or the equivalent SQL Server/SQLite setting) does not collide with a schema your own application already owns - [ ] `AutoApplyMigrations` matches your deployment's migration strategy, not just the default ## Read next - [Runs](/concepts/runs/) — attribution and the event sink in full - [Governance](/concepts/governance/) — the built-in tenant resolution chain `ITenantContext` replaces - [Production](/guides/production/) — migration strategy and process topology --- # Connect and expose agents Tracon supports three different external-agent directions. Keep them separate: | Direction | Purpose | Registration | HTTP surface | |---|---|---|---| | MCP client | Bring remote tools, prompts, and resources into Tracon | `UseMcp()` | Managed through `/api/mcp-servers/*` | | MCP server | Publish a Tracon agent as an MCP tool | `UseMcpServer()` | `/tracon/mcp` by default | | A2A server | Publish an agent through the agent-to-agent protocol | `UseA2A()` | `/tracon/a2a/{agent}` by default | ```mermaid flowchart LR accTitle: The three external-agent directions accDescr: As an MCP client Tracon calls remote servers to gain tools. As an MCP server and as an A2A server Tracon 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 `Tracon.Mcp` package and register discovery: ```csharp var tracon = builder.AddTracon() .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": "Tracon:Mcp:ExampleToken" } ``` ```bash dotnet user-secrets set "Tracon: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. Tracon 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 tracon = builder.AddTracon() .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.MapTracon("/tracon", options => { options.AllowRemoteAccess = true; options.RequireRolePolicies = true; }); app.MapTraconMcpServer(); ``` 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 `MapTracon`. 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. ### Long-running calls as MCP tasks By default, a `tools/call` for a published agent holds the connection open until the agent finishes. Set `EnableTasks` to serve long-running calls as MCP tasks instead: a task-aware client gets an immediate task id back, closes the connection, and polls `tasks/get` for the result — useful when an agent's response can take longer than a client is willing to keep a connection open. ```csharp .UseMcpServer(options => { options.ExposedAgents.Add("support"); options.EnableTasks = true; options.TaskTimeToLive = TimeSpan.FromHours(2); options.TaskPollInterval = TimeSpan.FromSeconds(5); }); ``` `EnableTasks` is `false` by default: a client that never asks for the tasks capability keeps getting today's immediate response either way. The task id is the same id the run itself uses, so a task also shows up as an ordinary run in the management API and cost reports. An agent that ends up needing approval never surfaces as a task waiting on input — it is reported as a completed task carrying the same rejection message the synchronous path returns, for the same reason an approval-required tool cannot be exposed in the first place: 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 tracon = builder.AddTracon() .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.MapTracon("/tracon", options => { options.AllowRemoteAccess = true; options.RequireRolePolicies = true; }); app.MapTraconA2A(); ``` The invocation URL is `/tracon/a2a/support`; its card is under `/tracon/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. Tracon 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: `TraconMcpServerOptions` for the MCP server and `TraconA2AOptions` for A2A. They do not carry the same settings. `ExposeAllAgents` and `ToolNamePrefix` are **MCP-server settings only**: `ExposeAllAgents` opts out of naming published agents one by one, and `ToolNamePrefix` keeps the published tool names from colliding with another server's. A2A has neither — its names are fixed at registration, which is why there is no expose-all switch for it. `Budget`, which bounds what an external caller may spend, is on both. ## 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/) — bind exposed agents to authentication, tenant scopes, and execution limits - [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 Tracon notifying another system, another system starts a run in Tracon. 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 a Tracon 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 "Tracon:TriggerSecrets:Slack" "whsec_..." \ --project samples/Tracon.Api ``` ```bash curl -sS -X PUT \ https://agents.example.com/tracon/api/triggers/slack \ -H "Authorization: Bearer $TRACON_API_KEY" \ -H 'Content-Type: application/json' \ -d '{ "targetKind": "agent", "targetName": "support", "signingSecretConfigurationName": "Tracon:TriggerSecrets:Slack", "payloadMode": "path", "payloadPath": "event.text" }' ``` `signingSecretConfigurationName` carries only the configuration **key's name** — never the secret value. Tracon reads the value from `IConfiguration` at request time, the same rule tenant provider bindings and MCP server credentials follow. The name must be under `TraconInboundTriggerOptions.AllowedConfigurationPrefix`, which defaults to `Tracon: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 Tracon. ## Send a signed event ```bash curl -sS -i -X POST \ https://agents.example.com/tracon/api/triggers/default/slack \ -H 'Content-Type: application/json' \ -H "X-Tracon-Timestamp: $(date +%s)" \ -H "X-Tracon-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": "/tracon/api/runs/01a01890-1652-7183-9d50-6efd430644a3", "eventsLocation": "/tracon/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 a Tracon 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: Tracon 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` | `Tracon:TriggerSecrets:` | The only prefix a signing secret's configuration key name may start with | ```json { "Tracon": { "InboundTriggers": { "TimestampTolerance": "00:05:00", "MaxBodyBytes": 262144, "MaxRequestsPerMinute": 60, "AllowedConfigurationPrefix": "Tracon:TriggerSecrets:" } } } ``` :::caution[The rate limit is per process] Like Tracon'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/) — bind endpoint authorization and tenant resolution to your host. --- # Knowledge and RAG Tracon 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 Tracon; using Microsoft.Extensions.AI; using OpenAI; var openAiKey = builder.Configuration["Tracon:Providers:OpenAI:ApiKey"] ?? throw new InvalidOperationException("The OpenAI API key is missing."); var tracon = builder.AddTracon() .UsePostgreSql(builder.Configuration.GetSection( TraconPostgreSqlOptions.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: `TraconPostgreSqlOptions.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" { "Tracon": { "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 `TraconKnowledgeOptions.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 tracon.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 `TraconKnowledgeOptions.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/tracon/api/knowledge/support-policies/documents \ -H "Authorization: Bearer $TRACON_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, Tracon 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/tracon/api/knowledge/support-policies/search \ -H "Authorization: Bearer $TRACON_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 $TRACON_TOKEN" \ http://localhost:5081/tracon/api/knowledge/support-policies/documents # Deletes all chunks of the source. Repeating it still returns 204. curl -X DELETE -H "Authorization: Bearer $TRACON_TOKEN" \ http://localhost:5081/tracon/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 Tracon 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/) - [`TraconKnowledgeOptions` API](/api/tracon.traconknowledgeoptions/) - [`MemorySettings` API](/api/tracon.memorysettings/) - [`IVectorSearchStore` API](/api/tracon.ivectorsearchstore/) - [`UploadDocumentRequest` API](/api/tracon.uploaddocumentrequest/) - [`SearchKnowledgeRequest` API](/api/tracon.searchknowledgerequest/) - [`UsePostgreSql` API](/api/tracon.traconpostgresqlbuilderextensions/) ## Read next - [Context and memory](/guides/context-and-memory/) — control the context sent to a model and the memory retained. - [Persistence](/getting-started/persistence/) — choose a store for data that must survive process restarts. --- # 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 | |---|---|---|---| | `Tracon.OpenAI` | `UseOpenAI()` | `openai` | OpenAI Chat Completions | | `Tracon.OpenAI` | `UseOpenAI()` | `openai-responses` | OpenAI Responses | | `Tracon.OpenAI` | `UseOpenAICompatible(name, …)` | your `name` | Compatible Chat Completions | | `Tracon.Anthropic` | `UseAnthropic()` | `anthropic` | Anthropic Messages | | `Tracon.Google` | `UseGoogle()` | `google` | Gemini Developer API | | `Tracon.Azure` | `UseAzureOpenAI()` | `azure-openai` | Azure OpenAI Chat Completions | The `Tracon` meta package includes `Tracon.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 "Tracon:Providers:OpenAI:ApiKey" "" dotnet user-secrets set "Tracon:Providers:Anthropic:ApiKey" "" dotnet user-secrets set "Tracon:Providers:Google:ApiKey" "" dotnet user-secrets set "Tracon:Providers:AzureOpenAI:ApiKey" "" ``` Register only the providers for which your host has complete configuration: ```csharp title="Program.cs" using Tracon; var tracon = builder.AddTracon() .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. ## Live voice providers OpenAI can also register an `ILiveVoiceProvider`, which is a different kind of registration again: it opens an outbound connection billed by the second, so it is a separate call rather than a flag on `UseOpenAI()`. ```csharp title="Program.cs" tracon .UseOpenAI(builder.Configuration.GetSection(OpenAIProviderOptions.SectionName)) .UseOpenAILive(builder.Configuration.GetSection(OpenAILiveOptions.SectionName)) .UseLiveVoice(); ``` The provider runs the whole spoken conversation and carries the audio straight to the browser; your agents handle the work it delegates. The API key stays on the server. See [provider-hosted live voice](/guides/voice/#provider-hosted-live-voice) for what that trade gives up. ## Image generation providers OpenAI, Azure OpenAI, and Google can also register an `IImageGenerator`. These are separate from chat-model registrations because an image model or Azure deployment is not safely inferred from an agent's chat model. ```csharp title="Program.cs" tracon .UseOpenAIImages(options => { options.Enabled = true; options.Model = "gpt-image-1"; }); // Azure uses an image deployment name. // tracon.UseAzureOpenAIImages(options => options.Model = "image-deployment"); // Google uses a provider image model. It does not support WIDTHxHEIGHT in this surface. // tracon.UseGoogleImages(options => options.Model = "your-imagen-model"); ``` Each extension shares the authenticated client factory already created by `UseOpenAI`, `UseAzureOpenAI`, or `UseGoogle`; it adds no second credential path or package. The extensions register generators by provider name. When more than one is registered, set `Tracon:Images:Provider` to `openai`, `azure-openai`, or `google` to select the generator and matching price table. An unkeyed `IImageGenerator` that your application registers remains the fallback for a custom provider. `UseOpenAICompatible()` does not imply image support. Register a supported image extension only after you verify that its provider API supports the selected model. Then bind an agent to one stable provider name: ```csharp tracon.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. Tracon 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 tracon.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; Tracon 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`; Tracon 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; tracon.UseAzureOpenAI(options => { options.Endpoint = new Uri("https://my-resource.openai.azure.com/"); options.CredentialFactory = static () => new DefaultAzureCredential(); options.DefaultDeployment = "support-production"; }); ``` `Tracon.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. BYOK is an **optional** provider capability, not a universal contract. A provider opts in by implementing `ITenantCredentialModelProvider` in addition to `IModelProvider`; the four built-in providers (OpenAI, Anthropic, Google, Azure OpenAI) all do. A provider that does not implement it simply never sees a tenant credential, and Tracon never falls back to the setup-time key on its behalf: a tenant binding saved against a provider that does not support BYOK fails **every** run with a stable `provider_credential_unsupported` error instead of silently billing the tenant's traffic to the host's own account. A tenant's binding stores only the **name** of a configuration key, never the value: ```bash dotnet user-secrets set "Tracon:ProviderKeys:Acme:OpenAI" "" ``` ```bash curl -X PUT "http://localhost:5081/tracon/api/tenants/acme/providers/openai" \ -H "Authorization: Bearer $TRACON_TOKEN" \ -H "Content-Type: application/json" \ -d '{"apiKeyConfigurationName": "Tracon:ProviderKeys:Acme:OpenAI"}' ``` The name must sit under the configured prefix (default `Tracon: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/tracon/api/tenants/acme/egress" \ -H "Authorization: Bearer $TRACON_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 Tracon does not ship a package for. Implement `IModelProvider` — a stable `Name`, a `Models` catalog, and `CreateChatClient(ModelBinding)` returning a raw `IChatClient` — and register it. If the provider should also accept a per-tenant credential (see [Per-tenant credentials](#per-tenant-credentials-byok) above), additionally implement `ITenantCredentialModelProvider`, whose `CreateChatClient(ModelBinding, ModelProviderCredential)` takes a non-null credential; a provider that does not implement it is never called with one — Tracon fails the run instead of falling back to the setup-time key on its behalf: ```csharp public sealed class ContosoModelProvider(HttpClient httpClient, string setupApiKey) : ITenantCredentialModelProvider { public string Name => "contoso"; public IReadOnlyList Models { get; } = [new ModelDescriptor { Name = "contoso-large", SupportsTools = true }]; public IChatClient CreateChatClient(ModelBinding binding) => new ContosoChatClient(httpClient, binding.Model, setupApiKey); public IChatClient CreateChatClient(ModelBinding binding, ModelProviderCredential credential) => new ContosoChatClient(httpClient, binding.Model, credential.ApiKey); } services.AddHttpClient(); tracon.AddModelProvider(services => new ContosoModelProvider(services.GetRequiredService(), setupApiKey)); ``` A provider that never wants to support BYOK simply implements only `IModelProvider` and stops there — it still works with the setup-time credential for every tenant. A complete, runnable version of this provider — including per-tenant credentials and provider-settings validation — lives in the repository at `samples/Tracon.Samples.CustomModelProvider`. It takes Tracon by `PackageReference` only, which is what makes it a proof rather than an illustration. ### The runtime contract The rules below are what the registry and the compile path actually rely on. An implementation that breaks one still compiles and still passes its own unit tests; it misbehaves under a real deployment. Verify them by deriving `ModelProviderContract` from the [`Tracon.Testing.Contracts.Xunit`](/packages/) package rather than by reading carefully. If your provider supports BYOK, derive its opt-in `ModelProviderCredentialContract` too. Its required assertion must inspect the provider request boundary (for example a recording transport), not merely a different client object; otherwise a new wrapper can still send the setup-time key and bill the wrong tenant. **Lifetime and threading.** Your provider is a **singleton**. It must not capture a scoped service, and the provider set is fixed once the container is built. `CreateChatClient` is called **concurrently** and must be thread-safe. If you cache a client per credential in a `ConcurrentDictionary`, remember that `GetOrAdd` may run its factory more than once for the same key under a race and discard the extras — so building a client must be side-effect free. **Return a raw client.** `UseFunctionInvocation()`, OpenTelemetry, the content guard, the circuit breaker, the concurrency limiter, attachment resolution, response caching, the fallback chain and content-filter detection are all added by `ModelProviderRegistry` around whatever you return. Building any of them yourself is not merely redundant: the registry places the content guard directly above your client so that *every* turn of the tool-call loop is inspected. With your own inner loop, the turn that carries a tool result back into the model runs beneath the guard — the exact path prompt injection takes — while the reply text and the tool-call count stay identical, so nothing else reveals it. **Tracon never disposes the client you return.** It is built once per compiled agent and held by it; the compiled agent implements neither `IDisposable` nor `IAsyncDisposable`, and evicting one from the compile cache drops the reference without disposing. You own the lifetime of what you return, and it must tolerate never being disposed — which is why the shipped providers return clients backed by a long-lived, shared SDK client. **Naming.** `Name` is matched against `ModelBinding.Provider` case-insensitively. Registering two providers under one name is not last-one-wins: the host fails at startup. **The catalog is not an allow list.** See [the next section](#model-catalog-is-metadata-not-permission). **Credentials.** `CreateChatClient(ModelBinding, ModelProviderCredential)` — the `ITenantCredentialModelProvider` overload — is called only when a tenant supplies a credential **and** your provider implements that interface; without it, Tracon never calls your provider with a tenant credential at all, and it does not fall back to calling the plain `CreateChatClient(ModelBinding)` overload with the tenant's request either — the run fails closed with `provider_credential_unsupported` instead. If you do implement the interface, the key must never fall back to your setup-time key: a tenant that supplied a credential is billed on it, or the call fails. An endpoint may fall back, so a globally configured base address still applies when a tenant overrides only its key. ### How failures are classified Tracon decides whether to move to the next `Fallbacks` link by reading the exception's type **name** and message **text** across the whole exception graph, because the core holds no compile-time reference to any provider SDK's exception types. What that means when you write a provider: | Failure | Next fallback link is tried | |---|---| | A timeout — a `TimeoutException` in the graph, or a message that says it timed out | Yes | | `OperationCanceledException` with no timeout signal, while the caller's token is cancelled | No — the caller asked to stop | | Message carries `HTTP 401` or `HTTP 403` | No — switching providers would hide a configuration mistake | | Message carries `429`, `too many requests`, or `rate limit` | Yes | | Message carries `HTTP 5xx` | Yes | | A transport/SDK-client type with no HTTP status in the message | Yes — treated as a connection failure | | Anything else | No — the retry set is closed and positive | :::caution[A timeout is not a cancellation] .NET reports an `HttpClient` request timeout as a `TaskCanceledException`, which derives from `OperationCanceledException`. Do not let your own provider or retry classifier take that type at face value: a timeout with nobody cancelling is a failure, and treating it as a cancellation makes a provider outage look like a user pressing stop. Tracon separates them by looking for a timeout signal in the exception graph and, where a token is in scope, by asking whether that token was actually cancelled. ::: Two consequences are worth stating plainly. A provider-side safety filter is **not** an exception here: return an ordinary `ChatResponse` with `ChatFinishReason.ContentFilter` and Tracon raises `TraconContentFilteredException` itself. Throwing instead makes the circuit breaker count a healthy provider as failing. And a `TraconException` thrown from `CreateChatClient` is wrapped as a compilation error naming the agent, while every other exception propagates raw — so use it for configuration or binding problems the host author can act on. ## 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. The four capability flags do not carry equal weight, and only one of them is a gate: | Flag | What it does | |---|---| | `SupportsStructuredOutput` | **Enforced.** An agent requesting JSON output fails compilation when the model is *found* in the catalog with this explicitly `false`. A model absent from the catalog is not checked. See [Structured output](/guides/structured-output/) | | `SupportsTools` | Advisory metadata — nothing enforces it at run time | | `SupportsStreaming` | Advisory metadata — nothing enforces it at run time | | `SupportsReasoning` | Advisory metadata — nothing enforces it at run time | The advisory flags still drive the console's picker and capability hints, so an inaccurate catalog misleads a human even where it cannot fail a run. ## Response caching Enable it per agent. Tracon forces the cache key with three inputs beyond the messages and options themselves: the tenant, the sorted tool names, and the provider — a hit never crosses a tenant boundary and never lands on an agent with a different tool set, even when the prompt and instructions are otherwise identical. ```csharp builder.Services.AddDistributedMemoryCache(); // or a real distributed cache: Redis, SQL Server, ... tracon.AddAgent(new AgentDefinition { Name = "cached-support", Instructions = "Resolve support requests. State uncertainty clearly.", Model = new ModelBinding { Provider = AnthropicProviderNames.Anthropic, Model = "your-current-model-name", ResponseCache = new ResponseCacheSettings { Enabled = true, Lifetime = TimeSpan.FromMinutes(10) }, }, }); ``` A hit skips the model call entirely: no token usage, no cost, and no new trace span for that turn. It does **not** skip the tool-call loop — if the cached response carries a tool call, the tool still runs; a hit is not a shortcut around side effects. A run's own `usage` field reports `null` for a hit, not `0`: Tracon distinguishes "not measured" from "measured as zero" everywhere it reports usage. Turning `ResponseCache.Enabled` on without an `IDistributedCache` registered fails validation and fails to compile the agent; the error names the missing registration. Nothing runs uncached silently. A store failure (a timeout, an oversized payload the store rejects) is logged and treated as a miss on read, or simply dropped on write — a cache problem never fails a call that would otherwise have succeeded. ## Concurrent tool calls By default, independent tool calls returned in the same turn run one after another. Turn `AllowConcurrentToolCalls` on to run them at the same time instead — useful when a turn calls several independent, I/O-bound tools and their combined latency matters more than a small increase in peak concurrency. ```csharp Model = new ModelBinding { Provider = AnthropicProviderNames.Anthropic, Model = "your-current-model-name", AllowConcurrentToolCalls = true, }, ``` Off by default: with no change, calls still run one at a time exactly as they do today. Turn it on only for tools whose bodies are safe to run concurrently with themselves — a tool that shares mutable state across calls without its own synchronization should not opt in. ## Check a prompt against the context window before running it The pre-flight check is `TraconPreflightOptions`, bound from `Tracon: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 `TraconModelConcurrencyOptions`, bound from `Tracon:ModelConcurrency`: `MaxConcurrentCallsPerProvider` caps how many calls Tracon 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 { "Tracon": { "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/tracon/api/agents/support/estimate" \ -H "Authorization: Bearer $TRACON_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 | `Tracon:ProviderKeys:`; a name outside it is rejected with `400` | | Response cache | Off; a binding with `ResponseCache.Enabled = true` and no registered `IDistributedCache` fails to compile | | Response cache lifetime | 10 minutes, when caching is enabled | | Concurrent tool calls | Off; independent tool calls in one turn run one after another | Force a current, cost-free reachability check with: ```bash curl -H "Authorization: Bearer $TRACON_TOKEN" \ "http://localhost:5081/tracon/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/tracon.modelbinding/) - [`ResponseCacheSettings` API](/api/tracon.responsecachesettings/) - [`UseOpenAI` API](/api/tracon.openaiproviderextensions/) - [`UseOpenAICompatible` API](/api/tracon.openaicompatibleproviderextensions/) - [`UseAnthropic` API](/api/tracon.anthropicproviderextensions/) - [`UseGoogle` API](/api/tracon.googleproviderextensions/) - [`UseAzureOpenAI` API](/api/tracon.azureopenaiproviderextensions/) ## Read next - [Reliable runs](/guides/reliability/) — provider fallback chains and outgoing concurrency limits - [Choosing packages](/packages/) — select the packages needed by your host and its integrations. - [Agents and definitions](/concepts/agents/) — configure and version the definitions that the catalog resolves. --- # Multimodal input and generated images Tracon 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/tracon/api/attachments?sessionId=case-4182" \ -H "Authorization: Bearer $TRACON_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/tracon/api/agents/invoice-reader/run \ -H "Authorization: Bearer $TRACON_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 Tracon 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. Tracon 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 `AddTracon()` when code values must override the values read from `Tracon:Attachments`. ## List, download, and delete ```bash # Descriptors only. Defaults: skip=0, take=50. take is clamped to 1..200. curl -H "Authorization: Bearer $TRACON_TOKEN" \ "http://localhost:5081/tracon/api/attachments?sessionId=case-4182" # Raw bytes with the stored MIME type and a SHA-256 ETag. curl -OJ -H "Authorization: Bearer $TRACON_TOKEN" \ "http://localhost:5081/tracon/api/attachments/{attachmentId}" # Immediate hard delete. curl -X DELETE -H "Authorization: Bearer $TRACON_TOKEN" \ "http://localhost:5081/tracon/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 `