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.
What MAF is
Section titled “What MAF is”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.
The adapter’s shape
Section titled “The adapter’s shape”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 inAffiant.Core:AddAffiantCore()registersTaskInferenceStepitself, and the backend’s own DI registersTaskInferenceRunnerand the same two filters,InferenceTriggerFilterandTaskInferenceMergeFilter— on MAF both from the singleAddAffiantAgentFramework()call, on SK split acrossAddAffiantInferenceOrchestration()(the runner and the trigger filter) andAddAffiantSemanticKernel()(the merge filter). Both bridges register the two completion-stage filters through one sharedAffiant.Corehelper, which is what makes the merge-before-review order on the onion’s unwind identical on either backend. Each bridge does ship its ownIInferenceCompletionPort, though:AgentFrameworkInferenceCompletionPortbuilds the structured-output prompt itself and issues it on the host’sIChatClientwith 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 wrappedAIAgentrather 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, soAffiantToolCatalog.FromType<T>()reflects over your tool type’s public instance methods in one pass — skipping generic method definitions, members declared onobject, and special-name members (property accessors, event add/remove) — producing both theAIFunctions 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 callableAIFunctiontoo; and give each method its own name, because an overload makesFromType<T>()throw rather than register two descriptors under one identity. A tool’s LLM-visible name is not the C# method name but the oneAIFunctionFactory.Createderives 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 trailingAsyncstripped when the method returnsTask,Task<T>,ValueTask,ValueTask<T>orIAsyncEnumerable<T>— soTask<string> FetchThingAsync()is toolFetchThing, while a synchronousstring LookupThingAsync()keeps its literal name (“One naming rule worth knowing” below has the full statement, and the descriptor takes itsFunctionNamefrom 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 oneToolNamesconstant 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 theAffiant.Extensions.AIbridge works with, not a MAF-specific one — and it carries noResultmember 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’sTerminateflag, 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.
A wiring example
Section titled “A wiring example”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 typebuilder.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 chainThe turn scope
Section titled “The turn scope”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.
The hosted-tool boundary
Section titled “The hosted-tool boundary”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 anAIFunction,WithAffiantthrowsAffiantCoverageException(as of1.0.0-beta.3, via the framework’s ownToolCoverage.Refuse— previously a plainInvalidOperationException), naming every uncovered tool. An agent whoseChatOptions.Toolsis 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)), andChatClientAgentis the only concreteAIAgentinMicrosoft.Agents.AI1.13.0 that exposesChatOptionsitself — a delegating agent, including the wrapped oneWithAffiantreturns, forwardsGetServiceto the agent it wraps. When the probe returnsnullthe tool set cannot be enumerated, soWithAffiantrefuses with the sameAffiantCoverageExceptionand emits acoverage.refusedevent naming the agent’s type with the category spelled"unauditable-agent"— not one of the threeCoverageCategorymembers, because what cannot be covered here is an agent rather than a named tool.AgentFrameworkOptions.AllowUnauditableAgent = truedowngrades it to a logged warning and a telemetry span, with nocoverage.refusedevent and nothing recorded againstToolCoverage. -
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.
Migrating an SK host to MAF
Section titled “Migrating an SK host to MAF”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.
Where this fits
Section titled “Where this fits”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.