Skip to content

Using Affiant with Microsoft Agent Framework

Status: the Microsoft Agent Framework (MAF) bridge described on this page ships as Affiant.AgentFramework, one of the ten co-versioned packages of Sakwala/affiant — in the set since 2026-07-05 and currently at 1.0.0-beta.3, alongside Affiant.SemanticKernel and Affiant.Extensions.AI (the third backend; see Interception Backends). The wiring shown below — AddAffiantAgentFramework(), AffiantToolCatalog, WithAffiant(...) — is unchanged by 1.0.0-beta.3; most of that release’s breaking changes land in Affiant.Core and Affiant.Abstractions (the review-decision and Docket-row contracts — see Review Gate & Write Executors and Docket & Evidence Cards), not in this adapter’s own attach points. Two things here did change. First, this adapter’s hosted-tool coverage audit now raises AffiantCoverageException (a subclass of the new AffiantRefusalException) on both of its refusal paths, in place of the plain InvalidOperationException it threw at every earlier published version — through the framework’s own ToolCoverage.Refuse when a hosted tool is uncovered, and directly when the agent cannot be audited at all. A host catching the old type around WithAffiant should catch the new one instead — and keep the old catch too, because WithAffiant still throws a plain InvalidOperationException on its three DI-misconfiguration paths (IAffiantToolRegistry, ToolInvocationPipeline or AgentFrameworkOptions unregistered). Second, AgentFrameworkInferenceCompletionPort — the inference completion port this adapter owns — gained a third, optional TimeProvider? constructor parameter, so the today’s-date line of its inference prompt reads a clock a test can move. That is source-compatible and a declared binary break against 1.0.0-beta.1.1, recorded in the package’s own CompatibilitySuppressions.xml: a host that constructs the port itself needs a recompile, and a host that lets the container build it changes nothing. See Packages for the full package set.

Meridian, one of the two public demos at meridian.affiant.dev, runs on this adapter.

Microsoft Agent Framework is Microsoft’s current-generation .NET agent SDK — the successor to Semantic Kernel, reaching general availability on 2026-04-03. Microsoft has said new feature investment goes to MAF while SK receives critical-bug and security fixes, with SK support guaranteed for at least one year past MAF’s GA (a floor, not a published end date). If you’re starting a new .NET agent project today, Microsoft steers you to MAF; if you already run SK, nothing forces a migration on any particular timeline. See the FAQ for the full sourcing on that timeline.

Affiant’s stance: SK remains a fully-supported, first-class backend. The MAF bridge is a forward hedge, not a replacement — both can run side by side in one host, each over tool registrations of its own, and nothing about adding MAF support changes what an SK host already has wired up. One bridge per tool catalog, though: Migrating an SK host to MAF below has the rule.

Affiant.AgentFramework is Affiant’s second interception backend, sitting beside Affiant.SemanticKernel and Affiant.Extensions.AI behind one shared, backend-neutral pipeline. The provenance tagging, task inference, and review-gating behavior is the same whichever backend a host runs — the same core filters run regardless. What differs is how the framework attaches to your agent runtime:

  • Neutral pipeline. Provenance tagging and review gating are defined once, in Affiant.Core, and no bridge carries any of that logic of its own. Task inference is neutral where the decisions are made — the trigger, merge and idempotency machinery (InferenceTriggerFilter, TaskInferenceStep, TaskInferenceRunner) lives in Affiant.Core: AddAffiantCore() registers TaskInferenceStep itself, and the backend’s own DI registers TaskInferenceRunner and the same two filters, InferenceTriggerFilter and TaskInferenceMergeFilter — on MAF both from the single AddAffiantAgentFramework() call, on SK split across AddAffiantInferenceOrchestration() (the runner and the trigger filter) and AddAffiantSemanticKernel() (the merge filter). Both bridges register the two completion-stage filters through one shared Affiant.Core helper, which is what makes the merge-before-review order on the onion’s unwind identical on either backend. Each bridge does ship its own IInferenceCompletionPort, though: AgentFrameworkInferenceCompletionPort builds the structured-output prompt itself and issues it on the host’s IChatClient with no tools attached, so the inference call cannot recurse through the tool loop. Beyond that port, each backend package is a thin translation bridge over its framework’s native tool-interception seam.
  • WithAffiant(...) wrapping. MAF attaches middleware by decoration, not DI: it builds a new wrapped AIAgent rather than mutating the one you pass in. WithAffiant(serviceProvider, catalog) is the single blessed way to attach Affiant — it runs the hosted-tool audit (below) first, then registers your tool descriptors, attaches the middleware, and returns the wrapped agent. The audit deliberately runs before any registry mutation: a refused wrap leaves the singleton tool registry untouched, so a corrected retry does not die with “already registered”.
  • AffiantToolCatalog. MAF has no [KernelFunction]-equivalent opt-in attribute, so AffiantToolCatalog.FromType<T>() reflects over your tool type’s public instance methods in one pass — skipping generic method definitions, members declared on object, and special-name members (property accessors, event add/remove) — producing both the AIFunctions MAF invokes and the descriptors the pipeline reads. Two consequences: keep a MAF tool type’s public surface limited to tool methods, because an unrelated public helper becomes a callable AIFunction too; and give each method its own name, because an overload makes FromType<T>() throw rather than register two descriptors under one identity. A tool’s LLM-visible name is not the C# method name but the one AIFunctionFactory.Create derives from it: a [DisplayName] on the method where it carries one, and otherwise the member name sanitized to the characters a function name may carry, with a trailing Async stripped when the method returns Task, Task<T>, ValueTask, ValueTask<T> or IAsyncEnumerable<T> — so Task<string> FetchThingAsync() is tool FetchThing, while a synchronous string LookupThingAsync() keeps its literal name (“One naming rule worth knowing” below has the full statement, and the descriptor takes its FunctionName from the produced name either way). Affiant.AgentFramework’s own [AffiantToolName("search_thing")] overrides it — the counterpart to the naming half of SK’s [KernelFunction("name")], so a host can keep a PascalCase method and still feed one ToolNames constant to the attribute site. FromType<T>() throws if an override is blank, and throws if two methods end up producing the same visible name.
  • Sealing by return, not mutation. The context MAF’s middleware delegate receives is Microsoft.Extensions.AI.FunctionInvocationContext — the same type the Affiant.Extensions.AI bridge works with, not a MAF-specific one — and it carries no Result member to assign the way SK’s filters do: the delegate’s return value is the function result. Affiant’s bridge seals evidence by returning the (possibly replaced) value, and maps a filter’s termination request onto that context’s Terminate flag, used sparingly because Microsoft documents that flag as ending the loop without issuing the follow-up model request, and as able to prevent other function calls from the same iteration being processed at all.

The calls themselves — AddAffiantCore(), AddAffiantAgentFramework(), AffiantToolCatalog.FromType<T>(), WithAffiant(...) — mirror the real wiring in Affiant.AgentFramework (verified against src/Affiant.AgentFramework/Extensions/AgentExtensions.cs and src/Affiant.AgentFramework/AffiantToolCatalog.cs in the framework repository). The turn-scope facade in steps 2 and 3 is this guide’s own recommendation, and it departs from the package README’s quickstart, which hands the agent the application provider — the shape “The turn scope” below is about.

using Affiant.AgentFramework;
using Affiant.AgentFramework.Extensions;
using Affiant.Core.Extensions;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
// 1. Wire-up, once: the neutral pipeline, this backend's bridge, your tool type, and the provider
// client this adapter's inference port completes against.
builder.Services.AddAffiantCore();
builder.Services.AddAffiantAgentFramework();
builder.Services.AddScoped<WorkOrderTools>(); // your tool type
builder.Services.AddSingleton<IChatClient>(providerClient); // your provider client
var app = builder.Build();
// 2. Also once. WithAffiant registers every descriptor with the singleton tool registry, and that
// registry refuses a duplicate — so the wrapped agent is built once and kept. MAF fixes the
// function-invocation service provider at agent-construction time, so what the agent holds is a
// facade that forwards to whatever scope the turn opened. See "The turn scope" below.
var turnScope = new AmbientTurnScope(app.Services);
var catalog = AffiantToolCatalog.FromType<WorkOrderTools>();
AIAgent agent = new ChatClientAgent(
app.Services.GetRequiredService<IChatClient>(),
instructions: "You are a work-order assistant.",
tools: [.. catalog.Functions],
services: turnScope)
.WithAffiant(app.Services, catalog);
// 3. Per turn: open a scope, publish it for this run's tool calls, and set the conversation id.
using var turn = app.Services.CreateScope();
AmbientTurnScope.Turn = turn.ServiceProvider;
try
{
var response = await agent.RunAsync(
userMessage,
session,
new ChatClientAgentRunOptions(new ChatOptions { ConversationId = conversationId }));
}
finally
{
AmbientTurnScope.Turn = null;
}

What the block leaves out. These are the adapter’s attach points, not a complete host. A container that validates on build — the .NET host’s default in Development — refuses at the builder.Build() line above until the registrations a complete host also makes are there: an IStreamingTransport and an IDocketStore, for the ReviewGate that AddAffiantCore() registers; an IRouteRegistry, for the UiGuidanceBridge it registers beside it; and an ITaskInferenceStrategy, for the affidavit projection AddAffiantAgentFramework() registers when it finds no IAffidavitProjection already registered — a host that registers a projection of its own before that call needs no strategy contract. Those four are what container-build validation asks for; with a write-capable tool in the registry when the host starts, AffiantWireUpValidator refuses at StartAsync for a missing IReviewContextProvider or IDecisionAuthorizationPolicy as well — so a host that adds exactly the four above and then starts a write-capable agent is still refused. The validator reads the tool registry that step 2 fills, so wrap the agent after Build() and before the host starts serving; wrapped later, its write-tool checks look at an empty registry.

The facade is a host type, a dozen lines of it — an IServiceProvider that forwards to the turn’s scope while one is open and to the application provider otherwise (before any turn exists the only thing that resolves through it is agent construction, which asks for an ILoggerFactory; WithAffiant resolves the registry, the pipeline and its options from the provider it is handed, and the hosted-tool audit reads the tool set off the agent rather than from any provider):

public sealed class AmbientTurnScope(IServiceProvider root) : IServiceProvider
{
private static readonly AsyncLocal<IServiceProvider?> Ambient = new();
public static IServiceProvider? Turn
{
get => Ambient.Value;
set => Ambient.Value = value;
}
public object? GetService(Type serviceType) => (Turn ?? root).GetService(serviceType);
}

Wrapping produces a new AIAgent instance. MAF’s AsBuilder().Use(...).Build() decorates rather than mutates the original agent — a pre-wrap agent that a host retains and calls instead of the wrapped instance silently bypasses Affiant entirely. Discard the unwrapped local, or shadow it, so nothing in your codebase can call the version with no provenance tracking:

// Wrong — the unwrapped `agent` variable is still callable and bypasses Affiant entirely.
var agent = new ChatClientAgent(chatClient, instructions, tools: [.. catalog.Functions], services: turnScope);
var wrapped = agent.WithAffiant(sp, catalog);
await agent.RunAsync(userMessage, session); // BUG: no provenance captured, no review gate
// Right — only the wrapped instance exists in scope past the wiring line.
AIAgent agent = new ChatClientAgent(chatClient, instructions, tools: [.. catalog.Functions], services: turnScope)
.WithAffiant(sp, catalog);
await agent.RunAsync(userMessage, session); // Affiant's middleware is in the call chain

ChatClientAgent threads its services: argument onto every tool call’s AIFunctionArguments.Services, and that is the provider Affiant’s middleware hands the neutral pipeline. Microsoft scopes that parameter to the case the wiring above is in: it is relevant only when the IChatClient handed to the agent does not already contain a FunctionInvokingChatClient and the agent has to insert one. Hand the agent a client you built with .UseFunctionInvocation() yourself and the services: argument is never consulted at all. The provider is fixed when the agent is constructed, and the agent cannot be rebuilt per turn: WithAffiant registers each descriptor with the singleton tool registry, which throws on a second registration, so the wrapped agent is a process singleton.

Hand that singleton the application root and the pipeline is asked to resolve its scoped filters, and the scoped ContextFabric behind them, from the root provider. Under scope validation — the .NET host’s default in Development — the tool call throws InvalidOperationException: Cannot resolve scoped service … from root provider; MAF’s function-invoking client records a failed tool call and lets the loop continue, so the model answers without the tool and Affiant governs nothing. With validation off nothing throws and one ContextFabric is shared by every conversation in the process, which is the silent half of the same mistake.

The facade closes both while leaving the agent a singleton: every resolution the pipeline makes lands in the turn’s own scope — the filters, the fabric, and the tool type itself, which AffiantToolCatalog resolves from the invocation’s provider on every call, so an AddScoped<WorkOrderTools>() registration is correct only when that provider is a scope. Meridian, the MAF host behind one of the two public demos, wires it this way. The Microsoft.Extensions.AI guide carries the same shape at its own seam, where the per-turn provider is the one the chat client was built from.

AddAffiantAgentFramework() is the MAF analog of SK’s AddAffiantSemanticKernel() + AddAffiantInferenceOrchestration() combined into one call — MAF has a single function-calling seam rather than SK’s invocation/auto-invocation split, so there’s no separate “pre-tool” and “post-tool” registration to keep apart. The analogy is a filter-level one: AddAffiantSemanticKernel() also registers AffiantStartupValidator, CapabilityRegistry and IManualToolInvoker, and AddAffiantAgentFramework() has no analog for any of the three.

Stated plainly: Affiant on MAF swears only to writes made by locally-invoked tools. This is the same boundary Affiant draws on SK — see The Honest Boundary for the full shape of it — reproduced one layer up the stack, not removed.

MAF’s function-calling middleware fires only for client/locally-invoked tools: function tools (AIFunction) and local MCP tools. Hosted/provider-side tools bypass it entirely — hosted MCP, code interpreter, web search, file search, and other server-executed toolboxes run on the LLM provider’s own infrastructure and never enter the client middleware pipeline. There is no MAF extension point that would let Affiant observe them. If your agent has a hosted tool that can write anywhere, Affiant cannot see, tag, or gate that write — full stop.

WithAffiant(...) makes this structural rather than a silent gap, by auditing the agent’s tool set before its first turn:

  • Default: refuse. The audit reads the tool set off the agent you hand WithAffiant, before any wrapping or registry mutation happens. If that set contains any tool that is not an AIFunction, WithAffiant throws AffiantCoverageException (as of 1.0.0-beta.3, via the framework’s own ToolCoverage.Refuse — previously a plain InvalidOperationException), naming every uncovered tool. An agent whose ChatOptions.Tools is null or empty returns from the audit silently: there is nothing there to refuse.

  • Also default: refuse an agent it cannot audit at all. The audit reaches the tool set through agent.GetService(typeof(ChatOptions)), and ChatClientAgent is the only concrete AIAgent in Microsoft.Agents.AI 1.13.0 that exposes ChatOptions itself — a delegating agent, including the wrapped one WithAffiant returns, forwards GetService to the agent it wraps. When the probe returns null the tool set cannot be enumerated, so WithAffiant refuses with the same AffiantCoverageException and emits a coverage.refused event naming the agent’s type with the category spelled "unauditable-agent" — not one of the three CoverageCategory members, because what cannot be covered here is an agent rather than a named tool. AgentFrameworkOptions.AllowUnauditableAgent = true downgrades it to a logged warning and a telemetry span, with no coverage.refused event and nothing recorded against ToolCoverage.

  • Override: explicit acknowledgment. AgentFrameworkOptions.AcknowledgeUncoveredTools = ["code_interpreter", ...] permits named hosted tools to pass through. Each acknowledgment emits a telemetry span and a logged warning at wrap time, so it’s auditable, never silent:

    builder.Services.AddAffiantAgentFramework(options =>
    {
    options.AcknowledgeUncoveredTools = ["code_interpreter"];
    });

The rationale is the same one behind Affiant’s whole design: “Nothing commits without evidence. Nothing writes without approval.” A silently uncovered write path breaks that promise while the host believes it holds — refusal-by-default makes the boundary structural instead of a footnote.

Moving an existing Affiant.SemanticKernel host to Affiant.AgentFramework — or running both side by side, each over tool registrations of its own — runs the same filters under the same rules: the neutral pipeline is identical either way, and the rule for when inference fires and how its result merges is one piece of code. The completion port that issues the inference call to your provider is the backend’s own — and so are two of the inputs that rule reads. Task inference runs once per (ConversationId, function name, turn number), and each bridge sources the first and the third itself. MAF’s middleware takes the turn number from FunctionInvocationContext.Iteration and the conversation id from FunctionInvocationContext.Options?.ConversationId. The SK bridge reads kernel.Data["AffiantTurnNumber"] — 0 when the host never set it — and kernel.Data["ConversationId"]. Whichever backend is underneath, InferenceTriggerFilter keys on the identity hash of the ambient IContextFabric instance when the conversation id it is given is empty — a conservative fallback worth exactly as much as that instance’s lifetime. So an SK host moving to MAF gets the turn number per iteration without doing anything, and has to move its conversation id onto ChatOptions.ConversationId.

Setting it covers the first round of a turn, and no more than that. Under Microsoft.Extensions.AI 10.9.0 the FunctionInvokingChatClient in the chain — the one ChatClientAgent inserts in the wiring above — re-derives the in-flight options’ ConversationId from each provider response before it runs the next round, so against a stateless provider, one whose responses carry no conversation id of their own, the middleware reads the host’s id at iteration 0 and null from iteration 1 onward, and every tool call from the second round of a multi-round turn takes the fabric fallback instead. The host’s own ChatOptions object is left untouched; it is the copy in flight that changes. What that costs depends on the scope: with the per-turn facade above the fallback fabric is this turn’s, so those later rounds key on the turn rather than on the conversation; hand the agent the application root and it is the process-global fabric again, where round-two calls in unrelated conversations collide exactly as they would if the id had never been set. Nothing in the adapter restores the id at 1.0.0-beta.3; closing that gap is the host’s.

One Affiant bridge per tool catalog. Side by side means side by side: a Kernel with its own plugin registrations, an agent with its own catalog. Two bridges over the same tool catalog or chat-client pipeline is the mistake — the neutral pipeline is not idempotent, so a tool call that passes through two Affiant seams runs the whole onion twice. The invocation-stage filters (ToolArgumentCaptureFilter, InferenceTriggerFilter) fire twice, and a declared write tool’s second gate lands on whatever the first one left: where the first filing needs a human, the result is already the gate’s turn-ending message, so the outer gate reads a write-capable tool returning something that is not a proposal and seals a wireup-invalid refusal onto it; where the first filing settled without one — a Standing Order approval, a Referral escalation — the proposal is still the result, the entry id derives from it again, and the second filing finds the first row and replays it rather than adding a second. Nothing in the framework catches the pairing: the two double-wrap guards live inside Affiant.Extensions.AI and see only its own wrapper, and Affiant.AgentFramework neither references that package nor carries a guard of its own — on this backend the pairing no guard refuses is agent.WithAffiant over a chat client whose tools chatOptions.WithAffiant had already wrapped. What usually stops a second wiring first is unrelated to any of that: both walkers default the plugin name to typeof(T).Name, so an SK AddAffiantPluginsFromType<WorkOrderTools>() and a MAF AffiantToolCatalog.FromType<WorkOrderTools>() collide in the singleton tool registry with already registered — a duplicate-descriptor error that says nothing about the real mistake. Give the second one a plugin name of its own and both register, but the lookup does not survive it: this adapter’s middleware resolves each call’s descriptor by function name alone, and the registry throws InvalidOperationException naming the ambiguity when two descriptors share a function name under different plugin names. Interception Backends states the rule for all three bridges.

What changes at the call site:

Concern SK host MAF host
Tool registration [KernelFunction] + plugin registration Every public instance method reflected by AffiantToolCatalog.FromType<T>() (bar object members, accessors and generic definitions) — no opt-in attribute; [AffiantToolName("snake_name")] replaces the naming half of [KernelFunction("snake_name")]
Attach Affiant DI-registered filters on the Kernel agent.WithAffiant(services, catalog) — a decoration producing a new AIAgent
DI setup AddAffiantSemanticKernel() + AddAffiantInferenceOrchestration() AddAffiantAgentFramework() — one call
Provider abstraction IChatCompletionService + connector capabilities Microsoft.Extensions.AI.IChatClient — the abstraction MAF is built on
Session state SK ChatHistory MAF AgentSession (agent.CreateSessionAsync())
Conversation identity kernel.Data["ConversationId"] / kernel.Data["AffiantTurnNumber"], host-populated ChatOptions.ConversationId on the run options, read from FunctionInvocationContext.Options; the turn number is the context’s Iteration
Hosted-tool coverage No wire-up audit ships; a host that registers ToolCoverage can still declare a tool uncovered Audited at WithAffiant time and refused by default — plus the same ToolCoverage declarations

One naming rule worth knowing, because the two backends do not agree. On MAF the condition is the return type. AIFunctionFactory.Create — which AffiantToolCatalog.FromType<T>() calls — strips a trailing Async only when the method returns Task, Task<T>, ValueTask, ValueTask<T> or IAsyncEnumerable<T>, so Task<string> FetchThingAsync() becomes tool FetchThing while a synchronous string LookupThingAsync() keeps its literal name. The adapter’s own tests pin both halves, and the descriptor always takes its FunctionName from the produced AIFunction.Name — on MAF the descriptor, the LLM and every invocation agree on one string. Two attributes pre-empt the derivation entirely: a [DisplayName("…")] on the method replaces the derived name outright, and Affiant.AgentFramework’s own [AffiantToolName("…")] wins over both.

On SK the condition is the name. The walker that produces Affiant’s descriptors — AddAffiantPluginsFromType<T>() and AddAffiantPluginsFromAssembly(...), which Affiant ships — strips a trailing Async from the method name unconditionally whenever [KernelFunction] carries no explicit name, while SK’s own KernelFunctionFromMethod keeps a synchronous method’s name. A synchronous [KernelFunction] string LookupThingAsync() therefore registers as SK function LookupThingAsync and Affiant descriptor LookupThing; the registry lookup by (function name, plugin name) misses, and AffiantStartupValidator throws AffiantStartupException at boot naming that method. The SK walker’s tests pin only the Task-returning half. Give such a method an explicit name — [KernelFunction("lookup_thing")] — or do not suffix a synchronous method with Async.

The Honest Boundary covers the hosted-tool limit in full and states why it’s architecturally true rather than a missing feature. Interception Backends compares all three bridges side by side, including Affiant.Extensions.AI — the third backend — and the ConversationId gotcha that affects task inference across all of them at this seam — on MAF the host supplies it as ChatOptions.ConversationId, which the middleware reads from FunctionInvocationContext.Options. Packages has the full ten-package dependency graph. Authoring Write Tools and Authoring Read Tools cover the tool-authoring patterns this guide assumes. The patterns themselves are backend-neutral — [AffiantWriteTool], the ToolEnvelope return contract, ContextExtractor, IWriteExecutor and WriteProposal — but their worked code is the Semantic Kernel shape: [KernelFunction], plugin registration and AffiantStartupValidator exist only on the SK backend. On MAF, take the tool discovery and wiring from this page and the authoring patterns from those.