Add or modify a wire/protocol type in the AG-UI .NET SDK AGUI.Abstractions package — a new event, message, or content-part type, the AOT source-gen serializer context, or a polymorphic JSON converter, keeping it AOT-safe, JSON-wire-compatible with the TypeScript reference, and PublicAPI-clean. USE FOR: adding an AG-UI event type, adding a message role or input-content type, editing AGUIJsonSerializerContext, editing BaseEventJsonConverter / AGUIMessageJsonConverter / AGUIInputContentJsonConverter, fixing PublicAPI.Unshipped analyzer build failures on protocol types, wire-format round-trip serialization. DO NOT USE FOR: writing integration/SSE tests (use agui-dotnet-integration-tests), server hosting/endpoint code, or non-Abstractions packages.
73
90%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Add or change a serialized protocol type in sdks/dotnet/src/AGUI.Abstractions/. These types are the JSON wire format. The TypeScript SDK is the reference — every type must round-trip against its JSON. Add // Keep in sync with sdks/typescript/packages/core/src/{events,types}.ts to each new type.
The 5 source packages are AGUI.Abstractions, AGUI.Formatting, AGUI.Protobuf, AGUI.Client, AGUI.Server. Wire types live in AGUI.Abstractions only. (sdks/dotnet/AGENTS.md is the canonical reference for the serialization rules and conventions.)
Run all commands from sdks/dotnet/.
Every change to a polymorphic type touches four coordinated spots. Miss one and either the build fails or the type silently won't deserialize:
Events/, Messages/).AGUIEventTypes / AGUIRoles / AGUIInputContentTypes).Serialization/AGUIJsonSerializerContext.cs).Read and Write cases (BaseEventJsonConverter / AGUIMessageJsonConverter / AGUIInputContentJsonConverter).Plus: PublicAPI.Unshipped.txt (analyzer fails the build) and a round-trip test.
Events/{Name}Event.cs, sealed, deriving BaseEvent. Override Type => AGUIEventTypes.{Name}.Events/AGUIEventTypes.cs: public const string {Name} = "SCREAMING_SNAKE_CASE";.[JsonSerializable(typeof({Name}Event))] in AGUIJsonSerializerContext.BaseEventJsonConverter: add a Read switch arm (AGUIEventTypes.{Name} => jsonElement.Deserialize(options.GetTypeInfo(typeof({Name}Event))) as {Name}Event) and a Write case {Name}Event x: JsonSerializer.Serialize(writer, x, options.GetTypeInfo(typeof({Name}Event))); break;.src/AGUI.Abstractions/PublicAPI.Unshipped.txt (format below).tests/AGUI.Abstractions.UnitTests/{Name}EventTest.cs (JsonDocument assertions).[JsonPropertyName("type")]
public override string Type => AGUIEventTypes.RunStarted;
[JsonPropertyName("threadId")] // required: init to string.Empty
public string ThreadId { get; set; } = string.Empty;
[JsonPropertyName("parentRunId")] // optional: WhenWritingNull
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ParentRunId { get; set; }
[JsonPropertyName("value")] // dynamic payload: JsonElement, never object
public JsonElement? Value { get; set; }Same shape, but: folder Messages/, derive AGUIMessage, discriminator is role keyed on AGUIRoles constants (lowercase, e.g. "assistant"), and the converter is AGUIMessageJsonConverter (Read + Write switch arms). Sync against types.ts. Most roles serialize directly; AGUIUserMessage is hand-serialized because its content is string | array — follow the existing WriteUserMessage/DeserializeUserMessage pattern if a role needs union content.
Folder Messages/, derive AGUIInputContent, discriminator is type keyed on AGUIInputContentTypes constants (lowercase, e.g. "image"), converter is AGUIInputContentJsonConverter. Also add the same arm to AGUIMessageJsonConverter.DeserializeUserMessage's inner contentType switch, since user-message arrays inline-dispatch content parts.
[JsonSerializable(typeof(T))] for every new type — non-negotiable for AOT (no runtime reflection serializer).[JsonPropertyName("camelCase")] on every serialized property (explicit even though the context sets CamelCase).[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] on every optional/nullable property.string.Empty; collections init to [].AGUIJsonSerializerContext.Default.{Type} (or options.GetTypeInfo(typeof(T)) inside converters).JsonElement / JsonElement?.JsonSerializer.Serialize<object>(...), never pass raw strings through unparsed, never reflection-based serialization.Hand-written JsonConverter<TBase>, keyed on a discriminator property. BaseEventJsonConverter is the exemplar: Read deserializes to JsonElement, reads the discriminator, switches to the concrete GetTypeInfo(...), and throws JsonException on unknown/null; Write switches on the runtime type. Both directions must list every concrete type. A new abstract family needs the converter referenced via [JsonConverter(typeof(...))] on the base class.
The analyzer fails the build if a public member is missing here. Add one line per member, e.g. for an event:
AGUI.Abstractions.RunStartedEvent
AGUI.Abstractions.RunStartedEvent.RunStartedEvent() -> void
AGUI.Abstractions.RunStartedEvent.ThreadId.get -> string!
AGUI.Abstractions.RunStartedEvent.ThreadId.set -> void
override AGUI.Abstractions.RunStartedEvent.Type.get -> string!
AGUI.Abstractions.AGUIJsonSerializerContext.RunStartedEvent.get -> System.Text.Json.Serialization.Metadata.JsonTypeInfo<AGUI.Abstractions.RunStartedEvent>! = non-null reference, ? = nullable. Don't forget the source-gen context's generated JsonTypeInfo<T> getter line.
Assert JSON property names by parsing with JsonDocument — asserting the deserialized object alone misses camelCase/discriminator bugs. Don't compare full JSON strings; no reflection.
var json = JsonSerializer.Serialize(evt, AGUIJsonSerializerContext.Default.CustomEvent);
using var doc = JsonDocument.Parse(json);
Assert.Equal("CUSTOM", doc.RootElement.GetProperty("type").GetString());
Assert.Equal("user_preference_updated", doc.RootElement.GetProperty("name").GetString());Also test dispatch through the base: JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.BaseEvent) then Assert.IsType<CustomEvent>(evt). For wire-format drift, add a fixture under tests/AGUI.Abstractions.UnitTests/Compatibility/ produced by the TypeScript SDK and load it via FixtureLoader ({Category}CompatibilityTest naming).
sealed class (no record); one type per file named for the type.{Name}Event; event discriminators SCREAMING_SNAKE_CASE (member PascalCase); role/type/outcome discriminators are lowercase string constants in static classes — never enums.ArgumentNullException.ThrowIfNull(...) for public validation; no /// docs on internal/private.dotnet build
dotnet test tests/AGUI.Abstractions.UnitTests/34c3e0c
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.