Operate
Persistence
Without a database every store is in memory and everything ends with the process. That is deliberate — it makes the first agent work with no infrastructure — but it is not where you stop.
Pick one
Section titled “Pick one”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.
Binding from configuration is the usual shape:
.UsePostgreSql(builder.Configuration.GetSection(TraconPostgreSqlOptions.SectionName)){ "Tracon": { "PostgreSql": { "ConnectionString": "", "SchemaName": "tracon", "AutoApplyMigrations": true, "CommandTimeoutSeconds": 30, "EnableKnowledge": false, "EnableReadViews": false } }}How each provider isolates its tables
Section titled “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.
Migrations run at startup
Section titled “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:
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<br/>provider lock serializes concurrent instances"]
CHECK -->|"false"| SEPARATE["tracon migrate<br/>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.
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:
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:
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 for setup and the rest of the commands.
Giving your own data source instead of a connection string
Section titled “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:
.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 and, for the full EF Core pattern, Two connection planes: EF Core and Tracon.
What changes once it is durable
Section titled “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 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
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 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:
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.
Keeping it from growing forever
Section titled “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:
curl 'http://localhost:5081/tracon/api/retention/preview'curl -X POST 'http://localhost:5081/tracon/api/retention/run'Durability also enables governance
Section titled “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.
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).
Durable rows are also what content protection encrypts.
A durable sessions row is also what
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
Section titled “Read next”- Securing the endpoints — required reading before this leaves your machine.
- Write your own store — implement
IRunStore(or another store interface) against a persistence engine none of the three built-in providers cover.