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 00000000000..69a84736299
--- /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 ships in the release solution filter as an **alpha** package (maintainer decision on the PR) — the version suffix follows the maturity of the `ResponsibleAI.AgentHooks` dependency it is built on, and the whole surface stays `[Experimental]`; 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 3ba3f3b13b5..4a472fc8050 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -108,6 +108,7 @@
+
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index 9442f98cb18..380ebba2067 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/agent-framework-release.slnf b/dotnet/agent-framework-release.slnf
index 01f099e7819..8d31405597f 100644
--- a/dotnet/agent-framework-release.slnf
+++ b/dotnet/agent-framework-release.slnf
@@ -4,6 +4,7 @@
"projects": [
"src\\Microsoft.Agents.AI.A2A\\Microsoft.Agents.AI.A2A.csproj",
"src\\Microsoft.Agents.AI.Abstractions\\Microsoft.Agents.AI.Abstractions.csproj",
+ "src\\Microsoft.Agents.AI.AgentHooks\\Microsoft.Agents.AI.AgentHooks.csproj",
"src\\Microsoft.Agents.AI.Anthropic\\Microsoft.Agents.AI.Anthropic.csproj",
"src\\Microsoft.Agents.AI.GitHub.Copilot\\Microsoft.Agents.AI.GitHub.Copilot.csproj",
"src\\Microsoft.Agents.AI.Harness\\Microsoft.Agents.AI.Harness.csproj",
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 00000000000..3337f35926e
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksChatClientExtensions.cs
@@ -0,0 +1,266 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Linq;
+using AgentHooks;
+using Microsoft.Extensions.AI;
+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.
+///
+///
+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));
+ }
+
+ // Diagnostics logger, resolved the same way the agent resolves its own.
+ configuration.Logger =
+ ((services?.GetService(typeof(Extensions.Logging.ILoggerFactory)) as Extensions.Logging.ILoggerFactory)
+ ?? chatClient.GetService()
+ ?? Extensions.Logging.Abstractions.NullLoggerFactory.Instance)
+ .CreateLogger("Microsoft.Agents.AI.AgentHooks");
+
+ var options = agentOptions?.Clone() ?? new ChatClientAgentOptions();
+ if (options.UseProvidedChatClientAsIs)
+ {
+ // UseProvidedChatClientAsIs signals a fully custom, do-not-touch client
+ // stack — which is incompatible with this factory by definition: it always
+ // decorates the supplied client with the enforcement's chat seam and relies
+ // on the agent's default pipeline placing the function-invocation loop above
+ // that seam. Honoring the flag would silently change where (and whether) the
+ // seams sit, so it is rejected loudly instead.
+ throw new ArgumentException(
+ $"{nameof(ChatClientAgentOptions.UseProvidedChatClientAsIs)} is not supported with the agent-hooks " +
+ "factory: the factory always decorates the supplied chat client with the enforcement's chat seam " +
+ "and relies on the agent's default pipeline above it. Supply the raw chat client and let the " +
+ "factory compose the stack.",
+ nameof(agentOptions));
+ }
+
+ 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/AgentHooksOptions.cs b/dotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksOptions.cs
new file mode 100644
index 00000000000..390527921a0
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AgentHooks/AgentHooksOptions.cs
@@ -0,0 +1,70 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using AgentHooks;
+using Microsoft.Extensions.AI;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.AgentHooks;
+
+///
+/// Options controlling the AGENT-HOOKS-0.1 enforcement installed by
+/// .
+///
+public sealed class AgentHooksOptions
+{
+ private readonly List> _interceptors = [];
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The agent-hooks interceptors to register. At least one interceptor is required
+ /// (an emitter with zero interceptors fails closed on every emission).
+ /// or one of its elements is .
+ public AgentHooksOptions(params IEnumerable interceptors)
+ {
+ _ = Throw.IfNull(interceptors);
+
+ // Materialized exactly once: the sequence may be single-enumeration.
+ foreach (var interceptor in interceptors)
+ {
+ _ = this.AddInterceptor(interceptor);
+ }
+ }
+
+ /// Register an interceptor, optionally with a payload-free name recorded on the records' verdict summaries.
+ /// The interceptor to register.
+ /// An optional registration name.
+ /// This options instance.
+ public AgentHooksOptions AddInterceptor(IInterceptor interceptor, string? name = null)
+ {
+ _ = Throw.IfNull(interceptor);
+ this._interceptors.Add(new KeyValuePair(name, interceptor));
+ return this;
+ }
+
+ /// Gets the registered interceptors, in registration order.
+ public IReadOnlyList> Interceptors => this._interceptors;
+
+ /// Gets or sets the optional approval resolver consulted for liftable denies.
+ public IApprovalResolver? Resolver { get; set; }
+
+ /// Gets or sets whether verdicts are enforced (default) or recorded without acting.
+ public EnforcementMode Mode { get; set; } = EnforcementMode.Enforce;
+
+ /// Gets or sets the composition profile and knobs; uses the SDK default
+ /// (sequential/first_deny, on_approval: stop).
+ public CompositionConfig? Composition { get; set; }
+
+ /// Gets or sets the identity provider; uses the SDK default
+ /// (jcs-sha256). Use for identity-unbound records.
+ public IdentityProvider? IdentityProvider { get; set; }
+
+ /// Gets or sets the per-interceptor/resolver timeout; uses the
+ /// spec-recommended 5 seconds.
+ public TimeSpan? Timeout { get; set; }
+
+ /// Gets or sets an optional callback receiving every interception record.
+ public Action? RecordSink { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AgentHooks/Codecs/AgentHooksWriteBackException.cs b/dotnet/src/Microsoft.Agents.AI.AgentHooks/Codecs/AgentHooksWriteBackException.cs
new file mode 100644
index 00000000000..f9ebef2c183
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AgentHooks/Codecs/AgentHooksWriteBackException.cs
@@ -0,0 +1,29 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+
+namespace Microsoft.Agents.AI.AgentHooks;
+
+///
+/// A transform verdict could not be converted back into the native framework value.
+///
+///
+/// Thrown (and deliberately never caught by this package) so an unappliable transform
+/// fails the run closed instead of silently proceeding with the untransformed value.
+///
+internal sealed class AgentHooksWriteBackException : InvalidOperationException
+{
+ public AgentHooksWriteBackException()
+ {
+ }
+
+ public AgentHooksWriteBackException(string message)
+ : base(message)
+ {
+ }
+
+ public AgentHooksWriteBackException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AgentHooks/Codecs/InputCodec.cs b/dotnet/src/Microsoft.Agents.AI.AgentHooks/Codecs/InputCodec.cs
new file mode 100644
index 00000000000..5ee5a3df068
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AgentHooks/Codecs/InputCodec.cs
@@ -0,0 +1,100 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json.Nodes;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.AgentHooks;
+
+/// input: the run's input messages <-> the spec's input payload.
+internal static class InputCodec
+{
+ /// Project one input message with the spec's input role mapping.
+ public static JsonObject MessageToWire(ChatMessage message) => new()
+ {
+ ["role"] = Wire.InputRole(message.Role),
+ ["content"] = Wire.ContentsToWire(message.Contents),
+ };
+
+ ///
+ /// Project run input per the spec's input payload schema: a single plain-text
+ /// message projects as its content string (so string-matching perimeter guards fire);
+ /// multi-message or rich input projects as a list of per-message objects.
+ ///
+ ///
+ /// The content and role are returned alongside the payload object so callers never
+ /// have to re-read them out of the payload by property name — the projection is the
+ /// single producer, and this shape makes the "both fields always exist" invariant
+ /// hold by construction rather than by lookup.
+ ///
+ public static (JsonObject Payload, JsonNode? Content, string Role) ToWire(IReadOnlyList messages)
+ {
+ JsonNode? content;
+ string role;
+ if (messages.Count == 1)
+ {
+ content = Wire.ContentsToWire(messages[0].Contents);
+ role = Wire.InputRole(messages[0].Role);
+ }
+ else
+ {
+ var parts = new JsonArray();
+ foreach (var message in messages)
+ {
+ parts.Add(MessageToWire(message));
+ }
+
+ content = parts;
+ role = "user";
+ }
+
+ return (new JsonObject { ["content"] = content, ["role"] = role }, content, role);
+ }
+
+ /// Write a transformed input target back into the run's message list.
+ public static void WriteBack(List messages, JsonObject before, JsonNode? after)
+ {
+ if (after is null || Wire.WireEquals(after, before))
+ {
+ return;
+ }
+
+ if (after is not JsonObject afterObject)
+ {
+ throw new AgentHooksWriteBackException("agent-hooks input transform must produce an input object target.");
+ }
+
+ var afterRole = afterObject["role"];
+ if (!Wire.WireEquals(afterRole, before["role"]))
+ {
+ // The role field is per-message only for single-message input; for
+ // multi-message input the top-level role is synthetic and a transform
+ // against it is ambiguous.
+ if (messages.Count != 1 || (afterRole as JsonValue)?.TryGetValue(out string? newRole) is not true)
+ {
+ throw new AgentHooksWriteBackException(
+ "agent-hooks input transform changed the input role in a way that cannot be written back.");
+ }
+
+ messages[0].Role = new ChatRole(newRole!);
+ }
+
+ var afterContent = afterObject["content"];
+ if (Wire.WireEquals(afterContent, before["content"]))
+ {
+ return;
+ }
+
+ if (messages.Count == 1 && !Wire.LooksLikeMessageObjects(afterContent))
+ {
+ messages[0].Contents = Wire.WireToContents(afterContent, "input");
+ return;
+ }
+
+ List beforeList = [.. messages.Select(MessageToWire)];
+ var rebuilt = Wire.WriteBackMessageList([.. messages], beforeList, afterContent, "input");
+ messages.Clear();
+ messages.AddRange(rebuilt);
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AgentHooks/Codecs/ModelRequestCodec.cs b/dotnet/src/Microsoft.Agents.AI.AgentHooks/Codecs/ModelRequestCodec.cs
new file mode 100644
index 00000000000..586694c71ad
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AgentHooks/Codecs/ModelRequestCodec.cs
@@ -0,0 +1,26 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json.Nodes;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.AgentHooks;
+
+/// pre_model_call: the outgoing request messages <-> the spec's messages list.
+internal static class ModelRequestCodec
+{
+ public static JsonArray ToWire(IReadOnlyList messages) => Wire.MessagesToWire(messages);
+
+ /// Return the transformed message list, or when the target is untouched.
+ public static List? WriteBack(IReadOnlyList messages, JsonArray before, JsonNode? after)
+ {
+ if (Wire.WireEquals(after, before))
+ {
+ return null;
+ }
+
+ List beforeList = [.. before.Cast()];
+ return Wire.WriteBackMessageList(messages, beforeList, after, "pre_model_call");
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AgentHooks/Codecs/ModelResponseCodec.cs b/dotnet/src/Microsoft.Agents.AI.AgentHooks/Codecs/ModelResponseCodec.cs
new file mode 100644
index 00000000000..fdfec95aca2
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AgentHooks/Codecs/ModelResponseCodec.cs
@@ -0,0 +1,307 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json.Nodes;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.AgentHooks;
+
+///
+/// post_model_call: the assembled chat response <-> the spec's response payload.
+///
+///
+/// Host-executed tool calls ride tool_calls (they drive the function seam);
+/// service-executed (informational-only) tool calls are part of the model response itself
+/// and are surfaced in content so hosted tool activity is interceptable here even
+/// though the function seam never sees it.
+///
+internal static class ModelResponseCodec
+{
+ private static bool IsHostExecutedCall(AIContent content) =>
+ content is FunctionCallContent { InformationalOnly: false };
+
+ /// Project the response content (everything except host-executed tool calls).
+ public static JsonNode? ContentToWire(IList messages)
+ {
+ var parts = new JsonArray();
+ foreach (var message in messages)
+ {
+ List visible = [.. message.Contents.Where(content => !IsHostExecutedCall(content))];
+ if (visible.Count == 0)
+ {
+ continue;
+ }
+
+ parts.Add(new JsonObject
+ {
+ ["role"] = Wire.RoleString(message.Role),
+ ["content"] = Wire.ContentsToWire(visible),
+ });
+ }
+
+ if (parts.Count == 0)
+ {
+ return null;
+ }
+
+ if (parts.Count == 1 && parts[0]!["content"] is JsonValue value && value.TryGetValue(out string? text))
+ {
+ return JsonValue.Create(text);
+ }
+
+ return parts;
+ }
+
+ /// Project the host-executed tool calls (the ones the function seam will bracket).
+ public static JsonArray ToolCallsToWire(IList messages)
+ {
+ var calls = new JsonArray();
+ foreach (var message in messages)
+ {
+ foreach (var content in message.Contents)
+ {
+ if (content is FunctionCallContent { InformationalOnly: false } call)
+ {
+ calls.Add(new JsonObject
+ {
+ ["id"] = call.CallId ?? string.Empty,
+ ["name"] = call.Name ?? string.Empty,
+ ["args"] = Wire.ArgumentsToWire(call.Arguments),
+ });
+ }
+ }
+ }
+
+ return calls;
+ }
+
+ public static JsonObject ToWire(ChatResponse response) => new()
+ {
+ ["content"] = ContentToWire(response.Messages),
+ ["tool_calls"] = ToolCallsToWire(response.Messages),
+ ["finish_reason"] = Wire.FinishReasonString(response.FinishReason),
+ };
+
+ /// Write a transformed post_model_call target back into the chat response. Returns whether it changed.
+ public static bool WriteBack(ChatResponse response, JsonObject before, JsonNode? after)
+ {
+ if (after is null || Wire.WireEquals(after, before))
+ {
+ return false;
+ }
+
+ if (after is not JsonObject afterObject)
+ {
+ throw new AgentHooksWriteBackException("agent-hooks post_model_call transform must produce a response object.");
+ }
+
+ bool changed = false;
+ var afterFinish = afterObject["finish_reason"];
+ if (!Wire.WireEquals(afterFinish, before["finish_reason"]))
+ {
+ if ((afterFinish as JsonValue)?.TryGetValue(out string? finish) is not true)
+ {
+ throw new AgentHooksWriteBackException("agent-hooks post_model_call transform must keep finish_reason a string.");
+ }
+
+ response.FinishReason = new ChatFinishReason(finish!);
+ changed = true;
+ }
+
+ var afterCalls = afterObject["tool_calls"];
+ if (!Wire.WireEquals(afterCalls, before["tool_calls"]))
+ {
+ changed |= WriteBackToolCalls(response, afterCalls);
+ }
+
+ var afterContent = afterObject["content"];
+ if (!Wire.WireEquals(afterContent, before["content"]))
+ {
+ WriteBackContent(response, afterContent);
+ changed = true;
+ }
+
+ return changed;
+ }
+
+ /// Reconcile transformed tool_calls with the response's function-call contents.
+ private static bool WriteBackToolCalls(ChatResponse response, JsonNode? afterCalls)
+ {
+ if (afterCalls is not JsonArray callsArray)
+ {
+ throw new AgentHooksWriteBackException("agent-hooks post_model_call transform must keep tool_calls a list.");
+ }
+
+ // Validate the complete shape up front, before any reconciliation: every call —
+ // kept or added — must carry a non-empty string id, a non-empty string name and
+ // object-valued args, and ids must be unique (duplicates would silently collapse
+ // during reconciliation). An invalid shape fails closed rather than becoming a
+ // malformed native call.
+ List<(string Id, string Name, JsonObject Args)> wireCalls = [];
+ Dictionary callsById = [];
+ foreach (var item in callsArray)
+ {
+ if (item is not JsonObject callObject)
+ {
+ throw new AgentHooksWriteBackException("agent-hooks post_model_call transform produced a tool call that is not an object.");
+ }
+
+ if ((callObject["id"] as JsonValue)?.TryGetValue(out string? id) is not true || string.IsNullOrEmpty(id))
+ {
+ throw new AgentHooksWriteBackException(
+ "agent-hooks post_model_call transform must give each tool call a non-empty string id.");
+ }
+
+ if ((callObject["name"] as JsonValue)?.TryGetValue(out string? name) is not true || string.IsNullOrEmpty(name))
+ {
+ throw new AgentHooksWriteBackException(
+ "agent-hooks post_model_call transform must keep each tool call's name a non-empty string.");
+ }
+
+ if (callObject["args"] is not JsonObject args)
+ {
+ throw new AgentHooksWriteBackException(
+ "agent-hooks post_model_call transform must keep each tool call's args an object.");
+ }
+
+ if (!callsById.TryAdd(id!, (name!, args)))
+ {
+ throw new AgentHooksWriteBackException(
+ "agent-hooks post_model_call transform produced two tool calls with the same id.");
+ }
+
+ wireCalls.Add((id!, name!, args));
+ }
+
+ HashSet consumed = [];
+ bool changed = false;
+ foreach (var message in response.Messages)
+ {
+ List kept = [];
+ foreach (var content in message.Contents)
+ {
+ if (content is not FunctionCallContent { InformationalOnly: false } call)
+ {
+ kept.Add(content);
+ continue;
+ }
+
+ if (!callsById.TryGetValue(call.CallId ?? string.Empty, out var wire))
+ {
+ changed = true; // the transform dropped this tool call
+ continue;
+ }
+
+ consumed.Add(call.CallId ?? string.Empty);
+ if (wire.Name != call.Name || !Wire.WireEquals(Wire.ArgumentsToWire(call.Arguments), wire.Args))
+ {
+ kept.Add(new FunctionCallContent(call.CallId ?? string.Empty, wire.Name, WireArgsToNative(wire.Args)));
+ changed = true;
+ }
+ else
+ {
+ kept.Add(content);
+ }
+ }
+
+ if (kept.Count != message.Contents.Count || !kept.SequenceEqual(message.Contents))
+ {
+ message.Contents = kept;
+ }
+ }
+
+ List added = [];
+ foreach (var (id, name, args) in wireCalls)
+ {
+ if (!consumed.Contains(id))
+ {
+ added.Add(new FunctionCallContent(id, name, WireArgsToNative(args)));
+ changed = true;
+ }
+ }
+
+ if (added.Count > 0)
+ {
+ var target = response.Messages.LastOrDefault(m => Wire.RoleString(m.Role) == "assistant");
+ if (target is not null)
+ {
+ target.Contents = [.. target.Contents, .. added];
+ }
+ else
+ {
+ response.Messages.Add(new ChatMessage(ChatRole.Assistant, added));
+ }
+ }
+
+ return changed;
+ }
+
+ private static Dictionary WireArgsToNative(JsonObject wireArgs)
+ {
+ Dictionary native = [];
+ foreach (var (key, value) in wireArgs)
+ {
+ native[key] = value?.DeepClone();
+ }
+
+ return native;
+ }
+
+ /// Rebuild the response's visible content from a transformed response.content value, preserving host-executed tool calls.
+ private static void WriteBackContent(ChatResponse response, JsonNode? afterContent)
+ {
+ List calls = [.. response.Messages
+ .SelectMany(message => message.Contents)
+ .Where(IsHostExecutedCall)];
+
+ List baseMessages;
+ if (afterContent is null)
+ {
+ baseMessages = [];
+ }
+ else if (afterContent is JsonValue value && value.TryGetValue(out string? text))
+ {
+ baseMessages = [new ChatMessage(ChatRole.Assistant, text)];
+ }
+ else if (afterContent is JsonArray array)
+ {
+ baseMessages = [];
+ foreach (var item in array)
+ {
+ if (item is not JsonObject wireMessage || !wireMessage.ContainsKey("content"))
+ {
+ throw new AgentHooksWriteBackException("agent-hooks post_model_call transform produced content without role/content.");
+ }
+
+ string role = (wireMessage["role"] as JsonValue)?.GetValue() ?? "assistant";
+ baseMessages.Add(new ChatMessage(new ChatRole(role), Wire.WireToContents(wireMessage["content"], "post_model_call")));
+ }
+ }
+ else
+ {
+ throw new AgentHooksWriteBackException("agent-hooks post_model_call transform produced unsupported content.");
+ }
+
+ if (calls.Count > 0)
+ {
+ if (baseMessages.Count > 0 && Wire.RoleString(baseMessages[^1].Role) == "assistant")
+ {
+ baseMessages[^1].Contents = [.. baseMessages[^1].Contents, .. calls];
+ }
+ else
+ {
+ baseMessages.Add(new ChatMessage(ChatRole.Assistant, calls));
+ }
+ }
+
+ // Mutate the response's message list in place: deferred persistence callbacks and
+ // outer layers hold references to this list, and must observe the transformed content.
+ response.Messages.Clear();
+ foreach (var message in baseMessages)
+ {
+ response.Messages.Add(message);
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AgentHooks/Codecs/OutputCodec.cs b/dotnet/src/Microsoft.Agents.AI.AgentHooks/Codecs/OutputCodec.cs
new file mode 100644
index 00000000000..6c26ba2531e
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AgentHooks/Codecs/OutputCodec.cs
@@ -0,0 +1,83 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json.Nodes;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.AgentHooks;
+
+/// output: the final agent response <-> the spec's output payload.
+internal static class OutputCodec
+{
+ /// Project the run output: a single plain-text message as a string, else per-message objects.
+ public static JsonNode? ToWire(AgentResponse response)
+ {
+ var parts = Wire.MessagesToWire(response.Messages);
+ if (parts.Count == 1 && parts[0]!["content"] is JsonValue value && value.TryGetValue(out string? text))
+ {
+ return JsonValue.Create(text);
+ }
+
+ return parts;
+ }
+
+ /// Write a transformed output target back into the agent response. Returns whether it changed.
+ ///
+ /// Mutations happen in place (message contents and the response's message list) so
+ /// that persistence deferred behind the run gate — which holds references to the same
+ /// message objects — observes the transformed content, never the pre-transform value.
+ ///
+ public static bool WriteBack(AgentResponse response, JsonNode? beforeContent, JsonNode? after)
+ {
+ if (after is null)
+ {
+ return false;
+ }
+
+ if (after is not JsonObject afterObject)
+ {
+ throw new AgentHooksWriteBackException("agent-hooks output transform must produce an output object target.");
+ }
+
+ var afterContent = afterObject["content"];
+ if (Wire.WireEquals(afterContent, beforeContent))
+ {
+ return false;
+ }
+
+ List originals = [.. response.Messages];
+ if (afterContent is JsonValue value && value.TryGetValue(out string? text))
+ {
+ if (originals.Count == 1)
+ {
+ originals[0].Contents = Wire.WireToContents(afterContent, "output");
+ }
+ else
+ {
+ ReplaceMessages(response, [new ChatMessage(ChatRole.Assistant, text)]);
+ }
+
+ return true;
+ }
+
+ if (afterContent is null)
+ {
+ ReplaceMessages(response, []);
+ return true;
+ }
+
+ List beforeList = [.. originals.Select(Wire.MessageToWire)];
+ ReplaceMessages(response, Wire.WriteBackMessageList(originals, beforeList, afterContent, "output"));
+ return true;
+ }
+
+ private static void ReplaceMessages(AgentResponse response, List messages)
+ {
+ response.Messages.Clear();
+ foreach (var message in messages)
+ {
+ response.Messages.Add(message);
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AgentHooks/Codecs/ToolArgumentsCodec.cs b/dotnet/src/Microsoft.Agents.AI.AgentHooks/Codecs/ToolArgumentsCodec.cs
new file mode 100644
index 00000000000..066b6769305
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AgentHooks/Codecs/ToolArgumentsCodec.cs
@@ -0,0 +1,56 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Text.Json.Nodes;
+
+namespace Microsoft.Agents.AI.AgentHooks;
+
+/// pre_tool_call: the native tool arguments <-> the spec's args object.
+internal static class ToolArgumentsCodec
+{
+ public static JsonObject ToWire(IDictionary? arguments) => Wire.ArgumentsToWire(arguments);
+
+ ///
+ /// Merge a transformed args target back onto the native arguments.
+ ///
+ ///
+ /// Returns the effective wire args, and sets to the merged
+ /// native arguments (or when untouched). Only the keys the
+ /// transform actually changed (or added/removed) are taken from the wire value;
+ /// untouched keys keep their original native values, so non-JSON-native argument
+ /// values survive a transform that did not touch them.
+ ///
+ public static JsonObject WriteBack(
+ IDictionary arguments, JsonObject before, JsonNode? after, out Dictionary? merged)
+ {
+ if (after is not JsonObject effective)
+ {
+ throw new AgentHooksWriteBackException("agent-hooks pre_tool_call transform must produce an arguments object.");
+ }
+
+ if (Wire.WireEquals(effective, before))
+ {
+ merged = null;
+ return effective;
+ }
+
+ merged = [];
+ foreach (var (key, value) in arguments)
+ {
+ if (effective.ContainsKey(key))
+ {
+ merged[key] = value;
+ }
+ }
+
+ foreach (var (key, value) in effective)
+ {
+ if (!before.ContainsKey(key) || !Wire.WireEquals(before[key], value))
+ {
+ merged[key] = value?.DeepClone();
+ }
+ }
+
+ return effective;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AgentHooks/Codecs/ToolResultCodec.cs b/dotnet/src/Microsoft.Agents.AI.AgentHooks/Codecs/ToolResultCodec.cs
new file mode 100644
index 00000000000..424a3d3195c
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AgentHooks/Codecs/ToolResultCodec.cs
@@ -0,0 +1,81 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.AgentHooks;
+
+/// post_tool_call: the native tool result <-> the spec's result value.
+internal static class ToolResultCodec
+{
+ ///
+ /// Project a tool result faithfully, unwrapping framework content containers: text
+ /// content projects as its text, function-result content projects as its canonical
+ /// result value, and any other content projects as its full content object.
+ ///
+ public static JsonNode? ToWire(object? value)
+ {
+ switch (value)
+ {
+ case null:
+ return null;
+ case string s:
+ return JsonValue.Create(s);
+ case TextContent text:
+ return JsonValue.Create(text.Text ?? string.Empty);
+ case FunctionResultContent { Result: not null } result:
+ return ToWire(result.Result);
+ case AIContent content:
+ return JsonSerializer.SerializeToNode(content, typeof(AIContent), Wire.JsonOptions);
+ case IList { Count: 1 } single:
+ // The canonical single-content result projects as the content's value
+ // itself, matching what the model sees.
+ return ToWire(single[0]);
+ case IEnumerable contents:
+ var array = new JsonArray();
+ foreach (var item in contents)
+ {
+ array.Add(ToWire(item));
+ }
+
+ return array;
+ default:
+ return Wire.ValueToWire(value);
+ }
+ }
+
+ ///
+ /// Convert a transformed post_tool_call value back into the native result
+ /// shape. A wire value the interceptors left untouched maps back to the untouched
+ /// native result; text-content wrappers are preserved when shape-compatible;
+ /// otherwise the transformed wire value becomes the result as-is (the function
+ /// invocation layer serializes JSON values faithfully).
+ ///
+ public static object? WriteBack(object? original, JsonNode? before, JsonNode? after)
+ {
+ if (Wire.WireEquals(after, before))
+ {
+ return original;
+ }
+
+ if (original is string && after is JsonValue afterString && afterString.TryGetValue(out string? text))
+ {
+ return text;
+ }
+
+ if (original is TextContent && after is JsonValue afterText && afterText.TryGetValue(out string? content))
+ {
+ return new TextContent(content);
+ }
+
+ if (original is IList { Count: 1 } single && single[0] is TextContent &&
+ after is JsonValue afterValue && afterValue.TryGetValue(out string? singleText))
+ {
+ return new List { new TextContent(singleText) };
+ }
+
+ return after?.DeepClone();
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AgentHooks/Codecs/Wire.cs b/dotnet/src/Microsoft.Agents.AI.AgentHooks/Codecs/Wire.cs
new file mode 100644
index 00000000000..0d0d1693274
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AgentHooks/Codecs/Wire.cs
@@ -0,0 +1,290 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.AgentHooks;
+
+///
+/// Wire building blocks shared by the per-point codecs (framework values <-> AGENT-HOOKS wire JSON).
+///
+///
+/// One codec per interception point owns both directions of the wire conversion:
+/// ToWire projects the native framework value into the spec's payload, and
+/// WriteBack converts the (possibly transformed) wire target back into the native
+/// value. Every WriteBack implements the same rule exactly once per point: a wire
+/// value the interceptors left untouched maps back to the untouched native value — only
+/// genuine transforms modify native state, and an untranslatable transform throws
+/// (fail closed) rather than being dropped.
+///
+internal static class Wire
+{
+ /// The serializer options used for all content projections.
+ public static JsonSerializerOptions JsonOptions { get; } = AIJsonUtilities.DefaultOptions;
+
+ public static bool WireEquals(JsonNode? left, JsonNode? right) => JsonNode.DeepEquals(left, right);
+
+ public static string RoleString(ChatRole? role)
+ {
+ var value = role?.Value;
+ return string.IsNullOrEmpty(value) ? "user" : value!;
+ }
+
+ /// Map a framework role onto the spec's input role enum (user | system | external).
+ public static string InputRole(ChatRole? role)
+ {
+ var value = RoleString(role);
+ return value is "user" or "system" ? value : "external";
+ }
+
+ public static string FinishReasonString(ChatFinishReason? finishReason)
+ {
+ var value = finishReason?.Value;
+ return string.IsNullOrEmpty(value) ? "stop" : value!;
+ }
+
+ /// Project message contents faithfully: plain text as a string, rich content as content objects.
+ public static JsonNode? ContentsToWire(IList contents)
+ {
+ if (contents.Count == 1 && contents[0] is TextContent text)
+ {
+ return JsonValue.Create(text.Text ?? string.Empty);
+ }
+
+ var array = new JsonArray();
+ foreach (var content in contents)
+ {
+ array.Add(JsonSerializer.SerializeToNode(content, typeof(AIContent), JsonOptions));
+ }
+
+ return array;
+ }
+
+ public static JsonObject MessageToWire(ChatMessage message) => new()
+ {
+ ["role"] = RoleString(message.Role),
+ ["content"] = ContentsToWire(message.Contents),
+ };
+
+ public static JsonArray MessagesToWire(IEnumerable messages)
+ {
+ var array = new JsonArray();
+ foreach (var message in messages)
+ {
+ array.Add(MessageToWire(message));
+ }
+
+ return array;
+ }
+
+ /// Decode a transformed wire content value back into framework objects.
+ public static List WireToContents(JsonNode? value, string point)
+ {
+ if (value is null)
+ {
+ return [];
+ }
+
+ if (value is JsonValue jsonValue && jsonValue.TryGetValue(out string? s))
+ {
+ return [new TextContent(s)];
+ }
+
+ List items = value switch
+ {
+ JsonObject o => [o],
+ JsonArray a => [.. a],
+ _ => throw new AgentHooksWriteBackException($"agent-hooks {point} transform produced an unsupported content value type."),
+ };
+
+ List contents = [];
+ foreach (var item in items)
+ {
+ if (item is JsonValue itemValue && itemValue.TryGetValue(out string? itemText))
+ {
+ contents.Add(new TextContent(itemText));
+ continue;
+ }
+
+ if (item is JsonObject itemObject && itemObject.ContainsKey("$type"))
+ {
+ try
+ {
+ var content = JsonSerializer.Deserialize(itemObject, JsonOptions);
+ if (content is not null)
+ {
+ contents.Add(content);
+ continue;
+ }
+ }
+ catch (Exception exception) when (exception is JsonException or NotSupportedException)
+ {
+ throw new AgentHooksWriteBackException($"agent-hooks {point} transform produced an undecodable content item.");
+ }
+ }
+
+ throw new AgentHooksWriteBackException($"agent-hooks {point} transform produced an unsupported content item.");
+ }
+
+ return contents;
+ }
+
+ public static bool LooksLikeMessageObjects(JsonNode? value) =>
+ value is JsonArray array && array.Count > 0 &&
+ array.All(item => item is JsonObject o && o.ContainsKey("content"));
+
+ ///
+ /// Convert a transformed wire message list back into framework messages.
+ ///
+ ///
+ /// The transformed list is authoritative. Entries are matched to original messages by
+ /// projection identity rather than list position, so a removal or insertion in the
+ /// middle does not shift content onto the wrong original:
+ ///
+ /// An entry equal to an (unconsumed) original's projection reuses that
+ /// original untouched; originals skipped over were removed by the transform.
+ /// A changed entry mutates the next unconsumed original in place only when
+ /// that original's projection is not preserved later in the transformed list (i.e. it was
+ /// modified, not shifted) and its role is unchanged.
+ /// Anything else (insertions, role changes) becomes a new .
+ ///
+ ///
+ public static List WriteBackMessageList(
+ IReadOnlyList originals, IReadOnlyList before, JsonNode? after, string point)
+ {
+ if (after is not JsonArray afterArray)
+ {
+ throw new AgentHooksWriteBackException($"agent-hooks {point} transform must produce a list of messages.");
+ }
+
+ List afterItems = [];
+ foreach (var item in afterArray)
+ {
+ if (item is not JsonObject itemObject || !itemObject.ContainsKey("content"))
+ {
+ throw new AgentHooksWriteBackException($"agent-hooks {point} transform produced a message without role/content.");
+ }
+
+ afterItems.Add(itemObject);
+ }
+
+ List result = [];
+ int cursor = 0;
+ for (int index = 0; index < afterItems.Count; index++)
+ {
+ var item = afterItems[index];
+ int? matchIndex = null;
+ for (int position = cursor; position < originals.Count; position++)
+ {
+ if (WireEquals(before[position], item))
+ {
+ matchIndex = position;
+ break;
+ }
+ }
+
+ if (matchIndex is int match)
+ {
+ result.Add(originals[match]);
+ cursor = match + 1;
+ continue;
+ }
+
+ if (cursor < originals.Count)
+ {
+ var candidateProjection = before[cursor];
+ bool preservedLater = afterItems.Skip(index + 1).Any(later => WireEquals(later, candidateProjection));
+ string role = (item["role"] as JsonValue)?.GetValue() ?? "user";
+ if (!preservedLater && role == (candidateProjection["role"] as JsonValue)?.GetValue())
+ {
+ var message = originals[cursor];
+ cursor++;
+ message.Contents = WireToContents(item["content"], point);
+ result.Add(message);
+ continue;
+ }
+ }
+
+ string newRole = (item["role"] as JsonValue)?.GetValue() ?? "user";
+ result.Add(new ChatMessage(new ChatRole(newRole), WireToContents(item["content"], point)));
+ }
+
+ return result;
+ }
+
+ /// Project tool-call arguments as the spec's args object.
+ public static JsonObject ArgumentsToWire(IDictionary? arguments)
+ {
+ var result = new JsonObject();
+ if (arguments is not null)
+ {
+ foreach (var (key, value) in arguments)
+ {
+ result[key] = ValueToWire(value);
+ }
+ }
+
+ return result;
+ }
+
+ /// Project one runtime value into wire JSON, never throwing (repr fallback, matching the Python feature's make_json_safe).
+ public static JsonNode? ValueToWire(object? value)
+ {
+ if (value is null)
+ {
+ return null;
+ }
+
+ if (value is JsonNode node)
+ {
+ return node.DeepClone();
+ }
+
+ try
+ {
+ return JsonSerializer.SerializeToNode(value, value.GetType(), JsonOptions);
+ }
+ catch (Exception exception) when (exception is JsonException or NotSupportedException or InvalidOperationException)
+ {
+ return JsonValue.Create(value.ToString());
+ }
+ }
+
+ public static JsonObject? UsageToWire(UsageDetails? usage)
+ {
+ if (usage is null)
+ {
+ return null;
+ }
+
+ var result = new JsonObject();
+ if (usage.InputTokenCount is long input)
+ {
+ result["input_token_count"] = input;
+ }
+
+ if (usage.OutputTokenCount is long output)
+ {
+ result["output_token_count"] = output;
+ }
+
+ if (usage.TotalTokenCount is long total)
+ {
+ result["total_token_count"] = total;
+ }
+
+ if (usage.AdditionalCounts is not null)
+ {
+ foreach (var (key, count) in usage.AdditionalCounts)
+ {
+ result[key] = count;
+ }
+ }
+
+ return result.Count > 0 ? result : null;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AgentHooks/Core/AgentHooksAgent.cs b/dotnet/src/Microsoft.Agents.AI.AgentHooks/Core/AgentHooksAgent.cs
new file mode 100644
index 00000000000..a6bc2d5d8c3
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AgentHooks/Core/AgentHooksAgent.cs
@@ -0,0 +1,432 @@
+// 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 })
+ {
+ // Session-scoped: the host constructed the emitter/builder pair and owns
+ // the agent-hooks session boundaries — one session (one sequence, one
+ // record trail, one approval ledger) spans multiple agent runs, so this
+ // agent emits only the per-run points and never agent_startup /
+ // agent_shutdown (the host brackets the session itself).
+ return new AgentHooksRunState(configuration.Emitter, configuration.Builder, sessionScoped: true, configuration);
+ }
+
+ string agentId = this.Id ?? this.Name ?? "agent";
+ var builder = new AgentContextBuilder(
+ agentId,
+ framework: FrameworkName,
+ sessionId: 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);
+ }
+
+ // Not session-scoped: the default scoping is one agent-hooks session per run.
+ // The emitter and builder created above are fresh for this run — fresh session
+ // id, sequence numbering starting at zero, and an isolated record trail — so
+ // concurrent runs on this agent cannot interleave their emissions, and
+ // agent_startup / agent_shutdown bracket the run (emitted by this agent, see
+ // EmitRunStartAsync / EmitShutdownAsync, which skip them when session-scoped).
+ 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);
+ }
+
+ // The projection returns content and role directly (single producer, invariant
+ // by construction) so nothing here re-reads payload properties by name.
+ var before = InputCodec.ToWire(messages);
+ var outcome = await state.Emitter.EmitAsync(
+ state.Builder.Input(before.Content?.DeepClone(), before.Role), cancellationToken).ConfigureAwait(false);
+ InputCodec.WriteBack(messages, before.Payload, 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
+ // (including I/O-style faults from a record sink) must not mask the run's
+ // own outcome, which is already propagating — but it is logged so the
+ // incomplete session trail is trackable. Truly fatal exceptions
+ // (OutOfMemory; StackOverflow is uncatchable) are not swallowed.
+ state.Configuration.Logger.LogAgentShutdownEmissionFailed(reason, exception);
+ }
+ }
+
+ /// Project the registered tool names for agent_startup (spec tools_registered).
+ ///
+ /// This is deliberately the run-start snapshot: the tools declared on the agent and
+ /// on the run options when the run begins. Tools registered dynamically during the
+ /// run (for example by context providers during run preparation) cannot be known at
+ /// agent_startup time; they surface in each pre_model_call emission's
+ /// tools projection (the completed per-call set) and are bracketed by
+ /// pre_tool_call/post_tool_call like any other tool when invoked.
+ ///
+ 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 = this.HasUnwrappedProviderOverride(options.AdditionalProperties);
+ bool wrapChat = options is ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } chatProperties } &&
+ this.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 bool HasUnwrappedProviderOverride(AdditionalPropertiesDictionary? properties) =>
+ properties is not null &&
+ properties.TryGetValue(out ChatHistoryProvider? overrideProvider) &&
+ overrideProvider is not null &&
+ !this.IsOwnGatingWrapper(overrideProvider);
+
+ private void WrapProviderOverride(AdditionalPropertiesDictionary? properties)
+ {
+ if (properties is not null &&
+ properties.TryGetValue(out ChatHistoryProvider? overrideProvider) &&
+ overrideProvider is not null &&
+ !this.IsOwnGatingWrapper(overrideProvider))
+ {
+ bool perServiceCall = this.GetService()?.RequirePerServiceCallChatHistoryPersistence is true;
+ properties[typeof(ChatHistoryProvider).FullName!] =
+ new AgentHooksGatingChatHistoryProvider(overrideProvider, this._configuration, perServiceCall);
+ }
+ }
+
+ ///
+ /// Whether is a gating wrapper owned by this
+ /// installation. Ownership matters: a gating wrapper belonging to a different
+ /// agent-hooks installation runs inline under this run's state (its own gate is not
+ /// covering here), so it must be re-wrapped like any unguarded provider — skipping
+ /// it would let a denied run's history persist through the foreign wrapper.
+ ///
+ private bool IsOwnGatingWrapper(ChatHistoryProvider provider) =>
+ provider is AgentHooksGatingChatHistoryProvider wrapper &&
+ ReferenceEquals(wrapper.Configuration, this._configuration);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AgentHooks/Core/AgentHooksChatClient.cs b/dotnet/src/Microsoft.Agents.AI.AgentHooks/Core/AgentHooksChatClient.cs
new file mode 100644
index 00000000000..e7d8f48bb57
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AgentHooks/Core/AgentHooksChatClient.cs
@@ -0,0 +1,198 @@
+// 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, options, 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, options, 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;
+ }
+
+ ///
+ /// Project the per-call effective tool set into the spec's optional
+ /// pre_model_calltools field ({name, description?}).
+ ///
+ ///
+ /// This is the completed set for the call — including tools registered dynamically
+ /// by context providers during run preparation — whereas agent_startup's
+ /// tools_registered is the run-start snapshot.
+ ///
+ private static System.Text.Json.Nodes.JsonArray? ProjectTools(ChatOptions? options)
+ {
+ if (options?.Tools is not { Count: > 0 } tools)
+ {
+ return null;
+ }
+
+ var projected = new System.Text.Json.Nodes.JsonArray();
+ foreach (var tool in tools)
+ {
+ var entry = new System.Text.Json.Nodes.JsonObject { ["name"] = tool.Name };
+ if (!string.IsNullOrEmpty(tool.Description))
+ {
+ entry["description"] = tool.Description;
+ }
+
+ projected.Add((System.Text.Json.Nodes.JsonNode)entry);
+ }
+
+ return projected;
+ }
+
+ private string ResolveModelId(ChatOptions? options) =>
+ options?.ModelId
+ ?? this.GetService()?.DefaultModelId
+ ?? this.InnerClient.GetType().Name;
+
+ private async Task> EmitPreModelCallAsync(
+ AgentHooksRunState state, string modelId, IEnumerable messages, ChatOptions? options, 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, ProjectTools(options)), 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/Core/AgentHooksConfiguration.cs b/dotnet/src/Microsoft.Agents.AI.AgentHooks/Core/AgentHooksConfiguration.cs
new file mode 100644
index 00000000000..5380db00dfc
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AgentHooks/Core/AgentHooksConfiguration.cs
@@ -0,0 +1,43 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using AgentHooks;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Microsoft.Agents.AI.AgentHooks;
+
+///
+/// The configuration shared by all seams composed by one factory call.
+///
+///
+/// Reference identity of this object is the ownership token: the chat and tool seams
+/// only bind to an ambient run state created by their own factory call, so nesting two
+/// agent-hooks-enabled agents can never silently misroute emissions.
+///
+internal sealed class AgentHooksConfiguration
+{
+ public required IReadOnlyList> Interceptors { get; init; }
+
+ public IApprovalResolver? Resolver { get; init; }
+
+ public EnforcementMode Mode { get; init; } = EnforcementMode.Enforce;
+
+ public CompositionConfig? Composition { get; init; }
+
+ public IdentityProvider? IdentityProvider { get; init; }
+
+ public TimeSpan? Timeout { get; init; }
+
+ public Action? RecordSink { get; init; }
+
+ /// Host-owned session: when set, the middleware emits only the per-run points on this emitter.
+ public InterceptionEmitter? Emitter { get; init; }
+
+ /// Host-owned session: the builder matching .
+ public AgentContextBuilder? Builder { get; init; }
+
+ /// The logger for enforcement diagnostics (swallowed best-effort failures are tracked here).
+ public ILogger Logger { get; set; } = NullLogger.Instance;
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.AgentHooks/Core/AgentHooksFunctionMiddleware.cs b/dotnet/src/Microsoft.Agents.AI.AgentHooks/Core/AgentHooksFunctionMiddleware.cs
new file mode 100644
index 00000000000..1a9d2f19dae
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.AgentHooks/Core/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