An Agent Framework JSON Schema can be too weak for the .NET type that receives the result, even when Microsoft Agent Framework successfully deserializes a typed AgentResponse<T>. The gap appears when a property is a value object handled by a custom JsonConverter: the converter enforces a string contract at runtime, but the generated schema can expose true for the property and {} for collection items.
This guide shows how to inspect the schema without calling a model, add converter-aware constraints with TransformSchemaNode, preserve that schema by using the non-generic RunAsync overload, and still obtain a typed result. The behavior and workaround were verified with .NET SDK 10.0.401, Microsoft.Agents.AI 1.24.0 and Microsoft.Extensions.AI 10.10.0/10.10.1.
Table of Contents
Recognize the Agent Framework JSON Schema mismatch
Start with a value object that is intentionally represented as a JSON string. The converter below rejects non-string tokens and applies the same length boundary that the model-facing schema should express.
[JsonConverter(typeof(IbanJsonConverter))]
public readonly struct Iban(string value)
{
public string Value { get; } = value;
public override string ToString() => Value;
}
public sealed class IbanJsonConverter : JsonConverter<Iban>
{
public override Iban Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.String)
throw new JsonException("An IBAN is a JSON string.");
string text = reader.GetString()!;
if (text.Length is < 15 or > 34)
throw new JsonException("An IBAN has 15 to 34 characters.");
return new Iban(text);
}
public override void Write(
Utf8JsonWriter writer,
Iban value,
JsonSerializerOptions options) =>
writer.WriteStringValue(value.Value);
}
public sealed record Order(
Iban Account,
int Quantity,
List<Iban> Alternates);
The converter tells System.Text.Json how to read and write the value; it does not automatically give Agent Framework an exact JSON Schema for the model. In the verified package set, generic RunAsync<Order> produced an unconstrained account schema and an empty item schema for alternates. Deserialization still returned the expected Iban, so a green typed-result test alone would not detect the contract drift.
This distinction matters because structured output has two enforcement points. The provider sees the schema before generating JSON; the application serializer validates or converts the returned JSON afterward. If the first contract is weaker than the second, the model can produce shapes your converter will reject. Treat the transmitted schema as a testable production artifact.
Build a converter-aware JSON schema
Use the same serializer options for schema creation and response deserialization. Agent Framework exposes AIJsonSchemaCreateOptions.TransformSchemaNode, which lets you replace the inferred node for a value object and its collection element.
JsonSerializerOptions serializerOptions =
AgentAbstractionsJsonUtilities.DefaultOptions;
var schemaOptions = new AIJsonSchemaCreateOptions
{
IncludeSchemaKeyword = true,
TransformSchemaNode = DescribeIban
};
JsonNode schema = AIJsonUtilities.CreateJsonSchema(
typeof(Order),
serializerOptions: serializerOptions,
inferenceOptions: schemaOptions);
ChatResponseFormatJson responseFormat =
ChatResponseFormat.ForJsonSchema(schema, "Order");
The transform should update both direct properties and array items. Keep this function close to the converter, and cover both with tests; fixing only the property leaves List<Iban> unconstrained.
static JsonNode DescribeIban(
AIJsonSchemaCreateContext context,
JsonNode schema)
{
if (context.TypeInfo.Type == typeof(Iban))
return Describe(schema as JsonObject ?? []);
if (context.TypeInfo.ElementType == typeof(Iban)
&& schema is JsonObject collection)
{
if (collection["items"] is JsonObject items)
Describe(items);
else
collection["items"] = Describe([]);
}
return schema;
static JsonObject Describe(JsonObject node)
{
node["type"] = "string";
node["minLength"] = 15;
node["maxLength"] = 34;
node["pattern"] =
"^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$";
return node;
}
}
The example pattern is deliberately a structural format check, not full IBAN validation. Perform checksum and business validation after deserialization. Do not promise a model that every string matching a regex is a valid account identifier.
Preserve the custom format and typed response
Passing responseFormat to generic RunAsync<Order> looks natural, but the verified 1.24.0 path generated its own format. A reference check at the fake IChatClient boundary confirmed that the caller-supplied instance was not the one received by the client.
AgentResponse<Order> result = await agent.RunAsync<Order>(
"Place an order.",
options: new AgentRunOptions
{
ResponseFormat = responseFormat
});
// In the verified 1.24.0 path, the client did not receive
// the responseFormat instance supplied above.
When your transformed schema is part of the contract, call the non-generic overload so the supplied format reaches the chat client. Then construct the typed response explicitly with the same serializer options used to build the schema.
AgentResponse raw = await agent.RunAsync(
"Place an order.",
options: new AgentRunOptions
{
ResponseFormat = responseFormat
});
var typed = new AgentResponse<Order>(
raw,
serializerOptions);
Order order = typed.Result;
This is a narrow workaround, not a recommendation to abandon typed APIs generally. Keep generic RunAsync<T> when its generated schema correctly describes T. Use the explicit path only when a converter, schema transform or provider contract makes schema ownership important.
Turn the probe into regression checks
You can test this boundary without credentials, token costs or a model. Implement an IChatClient that records ChatOptions.ResponseFormat and returns a fixed assistant message. The test then isolates Agent Framework orchestration from provider behavior.
public sealed class ScriptedChatClient(string answer)
: IChatClient
{
public ChatResponseFormatJson? LastFormat { get; private set; }
public Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
LastFormat = options?.ResponseFormat
as ChatResponseFormatJson;
return Task.FromResult(new ChatResponse(
new ChatMessage(ChatRole.Assistant, answer)));
}
public IAsyncEnumerable<ChatResponseUpdate>
GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public object? GetService(Type serviceType,
object? serviceKey = null) => null;
public void Dispose() { }
}
Add assertions for the actual schema JSON, not only the typed result:
account.typeisstring.account.minLength,maxLengthandpatternmatch the converter contract.alternates.itemshas the same constraints.- The non-generic call preserves the exact
ChatResponseFormatJsoninstance. new AgentResponse<Order>(raw, serializerOptions)returns the expected value object.
Keep one control assertion for the generic path while the upstream behavior remains relevant. When a future package changes it, the test should prompt a bounded re-evaluation rather than preserving the workaround forever. If you share agent clients or tools across instances, test those isolation boundaries separately; this site’s Agent Framework tool-isolation guide covers that different failure mode.
Know the limits before production
- Package scope: the reproduced behavior is for Microsoft.Agents.AI 1.24.0 with the resolved dependency versions listed above. Check release notes and rerun the probe before carrying the workaround into another version.
- Response shape: the shown format is straightforward for an object-shaped result such as
Order. For a top-level primitive or array, reproduce Agent Framework’s wrapper behavior andIsWrappedInObjectsemantics before parsing. - Provider support: a fake client proves which format Agent Framework transmits. It does not prove that every model or provider supports every JSON Schema keyword.
- Validation: a schema guides provider output, while the converter and domain layer remain authoritative. Validate checksum, country rules and business constraints after deserialization.
- Security: never treat model-produced identifiers as trusted input. Apply authorization and ownership checks after parsing.
The practical decision for an Agent Framework JSON Schema is simple: if the generic API’s generated schema matches your serialized contract, keep it. If a custom converter creates a mismatch, own the schema explicitly, verify what reaches IChatClient, use the non-generic execution path, and wrap the raw response back into AgentResponse<T>. That preserves both model-facing constraints and typed application code.
References
- Microsoft Learn: Structured output in Agent Framework
- Microsoft Agent Framework issue #9245: Custom JSON converters and generated schemas
- Microsoft Agent Framework issue #8339: Caller response format replacement
- Microsoft Learn: Write custom converters for JSON serialization
Found this useful? Support more practical developer content.