diff --git a/docs/decisions/0035-dotnet-agent-hooks-enforcement.md b/docs/decisions/0035-dotnet-agent-hooks-enforcement.md
new file mode 100644
index 0000000000..36894ce627
--- /dev/null
+++ b/docs/decisions/0035-dotnet-agent-hooks-enforcement.md
@@ -0,0 +1,42 @@
+---
+status: proposed
+contact: MohammadHaroonAbuomar
+date: 2026-08-07
+deciders: agent-framework .NET maintainers
+---
+
+# .NET agent-hooks enforcement: composed factory over three seams
+
+## Context and Problem Statement
+
+The [AGENT-HOOKS-0.1](https://github.com/responsibleai/agent-hooks) interception contract shipped for Python as a first-class experimental core feature (#7515): a middleware bundle emitting eight interception points with three-verdict, fail-closed enforcement, transform write-back, buffered streaming, and verdict-before-durability persistence gating. The .NET side needs the same semantics, but the .NET framework has no category-based middleware lists — interception is decorator composition (`DelegatingAIAgent`, Microsoft.Extensions.AI `DelegatingChatClient`, the function-invocation middleware seam). How should the contract's indivisibility and enforcement properties be realized in that model?
+
+## Decision Drivers
+
+- Identical enforcement semantics to the merged Python feature (same spec, same fail-closed rules), diverging only where the .NET seam model requires it — never by weakening an enforcement property.
+- Partial installation of the enforcement must be impossible or loudly rejected, not silently degraded.
+- Denied content must never become durable; transformed content must persist post-transform.
+- No changes to existing framework source; the optional native-runtime dependency (`ResponsibleAI.AgentHooks`) must not be referenced by core packages.
+
+## Decision Outcome
+
+**A single factory (`AsAIAgentWithAgentHooks`, per-run and host-owned-session overloads) in a new package `Microsoft.Agents.AI.AgentHooks` composes the full enforcement itself** instead of exposing middleware values:
+
+- **Seam order (fixed by construction):** `AgentHooksAgent` (agent seam: `agent_startup`/`input`/`output`/`agent_shutdown`, per-run `AsyncLocal` state, buffered streaming, persistence gate) → framework function-invocation middleware (`pre_tool_call`/`post_tool_call`) → `ChatClientAgent` with its default pipeline → `AgentHooksChatClient` **below** `FunctionInvokingChatClient` (so `pre_model_call`/`post_model_call` bracket every model service call of the tool loop individually).
+- **Indivisibility:** the seam decorators are `internal`; only the factory composes them. Two pipeline-replacement affordances of `ChatClientAgent` are rejected loudly (fail closed): a caller-supplied per-run `ChatClientFactory` (the framework's own function-middleware factory is recognized and allowed — it wraps, not replaces), and a supplied chat client that already contains a `FunctionInvokingChatClient` (it would execute tools below the verdicts).
+- **Verdict-before-durability:** end-of-run history and context-provider writes defer behind the `output` verdict via gating provider wrappers installed by the factory (dropped on deny, flushed post-transform with verdicted-message substitution for streamed runs). The implicit default `InMemoryChatHistoryProvider` is materialized and gated, with the history-conflict flags set to mimic implicit-default semantics. Per-service-call persistence sits above the chat seam, so it is covered by its own `post_model_call` verdict. Per-run provider overrides are wrapped in both `AdditionalProperties` dictionaries, copy-on-write. Nested agents persist inline at their own boundaries (they have their own providers) — no run-identity bookkeeping is needed, unlike Python.
+- **Fail-closed error behavior:** interceptor crashes/timeouts surface as `host_error:*` denies; enforcement-layer failures at the tool seam halt the run through `FunctionInvocationContext.Terminate` (the loop's only loud escape — thrown exceptions are converted to tool errors by the loop, which would fail open); wire projections run inside the guarded blocks; failure notifications to providers are redacted (empty request messages) once a deny/halt stands.
+- **Streaming:** fully buffered per the spec's `buffered_output` semantics — zero egress ahead of a verdict; transformed responses re-derive the released updates (preserving continuation tokens) so egress never diverges from verdicted content.
+
+### Considered Alternatives
+
+- **Port Python's middleware-value model (a `MiddlewareBundle` type):** rejected — .NET has no middleware list to put a bundle into; indivisibility via runtime validation is weaker than construction ownership.
+- **Core-framework persistence gate (as Python added in `_sessions.py`):** rejected — unnecessary in .NET; construction ownership of the provider instances gives the same property with zero core changes.
+- **Per-run `ChatClientFactory` as the chat-seam install point:** rejected — it wraps the whole pipeline above the function-invocation loop, so per-model-call points would be impossible.
+
+## Consequences
+
+- Good: zero existing-source changes; the optional native dependency is isolated in one leaf package; enforcement properties are structural rather than convention-based.
+- Accepted: the package is build-only (not in the release solution filter) pending a maturity decision on the alpha dependency; a sample follows once the API shape settles.
+- Known limitations (documented on the factory): hosted (service-executed) tools never reach the function seam and are intercepted via the `post_model_call` content projection; service-managed (conversation-id) history is durable at the service and ungateable; the deferred-OTel decorator sits above the chat seam, so sensitive-data request spans observe pre-transform content; a chat-seam projection failure fails the run closed but without a synthesized `host_error` record (SDK affordance gap, responsibleai/agent-hooks#70).
+- The trust model is the spec's: cooperative contract, not a security boundary — the misuse rejections catch accidental foot-guns loudly, not in-process adversaries.
diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index 3ba3f3b13b..5d926a069e 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -108,6 +108,8 @@
+
+
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index 9442f98cb1..380ebba206 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -604,6 +604,7 @@
+
@@ -641,6 +642,7 @@
+
diff --git a/dotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksAgent.cs b/dotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksAgent.cs
new file mode 100644
index 0000000000..a02177c4bc
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksAgent.cs
@@ -0,0 +1,395 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using AgentHooks;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.AgentHooks;
+
+///
+/// Run bracket: emits agent_startup, input, output and
+/// agent_shutdown, owns the per-run enforcement state shared with the chat and
+/// function seams, and releases the run's deferred durable persistence only after the
+/// output verdict permits the content.
+///
+///
+/// Streaming runs are fail-closed by buffering: the inner stream is fully consumed, the
+/// output verdict is applied to the assembled response, deferred persistence is
+/// flushed (or dropped on deny), and only then are the (possibly re-derived) updates
+/// released. A deny releases zero updates and surfaces
+/// when the stream is consumed.
+///
+internal sealed class AgentHooksAgent : DelegatingAIAgent
+{
+ private const string FrameworkName = "agent-framework";
+
+ private readonly AgentHooksConfiguration _configuration;
+
+ internal AgentHooksAgent(AIAgent innerAgent, AgentHooksConfiguration configuration)
+ : base(innerAgent)
+ {
+ this._configuration = configuration;
+ }
+
+ ///
+ protected override async Task RunCoreAsync(
+ IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ var state = this.CreateRunState();
+ var previous = AgentHooksRunState.Current;
+ AgentHooksRunState.Current = state;
+ string shutdownReason = "completed";
+ try
+ {
+ List messageList = [.. messages];
+ await this.EmitRunStartAsync(state, messageList, options, cancellationToken).ConfigureAwait(false);
+
+ var response = await this.InnerAgent.RunAsync(messageList, session, this.WrapRunOptions(options), cancellationToken).ConfigureAwait(false);
+
+ if (state.Halted is Exception halted)
+ {
+ // The enforcement layer itself failed mid-run: strand the deferred
+ // persistence (fail closed) and surface the halt to the caller.
+ state.Denied = true;
+ state.Gate.Drop();
+ throw halted;
+ }
+
+ _ = await EmitOutputAsync(state, response, cancellationToken).ConfigureAwait(false);
+
+ // The verdict permitted the content: release the persistence the run
+ // deferred behind the gate. A deny drops it instead, so denied content
+ // never becomes durable, and transformed content persists post-transform
+ // (the deferred persists substitute the verdicted messages).
+ state.VerdictedResponseMessages = response.Messages;
+ await state.Gate.FlushAsync(cancellationToken).ConfigureAwait(false);
+ return response;
+ }
+ catch (InterceptionBlockedException)
+ {
+ state.Denied = true;
+ state.Gate.Drop();
+ shutdownReason = "error";
+ throw;
+ }
+ catch (OperationCanceledException)
+ {
+ shutdownReason = "cancelled";
+ throw;
+ }
+ catch (Exception)
+ {
+ shutdownReason = "error";
+ throw;
+ }
+ finally
+ {
+ await this.EmitShutdownAsync(state, shutdownReason).ConfigureAwait(false);
+ AgentHooksRunState.Current = previous;
+ }
+ }
+
+ ///
+ protected override async IAsyncEnumerable RunCoreStreamingAsync(
+ IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ // All guarded work (including full consumption of the inner stream) happens in
+ // the helper, so a deny surfaces when the returned stream is consumed and zero
+ // updates egress ahead of the verdict.
+ var released = await this.RunStreamingGuardedAsync(messages, session, options, cancellationToken).ConfigureAwait(false);
+ foreach (var update in released)
+ {
+ yield return update;
+ }
+ }
+
+ private async Task> RunStreamingGuardedAsync(
+ IEnumerable messages, AgentSession? session, AgentRunOptions? options, CancellationToken cancellationToken)
+ {
+ var state = this.CreateRunState();
+ var previous = AgentHooksRunState.Current;
+ AgentHooksRunState.Current = state;
+ string shutdownReason = "completed";
+ try
+ {
+ List messageList = [.. messages];
+ await this.EmitRunStartAsync(state, messageList, options, cancellationToken).ConfigureAwait(false);
+
+ List buffered = [];
+ await foreach (var update in this.InnerAgent.RunStreamingAsync(messageList, session, this.WrapRunOptions(options), cancellationToken).ConfigureAwait(false))
+ {
+ buffered.Add(update);
+ }
+
+ if (state.Halted is Exception halted)
+ {
+ state.Denied = true;
+ state.Gate.Drop();
+ throw halted;
+ }
+
+ var response = buffered.ToAgentResponse();
+ bool transformed = await EmitOutputAsync(state, response, cancellationToken).ConfigureAwait(false);
+ state.VerdictedResponseMessages = response.Messages;
+ await state.Gate.FlushAsync(cancellationToken).ConfigureAwait(false);
+
+ // No-divergence rule: a transformed output re-derives the released updates
+ // from the verdicted response, so streamed egress can never diverge from the
+ // verdicted content.
+ return transformed ? RederiveUpdates(response) : buffered;
+ }
+ catch (InterceptionBlockedException)
+ {
+ state.Denied = true;
+ state.Gate.Drop();
+ shutdownReason = "error";
+ throw;
+ }
+ catch (OperationCanceledException)
+ {
+ shutdownReason = "cancelled";
+ throw;
+ }
+ catch (Exception)
+ {
+ shutdownReason = "error";
+ throw;
+ }
+ finally
+ {
+ await this.EmitShutdownAsync(state, shutdownReason).ConfigureAwait(false);
+ AgentHooksRunState.Current = previous;
+ }
+ }
+
+ ///
+ /// Re-derive stream updates from the (transformed) verdicted response, preserving the
+ /// response-level metadata that
+ /// does not project — currently the ,
+ /// without which a transformed streaming background response could not be resumed.
+ ///
+ internal static IReadOnlyList RederiveUpdates(AgentResponse response)
+ {
+ var updates = response.ToAgentResponseUpdates();
+ if (response.ContinuationToken is { } continuationToken)
+ {
+ if (updates.Length == 0)
+ {
+ updates = [new AgentResponseUpdate { AgentId = response.AgentId, ResponseId = response.ResponseId }];
+ }
+
+ updates[^1].ContinuationToken = continuationToken;
+ }
+
+ return updates;
+ }
+
+ private AgentHooksRunState CreateRunState()
+ {
+ var configuration = this._configuration;
+ if (configuration is { Emitter: not null, Builder: not null })
+ {
+ return new AgentHooksRunState(configuration.Emitter, configuration.Builder, sessionScoped: true, configuration);
+ }
+
+ string agentId = this.Id ?? this.Name ?? "agent";
+ var builder = new AgentContextBuilder(
+ agentId,
+ FrameworkName,
+ Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture),
+ agentName: this.Name);
+ var emitter = new InterceptionEmitter(configuration.Mode, configuration.Resolver, configuration.Timeout);
+ if (configuration.Composition is not null)
+ {
+ _ = emitter.SetComposition(configuration.Composition);
+ }
+
+ if (configuration.IdentityProvider is not null)
+ {
+ _ = emitter.SetIdentityProvider(configuration.IdentityProvider);
+ }
+
+ if (configuration.RecordSink is not null)
+ {
+ _ = emitter.SetRecordSink(configuration.RecordSink);
+ }
+
+ foreach (var (name, interceptor) in configuration.Interceptors)
+ {
+ _ = emitter.Register(interceptor, name);
+ }
+
+ return new AgentHooksRunState(emitter, builder, sessionScoped: false, configuration);
+ }
+
+ /// Emit agent_startup (per-run sessions) and input; apply input transforms.
+ private async Task EmitRunStartAsync(
+ AgentHooksRunState state, List messages, AgentRunOptions? options, CancellationToken cancellationToken)
+ {
+ if (!state.SessionScoped)
+ {
+ _ = await state.Emitter.EmitAsync(state.Builder.AgentStartup(this.ResolveToolNames(options)), cancellationToken).ConfigureAwait(false);
+ }
+
+ var before = InputCodec.ToWire(messages);
+ string role = (before["role"] as System.Text.Json.Nodes.JsonValue)?.GetValue() ?? "user";
+ var outcome = await state.Emitter.EmitAsync(
+ state.Builder.Input(before["content"]?.DeepClone(), role), cancellationToken).ConfigureAwait(false);
+ InputCodec.WriteBack(messages, before, outcome.Target);
+ }
+
+ /// Emit output over the assembled response; apply output transforms. Returns whether the response changed.
+ private static async Task EmitOutputAsync(AgentHooksRunState state, AgentResponse response, CancellationToken cancellationToken)
+ {
+ var before = OutputCodec.ToWire(response);
+ var outcome = await state.Emitter.EmitAsync(state.Builder.Output(before), cancellationToken).ConfigureAwait(false);
+ return OutputCodec.WriteBack(response, before, outcome.Target);
+ }
+
+ /// Best-effort agent_shutdown (per-run sessions only; blocks there are record-only).
+ private async Task EmitShutdownAsync(AgentHooksRunState state, string reason)
+ {
+ if (state.SessionScoped)
+ {
+ return;
+ }
+
+ try
+ {
+ _ = await state.Emitter.EmitUncheckedAsync(state.Builder.AgentShutdown(reason)).ConfigureAwait(false);
+ }
+ catch (Exception exception) when (exception is not OutOfMemoryException)
+ {
+ // agent_shutdown is a best-effort trail closure: a failure to emit it must
+ // not mask the run's own outcome (which is already propagating).
+ }
+ }
+
+ /// Project the registered tool names for agent_startup (spec tools_registered).
+ private List ResolveToolNames(AgentRunOptions? options)
+ {
+ List names = [];
+ if (this.GetService()?.Tools is { } agentTools)
+ {
+ names.AddRange(agentTools.Select(tool => tool.Name));
+ }
+
+ if (options is ChatClientAgentRunOptions { ChatOptions.Tools: { } runTools })
+ {
+ names.AddRange(runTools.Select(tool => tool.Name));
+ }
+
+ return names;
+ }
+
+ ///
+ /// Guard per-run options against enforcement bypasses.
+ ///
+ ///
+ ///
+ /// A per-run would replace the
+ /// guarded chat pipeline — and the tool-wrapping stage riding it — silently removing the chat and tool seams,
+ /// so it is rejected loudly (fail closed).
+ /// A override can ride either the base
+ /// (merged into the chat options with precedence by the
+ /// agent) or ; both would bypass the gating wrapper installed at
+ /// construction, so both are wrapped — on a clone, never mutating the caller's options.
+ ///
+ ///
+ private AgentRunOptions? WrapRunOptions(AgentRunOptions? options)
+ {
+ if (options is null)
+ {
+ return null;
+ }
+
+ if (options is ChatClientAgentRunOptions { ChatClientFactory: { } factory } && !IsFrameworkFunctionMiddlewareFactory(factory))
+ {
+ throw new InvalidOperationException(
+ $"A per-run {nameof(ChatClientAgentRunOptions.ChatClientFactory)} is not supported on an " +
+ "agent-hooks-guarded agent: it would replace the guarded chat pipeline (and the tool-wrapping " +
+ "stage riding it), silently removing the pre/post_model_call and pre/post_tool_call seams. " +
+ "Decorate the chat client supplied to the agent-hooks factory instead.");
+ }
+
+ bool wrapBase = HasUnwrappedProviderOverride(options.AdditionalProperties);
+ bool wrapChat = options is ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } chatProperties } &&
+ HasUnwrappedProviderOverride(chatProperties);
+ if (!wrapBase && !wrapChat && options is not ChatClientAgentRunOptions)
+ {
+ return options;
+ }
+
+ // Copy-on-write: Clone() deep-copies both dictionaries, so the caller's options
+ // are never mutated. Chat-typed options are ALWAYS cloned, even when nothing
+ // needs wrapping: the framework's function-invocation middleware (including this
+ // composition's own tool seam) chains its factory onto the options instance it
+ // receives in place, so forwarding the caller's instance would leak that factory
+ // into it — reusing the same options for a second run would then trip the
+ // rejection above, and concurrent reuse would race.
+ var cloned = options.Clone();
+ this.WrapProviderOverride(cloned.AdditionalProperties);
+ if (cloned is ChatClientAgentRunOptions clonedChatOptions)
+ {
+ this.WrapProviderOverride(clonedChatOptions.ChatOptions?.AdditionalProperties);
+ }
+
+ return cloned;
+ }
+
+ ///
+ /// Whether a per-run chat-client factory was installed by the framework's own
+ /// function-invocation middleware (an agent decorator composed outside this agent).
+ ///
+ ///
+ /// That factory wraps the pipeline it is given (it only rewrites the run's tools to
+ /// add the middleware bracket), so the enforcement seams below stay intact — outer
+ /// position is outer trust, exactly like any other decorator on the returned agent.
+ /// A caller-supplied factory chained through it is still rejected by walking the
+ /// chain. The type check is intentionally narrow: anything unrecognized stays
+ /// rejected (fail closed).
+ ///
+ private static bool IsFrameworkFunctionMiddlewareFactory(Func? factory)
+ {
+ while (factory is not null)
+ {
+ if (factory.Method.DeclaringType?.FullName?.StartsWith(
+ "Microsoft.Agents.AI.FunctionInvocationDelegatingAgent", StringComparison.Ordinal) is not true)
+ {
+ return false;
+ }
+
+ // The framework middleware chains any pre-existing factory into its closure;
+ // walk it so a caller-supplied factory cannot ride in unnoticed.
+ factory = factory.Target?.GetType()
+ .GetFields(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic)
+ .Where(field => field.FieldType == typeof(Func))
+ .Select(field => (Func?)field.GetValue(factory.Target))
+ .FirstOrDefault(value => value is not null);
+ }
+
+ return true;
+ }
+
+ private static bool HasUnwrappedProviderOverride(AdditionalPropertiesDictionary? properties) =>
+ properties is not null &&
+ properties.TryGetValue(out ChatHistoryProvider? overrideProvider) &&
+ overrideProvider is not null and not AgentHooksGatingChatHistoryProvider;
+
+ private void WrapProviderOverride(AdditionalPropertiesDictionary? properties)
+ {
+ if (properties is not null &&
+ properties.TryGetValue(out ChatHistoryProvider? overrideProvider) &&
+ overrideProvider is not null and not AgentHooksGatingChatHistoryProvider)
+ {
+ bool perServiceCall = this.GetService()?.RequirePerServiceCallChatHistoryPersistence is true;
+ properties[typeof(ChatHistoryProvider).FullName!] =
+ new AgentHooksGatingChatHistoryProvider(overrideProvider, this._configuration, perServiceCall);
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksChatClient.cs b/dotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksChatClient.cs
new file mode 100644
index 0000000000..278df67664
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksChatClient.cs
@@ -0,0 +1,166 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.AgentHooks;
+
+///
+/// Model bracket: emits pre_model_call and post_model_call around each
+/// individual model service call.
+///
+///
+///
+/// Installed by the agent-hooks factory directly on the supplied chat client, so it sits
+/// below in the agent's default pipeline and
+/// brackets every service call of the tool loop individually.
+///
+///
+/// Streaming is fail-closed by buffering (spec §12.1 buffered_output): the model
+/// stream is fully consumed internally, the post_model_call verdict is applied to
+/// the assembled response, and only then are the (possibly re-derived) updates released.
+/// No partial content ever egresses ahead of the verdict.
+///
+///
+/// Durability note: PerServiceCallChatHistoryPersistingChatClient sits above this
+/// decorator, so a permitted (and possibly transformed) response is what gets persisted,
+/// and a denied response throws before the persister ever sees it — verdict precedes
+/// durability by pipeline order at this seam.
+///
+///
+internal sealed class AgentHooksChatClient : DelegatingChatClient
+{
+ private readonly AgentHooksConfiguration _configuration;
+
+ internal AgentHooksChatClient(IChatClient innerClient, AgentHooksConfiguration configuration)
+ : base(innerClient)
+ {
+ this._configuration = configuration;
+ }
+
+ public override async Task GetResponseAsync(
+ IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ var state = this.RequireRunState();
+ string modelId = this.ResolveModelId(options);
+ var effectiveMessages = await this.EmitPreModelCallAsync(state, modelId, messages, cancellationToken).ConfigureAwait(false);
+
+ var response = await base.GetResponseAsync(effectiveMessages, options, cancellationToken).ConfigureAwait(false);
+
+ await EmitPostModelCallAsync(state, modelId, response, cancellationToken).ConfigureAwait(false);
+ return response;
+ }
+
+ public override async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var state = this.RequireRunState();
+ string modelId = this.ResolveModelId(options);
+ var effectiveMessages = await this.EmitPreModelCallAsync(state, modelId, messages, cancellationToken).ConfigureAwait(false);
+
+ // Spec §12.1: the complete response is assembled before post_model_call is
+ // emitted, and nothing (updates or tool calls) is released beforehand. A deny
+ // throws before any update egresses.
+ List buffered = [];
+ await foreach (var update in base.GetStreamingResponseAsync(effectiveMessages, options, cancellationToken).ConfigureAwait(false))
+ {
+ buffered.Add(update);
+ }
+
+ var response = buffered.ToChatResponse();
+ bool changed = await EmitPostModelCallAsync(state, modelId, response, cancellationToken).ConfigureAwait(false);
+
+ // No-divergence rule: a transformed response re-derives the released updates
+ // from the verdicted content; otherwise the buffered updates replay as-is.
+ foreach (var update in changed ? response.ToChatResponseUpdates() : (IEnumerable)buffered)
+ {
+ yield return update;
+ }
+ }
+
+ private AgentHooksRunState RequireRunState()
+ {
+ var state = AgentHooksRunState.Current
+ ?? throw new InvalidOperationException(
+ "The agent-hooks chat seam was invoked without an active agent-hooks run. The agent-hooks " +
+ "decorators must be installed as one unit by the agent-hooks factory; do not extract or reuse " +
+ "the inner chat client outside the agent it guards.");
+
+ if (!ReferenceEquals(state.Configuration, this._configuration))
+ {
+ throw new InvalidOperationException(
+ "The agent-hooks chat seam found an active agent-hooks run owned by a different agent-hooks " +
+ "installation. Nesting one agent-hooks-guarded agent's chat client inside another guarded agent " +
+ "is not supported: emissions would silently bind to the wrong emitter.");
+ }
+
+ return state;
+ }
+
+ private string ResolveModelId(ChatOptions? options) =>
+ options?.ModelId
+ ?? this.GetService()?.DefaultModelId
+ ?? this.InnerClient.GetType().Name;
+
+ private async Task> EmitPreModelCallAsync(
+ AgentHooksRunState state, string modelId, IEnumerable messages, CancellationToken cancellationToken)
+ {
+ List messageList = [.. messages];
+ try
+ {
+ // Projection and write-back run inside the guarded block: a failure there is
+ // an enforcement-layer failure, so this run's gated persistence is refused
+ // (fail closed) before the exception fails the run.
+ var before = ModelRequestCodec.ToWire(messageList);
+ var outcome = await state.Emitter.EmitAsync(state.Builder.PreModelCall(modelId, before), cancellationToken).ConfigureAwait(false);
+ return ModelRequestCodec.WriteBack(messageList, before, outcome.Target) ?? messageList;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception)
+ {
+ state.Denied = true;
+ throw;
+ }
+ }
+
+ /// Emit post_model_call over the assembled response; apply transforms. Returns whether the response changed.
+ private static async Task EmitPostModelCallAsync(
+ AgentHooksRunState state, string modelId, ChatResponse response, CancellationToken cancellationToken)
+ {
+ try
+ {
+ // Projection and write-back run inside the guarded block (see
+ // EmitPreModelCallAsync). §6.1 on deny: the denied response must not be
+ // incorporated; downstream persistence (the per-service-call persister sits
+ // above this seam) never runs, and any later gated persist for this run is
+ // refused via the denied flag.
+ var before = ModelResponseCodec.ToWire(response);
+ var outcome = await state.Emitter.EmitAsync(
+ state.Builder.PostModelCall(
+ response.ModelId ?? modelId,
+ before["content"]?.DeepClone(),
+ (System.Text.Json.Nodes.JsonArray)before["tool_calls"]!.DeepClone(),
+ Wire.FinishReasonString(response.FinishReason),
+ Wire.UsageToWire(response.Usage),
+ response.ResponseId),
+ cancellationToken).ConfigureAwait(false);
+ return ModelResponseCodec.WriteBack(response, before, outcome.Target);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception)
+ {
+ state.Denied = true;
+ throw;
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksChatClientExtensions.cs
new file mode 100644
index 0000000000..4a1dfef58a
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksChatClientExtensions.cs
@@ -0,0 +1,246 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using AgentHooks;
+using Microsoft.Extensions.AI;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.AgentHooks;
+
+///
+/// Factory for AGENT-HOOKS-0.1 enforced agents: composes a
+/// whose runs emit every applicable interception point of the agent-hooks control
+/// contract and enforce the combined verdicts fail-closed.
+///
+///
+///
+/// The enforcement is one coherent feature riding three seams that this factory installs
+/// as one indivisible unit (the seam decorators are internal, so a partial install is
+/// impossible by construction):
+///
+/// agent_startup / input / output / agent_shutdown at the agent seam,
+/// pre_model_call / post_model_call at the chat seam (below the function-invocation loop, so every model service call is bracketed individually),
+/// pre_tool_call / post_tool_call at the function-invocation seam.
+///
+///
+///
+/// Enforcement semantics (): every interception
+/// point is emitted before the guarded action runs (pre points) or before its result is
+/// incorporated (post points); emission failures inside the SDK synthesize
+/// host_error:* denies and are treated as blocks — the feature never fails open.
+/// Transform verdicts are written back into the native values (messages, arguments,
+/// results) so the framework executes exactly the value the interceptors approved, and
+/// rich (non-text) content is preserved as content objects, never flattened to text. A
+/// deny at input, pre_model_call, post_model_call or output
+/// terminates the run: propagates to the
+/// caller of
+/// (for streaming runs, when the stream is consumed, with zero updates released). A deny
+/// at the tool seam blocks the tool call and surfaces a tool-error payload to the model
+/// so the agent loop can continue; a host_error:* deny there additionally halts
+/// the run. Streaming is fail-closed by buffering: no partial content ever egresses
+/// ahead of a verdict.
+///
+///
+/// Durable history persistence is gated behind the verdicts: end-of-run history and
+/// context-provider writes are deferred until the output verdict permits the
+/// content (denied content never becomes durable; transformed content persists
+/// post-transform), and per-service-call history persistence sits above the chat seam,
+/// so it only ever observes responses its own post_model_call verdict permitted —
+/// a permitted per-service-call write remains durable even if the run's output is
+/// later denied. Nested and sibling agents (including sub-agents invoked as tools) have
+/// their own providers and persist inline at their own run boundaries. Residual
+/// limitation: history managed server-side by the model service (a conversation id) is
+/// durable at the service the moment the model call executes and cannot be gated by any
+/// framework layer.
+///
+///
+/// Known limitation — service-side (hosted) tool execution: tools executed by the model
+/// provider itself never pass through the function-invocation seam, so
+/// pre_tool_call / post_tool_call cannot intercept them. Their calls and
+/// outputs are surfaced faithfully in the post_model_call content projection,
+/// where interceptors can observe and deny/transform the response that carries them.
+///
+///
+/// Composition order: decorators applied to the returned agent run outside the
+/// enforcement boundary (outer position is outer trust — the final output point
+/// still guards whatever egresses); decorators applied to the supplied chat
+/// client run inside it, below the verdicts. Install exactly one enforcement per agent:
+/// nesting one guarded agent's seams inside another fails closed, a supplied client that
+/// already contains a function-invocation loop is rejected (it would execute tools below
+/// the verdicts), and per-run
+/// callbacks are rejected on guarded agents (they would replace the guarded pipeline).
+///
+///
+/// Observability note: the agent's built-in deferred-OpenTelemetry decorator sits above
+/// the enforcement's chat seam, so when sensitive-data telemetry is enabled its
+/// request-side spans capture the request content before any
+/// pre_model_call transform is applied (an observer channel inside the
+/// enforcement boundary, analogous to outer-position middleware in the Python feature).
+/// Response-side telemetry observes only verdicted content; a denied call surfaces as an
+/// error span with no response content.
+///
+///
+/// Session scoping: by default each agent run is one agent-hooks session (fresh emitter
+/// and sequence, agent_startup/agent_shutdown bracket the run). A host
+/// that owns a longer-lived session constructs its own
+/// and and uses the
+/// host-owned overload; the enforcement then emits only the per-run points and the host
+/// owns the session boundaries.
+///
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public static class AgentHooksChatClientExtensions
+{
+ ///
+ /// Creates an over with
+ /// AGENT-HOOKS-0.1 enforcement installed on every seam, one agent-hooks session per
+ /// run.
+ ///
+ /// The chat client the agent talks to. It is decorated by the enforcement's chat
+ /// seam before the agent's default pipeline is applied, so every model service call is bracketed.
+ /// The agent-hooks enforcement options; at least one interceptor is required.
+ /// Optional agent options; a copy is used, with any configured history and
+ /// context providers wrapped so durable writes obey verdict-before-durability.
+ /// Optional service provider passed through to the agent.
+ /// The enforced agent.
+ /// or is .
+ /// has no interceptors.
+ public static AIAgent AsAIAgentWithAgentHooks(
+ this IChatClient chatClient,
+ AgentHooksOptions hooksOptions,
+ ChatClientAgentOptions? agentOptions = null,
+ IServiceProvider? services = null)
+ {
+ _ = Throw.IfNull(chatClient);
+ _ = Throw.IfNull(hooksOptions);
+ if (hooksOptions.Interceptors.Count == 0)
+ {
+ throw new ArgumentException(
+ "agent-hooks enforcement requires at least one interceptor (an emitter with zero interceptors " +
+ "fails closed on every emission).",
+ nameof(hooksOptions));
+ }
+
+ var configuration = new AgentHooksConfiguration
+ {
+ Interceptors = [.. hooksOptions.Interceptors],
+ Resolver = hooksOptions.Resolver,
+ Mode = hooksOptions.Mode,
+ Composition = hooksOptions.Composition,
+ IdentityProvider = hooksOptions.IdentityProvider,
+ Timeout = hooksOptions.Timeout,
+ RecordSink = hooksOptions.RecordSink,
+ };
+
+ return Compose(chatClient, configuration, agentOptions, services);
+ }
+
+ ///
+ /// Creates an over with
+ /// AGENT-HOOKS-0.1 enforcement bound to a host-owned session: the fully configured
+ /// and matching are used for
+ /// every run, only the per-run points (input through output) are
+ /// emitted, and the host owns the agent_startup / agent_shutdown
+ /// session boundaries.
+ ///
+ /// The chat client the agent talks to.
+ /// The host-owned, fully configured .
+ /// The host-owned matching .
+ /// Optional agent options; a copy is used, with providers wrapped as in the per-run overload.
+ /// Optional service provider passed through to the agent.
+ /// The enforced agent.
+ /// , or is .
+ public static AIAgent AsAIAgentWithAgentHooks(
+ this IChatClient chatClient,
+ InterceptionEmitter emitter,
+ AgentContextBuilder builder,
+ ChatClientAgentOptions? agentOptions = null,
+ IServiceProvider? services = null)
+ {
+ _ = Throw.IfNull(chatClient);
+ _ = Throw.IfNull(emitter);
+ _ = Throw.IfNull(builder);
+
+ var configuration = new AgentHooksConfiguration
+ {
+ Interceptors = [],
+ Emitter = emitter,
+ Builder = builder,
+ };
+
+ return Compose(chatClient, configuration, agentOptions, services);
+ }
+
+ private static AgentHooksAgent Compose(
+ IChatClient chatClient, AgentHooksConfiguration configuration, ChatClientAgentOptions? agentOptions, IServiceProvider? services)
+ {
+ if (chatClient.GetService() is not null)
+ {
+ // A supplied client that already contains a function-invocation loop would
+ // sit BELOW the enforcement's chat seam, inverting the seam order: tools
+ // would execute before any post_model_call verdict could deny the
+ // tool-calling response, and the function seam would never see them.
+ throw new ArgumentException(
+ "The chat client supplied to the agent-hooks factory must not already contain a " +
+ $"{nameof(FunctionInvokingChatClient)}: it would execute tools below the enforcement's chat seam, " +
+ "before any post_model_call verdict and outside the tool seam. Supply the raw chat client instead — " +
+ "the agent installs its own function-invocation loop above the enforcement.",
+ nameof(chatClient));
+ }
+
+ var options = agentOptions?.Clone() ?? new ChatClientAgentOptions();
+ bool perServiceCallPersistence = options.RequirePerServiceCallChatHistoryPersistence;
+
+ // Durability gating: wrap the durable-write providers so end-of-run writes defer
+ // behind the output verdict. The wrappers belong to this composition only, so
+ // other agents sharing the same underlying providers are unaffected.
+ if (options.ChatHistoryProvider is null)
+ {
+ // With no provider configured, the agent creates a default
+ // InMemoryChatHistoryProvider internally — which this factory would never
+ // see, so denied output would become durable session history on the
+ // zero-config path. Materialize the default here and gate it. An explicitly
+ // configured provider changes the agent's conflict handling for
+ // service-managed history (it warns/throws by default), so the conflict
+ // flags are set to mimic the implicit default: silently disengage.
+ options.ChatHistoryProvider = new InMemoryChatHistoryProvider();
+ options.WarnOnChatHistoryProviderConflict = false;
+ options.ThrowOnChatHistoryProviderConflict = false;
+ options.ClearOnChatHistoryProviderConflict = true;
+ }
+
+ options.ChatHistoryProvider = new AgentHooksGatingChatHistoryProvider(
+ options.ChatHistoryProvider, configuration, perServiceCallPersistence);
+
+ if (options.AIContextProviders is not null)
+ {
+ options.AIContextProviders = options.AIContextProviders
+ .Select(AIContextProvider (provider) => new AgentHooksGatingAIContextProvider(provider, configuration, perServiceCallPersistence))
+ .ToList();
+ }
+
+ // Chat seam: decorate the supplied client so the agent's default pipeline
+ // (including the function-invocation loop) is built on top of it — every model
+ // service call is bracketed individually.
+ var guardedClient = new AgentHooksChatClient(chatClient, configuration);
+ var chatAgent = new ChatClientAgent(guardedClient, options, loggerFactory: null, services);
+
+ // Function seam: bracket every host-executed tool invocation. Skipped when the
+ // agent has no function-invocation loop (nothing executes tools framework-side;
+ // hosted tools surface at post_model_call).
+ AIAgent innerAgent = chatAgent;
+ if (chatAgent.GetService() is not null)
+ {
+ innerAgent = new AIAgentBuilder(chatAgent)
+ .Use(AgentHooksFunctionMiddleware.CreateCallback(configuration))
+ .Build();
+ }
+
+ // Agent seam, outermost: owns the per-run state, the run bracket and the
+ // persistence gate.
+ return new AgentHooksAgent(innerAgent, configuration);
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksFunctionMiddleware.cs b/dotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksFunctionMiddleware.cs
new file mode 100644
index 0000000000..1a9d2f19da
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksFunctionMiddleware.cs
@@ -0,0 +1,241 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Globalization;
+using System.Linq;
+using System.Text.Json.Nodes;
+using System.Threading;
+using System.Threading.Tasks;
+using AgentHooks;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.AgentHooks;
+
+///
+/// Tool bracket: emits pre_tool_call and post_tool_call around each
+/// host-executed function invocation.
+///
+///
+///
+/// Installed by the agent-hooks factory through the framework's function-invocation
+/// middleware seam (),
+/// which wraps every so this callback brackets the invocation.
+///
+///
+/// A policy deny blocks the tool call — the tool is not executed (or its result is
+/// discarded) and a tool-error payload is surfaced to the model so the agent loop can
+/// continue, per the spec's block-propagation rules. A host_error:* deny (the
+/// enforcement layer itself failed) additionally halts the run: the loop is terminated
+/// via — the only loud escape from the
+/// function-invocation loop, which converts thrown exceptions into tool errors and keeps
+/// running (fail open) — and the agent seam rethrows the failure at the run boundary.
+///
+///
+/// Approval flows pass through structurally: the framework requests tool approvals
+/// before ever invoking the wrapped function, so an unapproved tool never reaches this
+/// seam, and the approved replay enters through pre_tool_call when it actually
+/// executes.
+///
+///
+/// Known limitation (service-side tool execution): tools executed by the model provider
+/// itself never pass through the function-invocation seam, so pre_tool_call /
+/// post_tool_call cannot intercept them. Their calls and outputs are surfaced in
+/// the post_model_call content projection, where interceptors can observe and
+/// deny/transform the response that carries them.
+///
+///
+internal static class AgentHooksFunctionMiddleware
+{
+ internal static Func>, CancellationToken, ValueTask