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:
// ❌ 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:
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:
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
{
"properties": {
"plan": {
"type": "string",
"description": "The implementation plan in one sentence"
}
}
}
2. Mark Required Fields
{
"required": ["plan", "steps"]
}
3. Use Enums for Constrained Values
{
"estimated_complexity": {
"type": "string",
"enum": ["low", "medium", "high"]
}
}
Error Handling
Even with structured output, validation is recommended:
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
- Read Creating Your First PMCR-O Agent for complete implementation
- Explore the complete article library
Build Your Own Strange Loop
The PMCR-O framework is open. Star the repository. Fork it. Seed your own intent.