diff --git a/docs/sdk/dotnet/abstractions/events.mdx b/docs/sdk/dotnet/abstractions/events.mdx
index 4c476fd0fd..2deae8da18 100644
--- a/docs/sdk/dotnet/abstractions/events.mdx
+++ b/docs/sdk/dotnet/abstractions/events.mdx
@@ -61,6 +61,11 @@ public abstract class BaseEvent
| `Timestamp` | `timestamp` | `long?` | Optional event timestamp |
| `RawEvent` | `rawEvent` | `JsonElement?` | Optional original event data |
+On the server, `AsAGUIEventStreamAsync` fills `RawEvent` in with the originating
+`ChatResponseUpdate` by default. Turn it off with
+[`AGUIStreamOptions.IncludeRawEvents`](/sdk/dotnet/hosting/extensibility#includerawevents)
+when no client reads the field.
+
## Lifecycle Events
Lifecycle events represent the run and step lifecycle.
diff --git a/docs/sdk/dotnet/hosting/extensibility.mdx b/docs/sdk/dotnet/hosting/extensibility.mdx
index 84ea276f1b..c94776a908 100644
--- a/docs/sdk/dotnet/hosting/extensibility.mdx
+++ b/docs/sdk/dotnet/hosting/extensibility.mdx
@@ -6,11 +6,13 @@ description: "Customize AG-UI stream conversion with AGUIStreamOptions"
# Extensibility
`AGUIStreamOptions` customizes how `ChatResponseUpdate` streams are converted
-to AG-UI events. It is fluent and method-only: create an instance, register
-`Map*` hooks, and pass it to `ToChatRequestContext`.
+to AG-UI events. Mapping is fluent and method-based: create an instance,
+register `Map*` hooks, and pass it to `ToChatRequestContext`. Plain toggles such
+as [`IncludeRawEvents`](#includerawevents) are init-only properties, so set them
+in an object initializer before chaining.
```csharp
-var streamOptions = new AGUIStreamOptions()
+var streamOptions = new AGUIStreamOptions { IncludeRawEvents = false }
.MapResultAsStateSnapshot("create_plan");
var ctx = input.ToChatRequestContext(jsonSerializerOptions, streamOptions);
@@ -40,6 +42,36 @@ maps:
`RawEvent` values.
+## IncludeRawEvents
+
+By default the converter serializes each `ChatResponseUpdate` and attaches it as
+the `rawEvent` field of every event derived from that update — the
+`TEXT_MESSAGE_*`, `TOOL_CALL_*`, and `REASONING_ENCRYPTED_VALUE` events. It lets
+a client inspect the provider payload behind an event, but it is not free: on a
+typical streaming response the `rawEvent` payloads are several times the size of
+the events that carry them, because a short text delta ships alongside a full
+serialized update.
+
+Set `IncludeRawEvents` to `false` when no client reads the field. This skips the
+serialization altogether, so you save the per-update CPU and allocations as well
+as the bytes.
+
+```csharp
+var streamOptions = new AGUIStreamOptions { IncludeRawEvents = false };
+
+var ctx = input.ToChatRequestContext(jsonSerializerOptions, streamOptions);
+```
+
+The event sequence is unchanged — only the `rawEvent` field is omitted. Events
+you supply yourself keep whatever `RawEvent` you set on them, whether they come
+from `ChatResponseUpdate.RawRepresentation` or from a `Map*` callback; the
+toggle only governs the payload the converter attaches on your behalf.
+
+
+ This applies to both transports. `rawEvent` rides the protobuf encoding as
+ well as SSE, so the saving is the same either way.
+
+
## MapContent
`MapContent(Func?> mapper)` receives an
diff --git a/sdks/dotnet/docs/architecture.md b/sdks/dotnet/docs/architecture.md
index ddd0b82ca7..274a441622 100644
--- a/sdks/dotnet/docs/architecture.md
+++ b/sdks/dotnet/docs/architecture.md
@@ -79,7 +79,9 @@ On the server, the negotiating `AGUIResults.Events` result picks the formatter f
### Tool mapping with AGUIStreamOptions
-`AGUIStreamOptions` is the configuration object passed to `ToChatRequestContext` and consumed by `AsAGUIEventStreamAsync`. It is method-only — no public getters or setters; everything is configured fluently.
+`AGUIStreamOptions` is the configuration object passed to `ToChatRequestContext` and consumed by `AsAGUIEventStreamAsync`. Mappings are configured fluently through the `Map*` methods; plain toggles are init-only properties set in an object initializer.
+
+One toggle exists today: **`IncludeRawEvents`** (default `true`) controls whether the converter serializes each `ChatResponseUpdate` and attaches it as the `rawEvent` field of the `TEXT_MESSAGE_*`, `TOOL_CALL_*`, and `REASONING_ENCRYPTED_VALUE` events derived from it. This attachment is a .NET-only behavior — neither the TypeScript nor the Python SDK populates `rawEvent` from the server side — and it is not cheap: on a typical streaming response the attached payloads outweigh the events carrying them several times over. Setting it to `false` skips the serialization entirely. Events supplied by the caller (via `RawRepresentation` or a `Map*` mapping) keep whatever `RawEvent` they were given either way.
The most common use case is state management. Suppose your agent has a `write_document` tool that writes a markdown document. You want the frontend to update a live preview as the document is written. Without tool mapping, you'd have to intercept the stream manually and inject `StateSnapshotEvent`s. With tool mapping:
diff --git a/sdks/dotnet/src/AGUI.Server/AGUIStreamOptions.cs b/sdks/dotnet/src/AGUI.Server/AGUIStreamOptions.cs
index 080366d6a7..314d55a8aa 100644
--- a/sdks/dotnet/src/AGUI.Server/AGUIStreamOptions.cs
+++ b/sdks/dotnet/src/AGUI.Server/AGUIStreamOptions.cs
@@ -10,9 +10,11 @@ namespace AGUI.Server;
/// Options for configuring how streams are converted to AG-UI event streams.
///
///
-/// Configuration is entirely method-based and fluent — there are no public setters. Construct an
-/// instance and call the Map* helpers to register mappings for tool calls, tool results,
-/// custom interrupts, and otherwise-unmapped instances.
+/// Mapping configuration is method-based and fluent — the Map* helpers register mappings for
+/// tool calls, tool results, custom interrupts, and otherwise-unmapped
+/// instances, and each returns the same instance so calls can be chained. Plain toggles such as
+/// are init-only properties, so combine the two with an object
+/// initializer: new AGUIStreamOptions { IncludeRawEvents = false }.MapResultAsStateSnapshot("x").
///
public sealed class AGUIStreamOptions
{
@@ -21,6 +23,26 @@ public sealed class AGUIStreamOptions
private List>? _interruptMappers;
private List?>>? _contentMappers;
+ ///
+ /// Gets a value indicating whether the originating is serialized
+ /// and attached as to the events derived from it. Defaults to
+ /// .
+ ///
+ ///
+ ///
+ /// The attachment applies to the TEXT_MESSAGE_*, TOOL_CALL_*, and
+ /// REASONING_ENCRYPTED_VALUE events produced from an update; events the SDK synthesizes on
+ /// its own (RUN_STARTED, RUN_FINISHED) and events supplied by a caller — whether via
+ /// or a registered mapping — are never touched.
+ ///
+ ///
+ /// Setting this to skips the serialization entirely, which removes both the
+ /// wire cost (the payload is typically several times the size of a small text delta) and the
+ /// per-update serialization work. Turn it off unless a client actually reads rawEvent.
+ ///
+ ///
+ public bool IncludeRawEvents { get; init; } = true;
+
///
/// Registers a fallback that maps an to an .
/// When the registered mapper returns a non-null value, the hosting layer emits a
diff --git a/sdks/dotnet/src/AGUI.Server/ChatResponseUpdateAGUIExtensions.cs b/sdks/dotnet/src/AGUI.Server/ChatResponseUpdateAGUIExtensions.cs
index 8a26ca1694..15ad7f80e0 100644
--- a/sdks/dotnet/src/AGUI.Server/ChatResponseUpdateAGUIExtensions.cs
+++ b/sdks/dotnet/src/AGUI.Server/ChatResponseUpdateAGUIExtensions.cs
@@ -196,8 +196,12 @@ private static async IAsyncEnumerable CoreAsync(
yield return RunStartedEvent.Create(threadId, runId, context.Input.ParentRunId);
}
- // Serialize the raw ChatResponseUpdate once for attaching to emitted events
- var raw = JsonSerializer.SerializeToElement(chatResponse, jsonSerializerOptions.GetTypeInfo(typeof(ChatResponseUpdate)));
+ // Serialize the raw ChatResponseUpdate once for attaching to emitted events.
+ // Skipped entirely when the caller opted out, so neither the serialization work nor the
+ // wire bytes are paid for.
+ JsonElement? raw = options.IncludeRawEvents
+ ? JsonSerializer.SerializeToElement(chatResponse, jsonSerializerOptions.GetTypeInfo(typeof(ChatResponseUpdate)))
+ : null;
string? effectiveMessageId = null;
foreach (var content in chatResponse.Contents)
diff --git a/sdks/dotnet/src/AGUI.Server/Internal/ReasoningMessageTracker.cs b/sdks/dotnet/src/AGUI.Server/Internal/ReasoningMessageTracker.cs
index 268161cf92..15e0675bbb 100644
--- a/sdks/dotnet/src/AGUI.Server/Internal/ReasoningMessageTracker.cs
+++ b/sdks/dotnet/src/AGUI.Server/Internal/ReasoningMessageTracker.cs
@@ -37,7 +37,7 @@ public BaseEvent EmitDelta(string delta) =>
Delta = delta
};
- public BaseEvent EmitEncryptedValue(string encryptedValue, JsonElement raw) =>
+ public BaseEvent EmitEncryptedValue(string encryptedValue, JsonElement? raw) =>
new ReasoningEncryptedValueEvent
{
Subtype = "message",
diff --git a/sdks/dotnet/src/AGUI.Server/PublicAPI.Unshipped.txt b/sdks/dotnet/src/AGUI.Server/PublicAPI.Unshipped.txt
index 583fa06017..5cacff7c74 100644
--- a/sdks/dotnet/src/AGUI.Server/PublicAPI.Unshipped.txt
+++ b/sdks/dotnet/src/AGUI.Server/PublicAPI.Unshipped.txt
@@ -5,6 +5,8 @@ AGUI.Server.ChatResponseUpdateAGUIExtensions
static AGUI.Server.ChatResponseUpdateAGUIExtensions.AsAGUIEventStreamAsync(this System.Collections.Generic.IAsyncEnumerable! updates, AGUI.Server.ChatRequestContext! context, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable!
AGUI.Server.AGUIStreamOptions
AGUI.Server.AGUIStreamOptions.AGUIStreamOptions() -> void
+AGUI.Server.AGUIStreamOptions.IncludeRawEvents.get -> bool
+AGUI.Server.AGUIStreamOptions.IncludeRawEvents.init -> void
AGUI.Server.AGUIStreamOptions.MapInterrupt(System.Func! mapper) -> AGUI.Server.AGUIStreamOptions!
AGUI.Server.AGUIStreamOptions.MapContent(System.Func?>! mapper) -> AGUI.Server.AGUIStreamOptions!
AGUI.Server.AGUIStreamOptions.MapCall(string! toolName, System.Func!>! mapper) -> AGUI.Server.AGUIStreamOptions!
diff --git a/sdks/dotnet/tests/AGUI.Hosting.AspNetCore.IntegrationTests/RawEventSuppressionIntegrationTest.cs b/sdks/dotnet/tests/AGUI.Hosting.AspNetCore.IntegrationTests/RawEventSuppressionIntegrationTest.cs
new file mode 100644
index 0000000000..4710cda47b
--- /dev/null
+++ b/sdks/dotnet/tests/AGUI.Hosting.AspNetCore.IntegrationTests/RawEventSuppressionIntegrationTest.cs
@@ -0,0 +1,77 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using System.Threading.Tasks;
+using AGUI.Abstractions;
+using AGUI.Samples.Shared;
+using AGUI.Server;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace AGUI.Server.IntegrationTests;
+
+///
+/// Verifies that reaches the wire: the field is
+/// serialized into the SSE body by default and absent from it when the option is turned off.
+///
+public sealed class RawEventSuppressionIntegrationTest
+{
+ private const string SseMediaType = "text/event-stream";
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task SseBody_ContainsRawEvent_OnlyWhenIncludeRawEventsIsTrue(bool includeRawEvents)
+ {
+ var body = await WriteSseBody(new AGUIStreamOptions { IncludeRawEvents = includeRawEvents });
+
+ Assert.Equal(includeRawEvents, body.Contains("\"rawEvent\""));
+
+ // The events themselves are on the wire either way — only the field differs.
+ Assert.Contains("TEXT_MESSAGE_CONTENT", body);
+ Assert.Contains("TOOL_CALL_ARGS", body);
+ }
+
+ [Fact]
+ public async Task SseBody_DefaultOptions_ContainRawEvent()
+ {
+ var body = await WriteSseBody(new AGUIStreamOptions());
+
+ Assert.Contains("\"rawEvent\"", body);
+ }
+
+ private static async Task WriteSseBody(AGUIStreamOptions streamOptions)
+ {
+ var input = new RunAgentInput { ThreadId = "thread-1", RunId = "run-1" };
+ var ctx = input.ToChatRequestContext(AIJsonUtilities.DefaultOptions, streamOptions);
+
+ var context = new DefaultHttpContext
+ {
+ RequestServices = new ServiceCollection().AddLogging().BuildServiceProvider(),
+ };
+ context.Response.Body = new MemoryStream();
+ context.Request.Headers.Accept = SseMediaType;
+
+ var result = AGUIResults.Events(Updates().AsAGUIEventStreamAsync(ctx), context);
+ await result.ExecuteAsync(context).ConfigureAwait(false);
+
+ Assert.Equal(SseMediaType, context.Response.ContentType);
+
+ return Encoding.UTF8.GetString(((MemoryStream)context.Response.Body).ToArray());
+ }
+
+ private static async IAsyncEnumerable Updates()
+ {
+ yield return new ChatResponseUpdate(ChatRole.Assistant, "Hello") { MessageId = "msg-1" };
+ yield return new ChatResponseUpdate
+ {
+ Role = ChatRole.Assistant,
+ MessageId = "msg-1",
+ Contents = [new FunctionCallContent("call-1", "search", new Dictionary { ["q"] = "x" })],
+ };
+
+ await Task.CompletedTask.ConfigureAwait(false);
+ }
+}
diff --git a/sdks/dotnet/tests/AGUI.Server.UnitTests/ChatResponseUpdateAGUIExtensionsTest.cs b/sdks/dotnet/tests/AGUI.Server.UnitTests/ChatResponseUpdateAGUIExtensionsTest.cs
index 0fef2be146..b815c540d6 100644
--- a/sdks/dotnet/tests/AGUI.Server.UnitTests/ChatResponseUpdateAGUIExtensionsTest.cs
+++ b/sdks/dotnet/tests/AGUI.Server.UnitTests/ChatResponseUpdateAGUIExtensionsTest.cs
@@ -1344,6 +1344,112 @@ public async Task NonRawRepresentation_Updates_HaveRawEventAttached()
Assert.NotNull(content.RawEvent);
}
+ [Fact]
+ public void IncludeRawEvents_DefaultsToTrue()
+ {
+ Assert.True(new AGUIStreamOptions().IncludeRawEvents);
+ }
+
+ [Fact]
+ public async Task IncludeRawEventsFalse_OmitsRawEventFromTextEvents()
+ {
+ var updates = ToAsyncEnumerable(
+ new ChatResponseUpdate(ChatRole.Assistant, "Hello") { MessageId = "msg-1" },
+ new ChatResponseUpdate(ChatRole.Assistant, " world") { MessageId = "msg-1" });
+
+ var events = await CollectEvents(updates, NoRawEvents());
+
+ Assert.NotEmpty(events.OfType());
+ Assert.All(events, e => Assert.Null(e.RawEvent));
+ }
+
+ [Fact]
+ public async Task IncludeRawEventsFalse_OmitsRawEventFromToolCallEvents()
+ {
+ var updates = ToAsyncEnumerable(
+ new ChatResponseUpdate
+ {
+ Role = ChatRole.Assistant,
+ Contents = [new FunctionCallContent("call-1", "search", new Dictionary { ["q"] = "x" })]
+ },
+ new ChatResponseUpdate
+ {
+ Role = ChatRole.Tool,
+ Contents = [new FunctionResultContent("call-1", "done")]
+ });
+
+ var events = await CollectEvents(updates, NoRawEvents());
+
+ Assert.Null(events.OfType().Single().RawEvent);
+ Assert.Null(events.OfType().Single().RawEvent);
+ Assert.Null(events.OfType().Single().RawEvent);
+ Assert.Null(events.OfType().Single().RawEvent);
+ }
+
+ [Fact]
+ public async Task IncludeRawEventsFalse_OmitsRawEventFromReasoningEncryptedValue()
+ {
+ var updates = ToAsyncEnumerable(
+ new ChatResponseUpdate
+ {
+ Role = ChatRole.Assistant,
+ Contents = [new TextReasoningContent("") { ProtectedData = "signed-blob" }]
+ });
+
+ var withRaw = await CollectEvents(updates, new AGUIStreamOptions());
+ Assert.NotNull(withRaw.OfType().Single().RawEvent);
+
+ var events = await CollectEvents(updates, NoRawEvents());
+ Assert.Null(events.OfType().Single().RawEvent);
+ }
+
+ [Fact]
+ public async Task IncludeRawEventsFalse_PreservesCallerSuppliedRawEvent()
+ {
+ var passthrough = new CustomEvent
+ {
+ Name = "mine",
+ Value = JsonDocument.Parse("""{"a":1}""").RootElement,
+ RawEvent = JsonDocument.Parse("""{"keep":"me"}""").RootElement,
+ };
+
+ var events = await CollectEvents(
+ ToAsyncEnumerable(new ChatResponseUpdate { RawRepresentation = passthrough }),
+ NoRawEvents());
+
+ var emitted = Assert.Single(events.OfType());
+ Assert.Equal("me", emitted.RawEvent!.Value.GetProperty("keep").GetString());
+ }
+
+ [Fact]
+ public async Task IncludeRawEventsFalse_ProducesIdenticalEventSequence()
+ {
+ static IAsyncEnumerable Updates() => ToAsyncEnumerable(
+ new ChatResponseUpdate(ChatRole.Assistant, "Hello") { MessageId = "msg-1" },
+ new ChatResponseUpdate
+ {
+ Role = ChatRole.Assistant,
+ MessageId = "msg-1",
+ Contents = [new FunctionCallContent("call-1", "search", new Dictionary { ["q"] = "x" })]
+ },
+ new ChatResponseUpdate
+ {
+ Role = ChatRole.Tool,
+ Contents = [new FunctionResultContent("call-1", "done")]
+ });
+
+ var withRaw = await CollectEvents(Updates(), new AGUIStreamOptions());
+ var withoutRaw = await CollectEvents(Updates(), NoRawEvents());
+
+ Assert.Equal(
+ withRaw.ConvertAll(e => e.Type),
+ withoutRaw.ConvertAll(e => e.Type));
+ Assert.Contains(withRaw, e => e.RawEvent is not null);
+ Assert.All(withoutRaw, e => Assert.Null(e.RawEvent));
+ }
+
+ private static AGUIStreamOptions NoRawEvents() => new() { IncludeRawEvents = false };
+
#endregion
#region Unicode and Special Characters (GAP-7)
@@ -1540,6 +1646,13 @@ private static async Task> CollectEvents(
options.MapContent(content => unmappedUpdateHandler(null!, content));
}
+ return await CollectEvents(updates, options).ConfigureAwait(false);
+ }
+
+ private static async Task> CollectEvents(
+ IAsyncEnumerable updates,
+ AGUIStreamOptions options)
+ {
var events = new List();
await foreach (var evt in updates.AsAGUIEventStreamAsync(BuildContext(options)).ConfigureAwait(false))
{