Operate
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
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
Section titled “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 below; to turn a missed binding
into a failed startup instead of a report nobody reads, see
Make a binding required.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ITenantContext, YourTenantContext>();builder.Services.AddSingleton<ITenantStore, YourTenantStore>();builder.Services.AddSingleton<IRunAttributionContext, YourRunAttributionContext>();builder.Services.AddSingleton<IToolAuthorizationHandler, YourToolAuthorizationHandler>();builder.Services.AddSingleton<IRunAuthorizationHandler, YourRunAuthorizationHandler>();builder.Services.AddSingleton<IRunEventSink, YourRunEventSink>();builder.Services.AddSingleton<IAttachmentStorage, YourAttachmentStorage>();
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
Section titled “1 — Tenant resolution”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 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 — a
background job binds both together, since neither has a request to read from:
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<T>; 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
Section titled “2 — Run attribution”Attributes a run to a user and, optionally, job labels — see 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
Section titled “3 — Tool authorization”Decides whether a caller may invoke a specific tool at all, separately from
approval — see Tools 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 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
Section titled “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 and Troubleshooting a slow sink 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
Section titled “5 — Attachment storage”public sealed class YourAttachmentStorage(IYourBlobClient blobs) : IAttachmentStorage{ public async ValueTask<Uri> WriteAsync( string tenantId, Guid id, Stream content, string mediaType, CancellationToken cancellationToken = default) => await blobs.PutAsync($"{tenantId}/{id}", content, mediaType, cancellationToken);
public ValueTask<Stream?> 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
Section titled “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.
Ownership and attribution are not the same promise
Section titled “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 |
public sealed class YourRunAuthorizationHandler(IYourOwnershipService ownership) : IRunAuthorizationHandler{ public async ValueTask<RunAuthorizationResult> 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<RunAuthorizationResult> 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/responsesand/v1/chat/completionsendpoints, andPOST /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}andGET /v1/conversations/{id}/items.POST /v1/conversationsis 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
Section titled “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:
[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
Section titled “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) 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:
var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();builder.Services.AddDbContext<YourDbContext>(o => o.UseNpgsql(dataSource));builder.AddTracon() .UsePostgreSql(o => o.DataSource = dataSource);See Two connection planes: EF Core and Tracon 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 for
the fleet-deployment version of this same setting.
Make a binding required
Section titled “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:
builder.Services.AddSingleton<IRunAuthorizationHandler, YourRunAuthorizationHandler>();builder.Services.AddSingleton<IToolAuthorizationHandler, YourToolAuthorizationHandler>();
builder.AddTracon() .RequireCustomBinding<IRunAuthorizationHandler>() .RequireCustomBinding<IToolAuthorizationHandler>();The check runs while the host starts, and the message names the contract, the type that resolved instead, and how to fix it:
IRunAuthorizationHandler was declared as a required custom binding, but Tracon'sbuilt-in default AllowAllRunAuthorizationHandler is what resolved. Register your ownIRunAuthorizationHandler 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
RequireCustomBindingbehaves 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. IRunEventSinkandIAttachmentStorageare 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
Section titled “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.
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,<br/>IToolAuthorizationHandler, IRunAuthorizationHandler,<br/>IRunEventSink, IAttachmentStorage"] --> B["AddTracon call<br/>TryAdd claims any still-open slot"]
B --> C["GET /api/diagnostics<br/>reads extensionPoints"]
C --> D{"isBuiltInDefault?"}
D -->|false| E["binding is active"]
D -->|true| F["registered too late, or against<br/>the wrong interface"]
{ "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) 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
Section titled “Verification checklist”- Every binding you need is registered before
AddTracon() - Bindings your deployment must not run without are declared with
RequireCustomBinding<T>() -
GET /api/diagnosticsshowsisBuiltInDefault: falsefor each contract you bound - A background job opens
AmbientTenantScope.Begin(tenantId)in the method that starts the run, and the scope covers everyawaiton that path -
IRunEventSink.OnEventAsyncnever 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 -
AutoApplyMigrationsmatches your deployment’s migration strategy, not just the default
Read next
Section titled “Read next”- Runs — attribution and the event sink in full
- Governance — the built-in tenant resolution chain
ITenantContextreplaces - Production — migration strategy and process topology