Structured Output with JSON Schema

From fragile regex parsing to native type-safe LLM responses.

← Back to Articles

Traditional prompt engineering extracts JSON from text using regex or bracket counting—fragile, error-prone, and maintenance-heavy. Structured output tells the LLM to output only JSON, validated against a schema.

This guide shows you how to implement structured output in PMCR-O agents using Microsoft.Extensions.AI and Ollama. It assumes you have BIP fundamentals.

The Problem: Fragile Parsing

The old way used custom parsing logic:

C#
// ❌ DON'T DO THIS
var response = await _chatClient.CompleteChatAsync(history);
var jsonBlocks = ExtractJsonBlocksUsingBracketCounter(response.Content);
foreach (var block in jsonBlocks)
{
    try 
    { 
        var parsed = JsonDocument.Parse(block);
        // Hope this is the right one...
    }
    catch 
    { 
        // Try next block...
    }
}

Problems:

  • ~85% success rate (fails on edge cases)
  • 50-200ms parsing overhead
  • 200+ lines of fragile parsing code
  • Breaks when LLM output format changes

The Solution: Native Structured Output

Modern LLMs (including Ollama) support structured output via JSON schema. Tell the model to output only JSON:

C#
var chatOptions = new ChatOptions
{
    ResponseFormat = ChatResponseFormat.Json,  // ✅ Magic: output only JSON
    AdditionalProperties = new Dictionary<string, object?>
    {
        ["schema"] = JsonSerializer.Serialize(new
        {
            type = "object",
            properties = new
            {
                plan = new { type = "string" },
                steps = new
                {
                    type = "array",
                    items = new
                    {
                        type = "object",
                        properties = new
                        {
                            action = new { type = "string" },
                            rationale = new { type = "string" }
                        }
                    }
                }
            },
            required = new[] { "plan", "steps" }
        })
    }
};

var response = await _chatClient.CompleteChatAsync(history, chatOptions);
var plan = JsonSerializer.Deserialize<PlanResponse>(response.Content);  // ✅ Direct deserialization

Benefits

  • ~99% success rate: Schema-enforced validation
  • <1ms deserialization: No parsing overhead
  • 0 lines of parsing code: Native framework support
  • Type safety: Compile-time validation

Complete Example: Planner Agent

Here's a complete implementation:

C#
public class PlanResponse
{
    public string Plan { get; set; } = "";
    public List<PlanStep> Steps { get; set; } = new();
    public string EstimatedComplexity { get; set; } = "";
}

public class PlanStep
{
    public string Action { get; set; } = "";
    public string Rationale { get; set; } = "";
}

public override async Task<AgentResponse> ExecuteTask(
    AgentRequest request,
    ServerCallContext context)
{
    var chatOptions = new ChatOptions
    {
        Temperature = 0.7,
        ResponseFormat = ChatResponseFormat.Json,
        AdditionalProperties = new Dictionary<string, object?>
        {
            ["schema"] = JsonSerializer.Serialize(new
            {
                type = "object",
                properties = new
                {
                    plan = new { type = "string", description = "The implementation plan" },
                    steps = new
                    {
                        type = "array",
                        items = new
                        {
                            type = "object",
                            properties = new
                            {
                                action = new { type = "string" },
                                rationale = new { type = "string" }
                            },
                            required = new[] { "action", "rationale" }
                        }
                    },
                    estimated_complexity = new 
                    { 
                        type = "string", 
                        @enum = new[] { "low", "medium", "high" } 
                    }
                },
                required = new[] { "plan", "steps" }
            })
        }
    };

    var response = await _chatClient.CompleteChatAsync(history, chatOptions);
    
    // Direct deserialization - no parsing needed!
    var plan = JsonSerializer.Deserialize<PlanResponse>(response.Content);
    
    return new AgentResponse
    {
        Content = JsonSerializer.Serialize(plan),
        Success = plan != null
    };
}

Schema Design Best Practices

1. Use Descriptive Property Names

JSON
{
  "properties": {
    "plan": {
      "type": "string",
      "description": "The implementation plan in one sentence"
    }
  }
}

2. Mark Required Fields

JSON
{
  "required": ["plan", "steps"]
}

3. Use Enums for Constrained Values

JSON
{
  "estimated_complexity": {
    "type": "string",
    "enum": ["low", "medium", "high"]
  }
}

Error Handling

Even with structured output, validation is recommended:

C#
try
{
    var response = await _chatClient.CompleteChatAsync(history, chatOptions);
    var plan = JsonSerializer.Deserialize<PlanResponse>(response.Content);
    
    if (plan == null || string.IsNullOrEmpty(plan.Plan))
    {
        _logger.LogWarning("Planner returned invalid response");
        return new AgentResponse { Success = false, Content = "Invalid plan structure" };
    }
    
    return new AgentResponse { Success = true, Content = JsonSerializer.Serialize(plan) };
}
catch (JsonException ex)
{
    _logger.LogError(ex, "Failed to deserialize planner response");
    return new AgentResponse { Success = false, Content = "JSON parsing error" };
}

Next Steps

Build Your Own Strange Loop

The PMCR-O framework is open. Star the repository. Fork it. Seed your own intent.

View on GitHub →