Tracon / Approach control for your agents
The .NET control plane for Microsoft Agent Framework.
A package family that runs inside your own ASP.NET Core host. MAF runs your agents. Tracon records them, scopes them to a tenant, and governs what they may call.
dotnet add package Tracon --prerelease Production-oriented preview packages are on NuGet and npm. No stable version yet. Source on GitHub
RUN 01a0d2af…a47esupport · acmeSample
- 0
run.startedT+00:00.048"Please cancel ORD-7. I ordered it twice." - 1
tool.invokingT+00:01.400get_order_status orderId=ORD-7 - 2
tool.invokedT+00:01.401"Order ORD-7 has shipped." - 3
message.completedT+00:01.402"" · a tool call,no text - 4
run.awaiting-inputT+00:01.406[{"requestId":"…","toolName":"cancel_order"}]Holding
AwaitingApproval · cancel_order has not run
Two lines to the tower.
Stores start in memory and the host starts without a model key. The console and API live under /tracon, and until you allow remote access, only local callers reach the data endpoints.
using Tracon;
var builder =
WebApplication.CreateBuilder(args);
builder.AddTracon().UseUI();
var app = builder.Build();
app.MapTracon(); // at /tracon
app.Run();20 modular packages176 generated HTTP operations30 embedded console screens
MAF flies the aircraft. Tracon runs the airspace.
AIAgent, AgentSession, ChatMessage and AIFunction pass through unwrapped. There is no parallel type hierarchy to adopt.
One request, four questions.
A customer asks the support agent to cancel order ORD-7. The order has shipped, and the model proposes cancel_order anyway. Follow that one sample request to its filed record.
01 / Separate T+00:00.003
Who owns this request?
acme · matched by the API key
The request carries an API key bound to tenant acme, and the key outranks any claim or header. From here on, tenant-scoped queries keep each tenant’s records logically isolated. Your host signs operators in; Tracon reads their tenant_id claim, and once a claim type is set, it never reads the header.
builder.AddTracon()
.UseTenancy(o =>
{
o.Enabled = true;
o.ClaimType = "tenant_id";
});02 / Clear T+00:01.406
Who may call this tool?
No one yet. AwaitingApproval
The policy is set in code, not by the model. The tool requires approval, so the queued run closes AwaitingApproval and the call waits with its arguments, for 24 hours by default. A tool is a method: the console can select it but never write it.
No clearance, no takeoff.
[TraconTool("cancel_order",
"Cancels an order.",
RequiresApproval = true,
Effect = ToolEffect.Destructive)]
public static string CancelOrder(
[Description("The order id.")]
string orderId)
=> $"Order {orderId} is canceled.";

03 / Recover T+04:12.300
What survives a restart?
The hold. The decision later opens R2.
builder.AddTracon()
.UsePostgreSql(connectionString);At T+01:30 the host restarts; the held run and the pending call are rows in PostgreSQL. After the restart, ops@acme.test rejects the call. The audit entry is written first, and the decision applies only if that write succeeds.
The decision opens R2 in session s-7f3a, where the model reads the refusal. R1 keeps its status: run history is append-only. A client that lost the 202 retries with the same Idempotency-Key and gets the stored response.
Timeline · T+ from the first requestSample
approval.decision "approved":falseaudit writtendecision appliedR2 queued
04 / Record T+04:14.118
What happened, and who changed what?
Valid · audit trail, 3 entries checked
Each run is a numbered event tape: sequence numbers start at 0, leave no gaps, and resume after Last-Event-ID when a stream drops. These are persisted run records. Recording is best-effort, so a store failure costs the record, not the run.


reserve_stock failed, with its arguments.Audit trail · tenant acmeSample
Runs are not audit entries; the run history is the record. The audit trail records who changed what: the tenant, the key, the decision. Each entry stores its SHA-256 and the previous entry’s hash.
GET /tracon/api/audit/verify {"status":"Valid", "entriesChecked":3, "firstFailingEntryId":null}{"status":"Broken", "entriesChecked":3, "firstFailingEntryId": "01a0d2b3-85e6-719a-ae24-c8b3d1e0f571"}
Verification is an API call; the console does not run it. Tamper detection
Thirty minutes, one machine: 12 515 runs accepted, 12 515 completed, no gap in any event stream, nothing under the wrong tenant. Not an SLA.
prev
null#1
tenant.createtenant:acmehash
ef98b97e…f3b3prev
ef98b97e…f3b3#2
apikey.createapikey:01a0d2a2…2b10hash
5f2e91af…08ffprev
5f2e91af…08ff#3
approval.decisiontool:cancel_orderafter
{"approved":false,…}{"approved":true,…}hash
de2fed3b…fbd5≠ recomputedb4c99378…f65c
Autonomy, under clearance.
The model proposed. Your code set the policy. A person decided, and the record kept each step.
Most governance is off until you turn it on. Before production, make the host refuse to start while a decision is still open.
builder.AddTracon()
.RequireProductionProfile();RequireProductionProfile() was called, and 6 production decisions are still on the permissive default, so the host does not start.
Your host owns the boundary: authentication, model providers, storage and outbound connections. The public API is not frozen, and Tracon.AspNetCore uses preview MAF hosting packages. Architecture and limits · Compatibility · Versioning · Licensing
Coming from DevUI? Microsoft advises you to “build your own custom interface” for production. Tracon is one implementation of that production-facing layer. Compare DevUI and Tracon