StructuredChat 0.0.6
dotnet add package StructuredChat --version 0.0.6
NuGet\Install-Package StructuredChat -Version 0.0.6
<PackageReference Include="StructuredChat" Version="0.0.6" />
<PackageVersion Include="StructuredChat" Version="0.0.6" />
<PackageReference Include="StructuredChat" />
paket add StructuredChat --version 0.0.6
#r "nuget: StructuredChat, 0.0.6"
#:package StructuredChat@0.0.6
#addin nuget:?package=StructuredChat&version=0.0.6
#tool nuget:?package=StructuredChat&version=0.0.6
StructuredChat
Overview
StructuredChat is a .NET library that provides a fluent interface for extracting structured data from AI chat responses using schema validation. It wraps the Microsoft.Extensions.AI IChatClient interface and ensures responses conform to predefined formats through JsonSchema.Net validation.
The library leverages JsonSchema.Net and JsonSchema.Net.Generation for schema creation and validation, allowing you to use schema attributes on your classes or provide custom schemas directly.
Why StructuredChat?
The Problem
When working with Large Language Models (LLMs), responses are inherently unpredictable. Even with careful prompting, models can return:
- Missing required fields
- Wrong data types (string instead of number)
- Values outside valid ranges
- Inconsistent formats (dates, phone numbers, etc.)
- Fabricated or hallucinated data
This unpredictability makes it challenging to integrate AI responses into production applications that require reliable, structured data.
The Solution
StructuredChat solves these problems by:
- Schema Validation: Every AI response is validated against a JSON schema before being returned to your application
- Automatic Retry: When validation fails, the library automatically retries with specific error feedback, helping the AI correct its mistakes
- Type Safety: Extract data into strongly-typed C# objects with compile-time safety
- Fluent API: Easy-to-use configuration interface that chains naturally
- Built-in Schemas: Pre-built schemas for common patterns (yes/no, enums, dates, numbers, etc.)
- Custom Schemas: Full support for custom JSON schemas when you need precise control
- Workflow Engine: Build complex multi-step AI interactions with branching, loops, and parallel execution
- Chat History: Maintain conversation context across multiple interactions
Features
- ✅ Type-safe extraction - Extract structured data with compile-time type safety
- ✅ Schema validation - Automatic validation using JsonSchema.Net
- ✅ Retry mechanism - Built-in retry logic with exponential backoff
- ✅ Fluent API - Easy-to-use configuration interface
- ✅ Multiple data types - Support for enums, numbers, dates, lists, and custom objects
- ✅ Workflow engine - Build complex multi-step workflows with branching and loops
- ✅ Chat history - Maintain conversation context across interactions
- ✅ Multi-framework support - Works with .NET Standard 2.0, .NET 8, 9, and 10
Installation
dotnet add package StructuredChat
<PackageReference Include="StructuredChat" />
Dependencies:
- Microsoft.Extensions.AI.Abstractions
- JsonSchema.Net
- JsonSchema.Net.Generation
Getting Started
First, create an instance of IChatClient (using your preferred AI provider), then wrap it with StructuredChat:
using Microsoft.Extensions.AI;
using StructuredChat;
// Create your AI client (example using OpenAI)
IChatClient chatClient = new OpenAIClient("api-key")
.GetChatClient("gpt-4o")
.AsIChatClient();
// Wrap it with StructuredChat
var structuredClient = chatClient.AsStructuredChatClient();
Configuration Options
Configure various parameters using a fluent API:
var client = chatClient.AsStructuredChatClient()
.WithSystemPrompt("You are a helpful assistant.")
.WithTemperature(0.7f)
.WithMaxOutputTokens(1000)
.WithTopK(40)
.WithTopP(0.9f)
.WithFrequencyPenalty(0.5f)
.WithPresencePenalty(0.5f)
.WithMaxRetries(5)
.WithRetryDelay(2000)
.WithSchemaValidationException(); // Throw exception on validation failure
Configuration Methods
| Method | Parameter Type | Description | Default |
|---|---|---|---|
WithSystemPrompt(string) |
string | Set custom system prompt | Data extraction prompt |
WithTemperature(float) |
float | Control randomness (0.0 - 2.0) | null (provider default) |
WithMaxOutputTokens(int) |
int | Limit response length | null (provider default) |
WithTopK(int) |
int | Limit vocabulary for sampling | null (provider default) |
WithTopP(float) |
float | Nucleus sampling threshold (0.0 - 1.0) | null (provider default) |
WithFrequencyPenalty(float) |
float | Reduce repetition (-2.0 to 2.0) | null (provider default) |
WithPresencePenalty(float) |
float | Encourage topic diversity (-2.0 to 2.0) | null (provider default) |
WithMaxRetries(int) |
int | Retry attempts on validation failure | 3 |
WithRetryDelay(int) |
int | Delay between retries (milliseconds) | 1000 |
WithSchemaValidationException() |
- | Throw exception on validation failure | false |
Retry Mechanism
StructuredChat includes a built-in retry mechanism that automatically retries failed schema validations.
How It Works
- Automatic Retry: When a response fails schema validation, the library automatically retries
- Exponential Backoff: Each retry waits progressively longer (delay × attempt number)
- Error Feedback: Validation errors are sent back to the AI model for correction
- Configurable: Both retry count and delay are configurable
Error Handling Modes
Default Mode (Returns Invalid Result)
var client = chatClient.AsStructuredChatClient()
.WithMaxRetries(3);
var result = await client.AskStructuredAsync<Product>("Extract product info");
if (!result.IsValid)
{
foreach (var error in result.ValidationErrors)
{
Console.WriteLine($"{error.PropertyPath}: {string.Join(", ", error.ErrorMessages)}");
}
}
Exception Mode (Throws on Failure)
var client = chatClient.AsStructuredChatClient()
.WithMaxRetries(3)
.WithSchemaValidationException();
try
{
var result = await client.AskStructuredAsync<Product>("Extract product info");
// Use result.Data safely - guaranteed to be valid
}
catch (SchemaValidationException ex)
{
Console.WriteLine($"Validation failed after {ex.ValidationErrors.Count} errors");
foreach (var error in ex.ValidationErrors)
{
Console.WriteLine($"{error.PropertyPath}: {string.Join(", ", error.ErrorMessages)}");
}
}
Ask Methods Reference
1. AskAsync
Ask a generic question and get a text response.
Signature:
Task<StringResult> AskAsync(string question, List<ChatMessage>? history = null)
Returns: StringResult
Answer(string?): The text responseHasValue(bool): True if an answer was receivedIsValid(bool): Schema validation statusValidationErrors(List<SchemaValidationError>): Validation errors if anyChatHistory(List<ChatMessage>): The conversation history
Example:
var result = await client.AskAsync("What is the capital of France?");
Console.WriteLine($"Answer: {result.Answer}"); // "Paris"
2. AskYesNoAsync
Extract boolean answers from natural language questions.
Signature:
Task<YesNoResult> AskYesNoAsync(string question, List<ChatMessage>? history = null)
Returns: YesNoResult
Answer(bool): True for "yes", False for "no"RawResponse(string): The raw "yes" or "no" responseHasValue(bool): True if an answer was receivedIsValid(bool): Schema validation statusValidationErrors(List<SchemaValidationError>): Validation errors if anyChatHistory(List<ChatMessage>): The conversation history
Example:
var result = await client.AskYesNoAsync("Is the sky blue?");
Console.WriteLine($"Answer: {result.Answer}"); // True
Console.WriteLine($"Raw Response: {result.RawResponse}"); // "yes"
// More examples
var isRaining = await client.AskYesNoAsync("Based on the forecast, will it rain tomorrow?");
var isAffordable = await client.AskYesNoAsync("Is $50,000 an affordable price for a luxury car?");
3. AskSingleChoiceAsync
Select one option from a predefined list.
Signature:
Task<SingleChoiceResult<T>> AskSingleChoiceAsync<T>(
string question,
IEnumerable<T> choices,
List<ChatMessage>? history = null
) where T : notnull
Returns: SingleChoiceResult<T>
Choice(T?): The selected choiceHasValue(bool): True if an answer was receivedIsValid(bool): Schema validation statusValidationErrors(List<SchemaValidationError>): Validation errors if anyChatHistory(List<ChatMessage>): The conversation history
Examples:
// String choices
var colors = new[] { "Red", "Blue", "Green", "Yellow" };
var result = await client.AskSingleChoiceAsync(
"What color is the ocean?",
colors
);
Console.WriteLine($"Selected: {result.Choice}"); // "Blue"
// Numeric choices
var numbers = new[] { 1, 2, 3, 4, 5 };
var primeResult = await client.AskSingleChoiceAsync(
"Pick the smallest prime number",
numbers
);
Console.WriteLine($"Selected: {primeResult.Choice}"); // 2
// Custom object choices
var options = new[]
{
new { Id = 1, Name = "Basic" },
new { Id = 2, Name = "Premium" },
new { Id = 3, Name = "Enterprise" }
};
var planResult = await client.AskSingleChoiceAsync(
"Which plan offers the most features?",
options
);
4. AskMultipleChoiceAsync
Select multiple options from a list with optional min/max constraints.
Signature:
Task<MultipleChoiceResult<T>> AskMultipleChoiceAsync<T>(
string question,
IEnumerable<T> choices,
int? minSelections = null,
int? maxSelections = null,
List<ChatMessage>? history = null
) where T : notnull
Returns: MultipleChoiceResult<T>
Choices(List<T>): The selected choicesHasValue(bool): True if an answer was receivedIsValid(bool): Schema validation statusValidationErrors(List<SchemaValidationError>): Validation errors if anyChatHistory(List<ChatMessage>): The conversation history
Examples:
// Without constraints
var fruits = new[] { "Apple", "Banana", "Orange", "Grape", "Mango" };
var result = await client.AskMultipleChoiceAsync(
"Which fruits are citrus?",
fruits
);
foreach (var choice in result.Choices)
{
Console.WriteLine($"- {choice}"); // Orange
}
// With minimum selections
var languages = new[] { "C#", "Java", "Python", "JavaScript", "Go" };
var langResult = await client.AskMultipleChoiceAsync(
"Which languages support garbage collection?",
languages,
minSelections: 1
);
// With both constraints
var features = new[] { "Authentication", "Logging", "Caching", "Monitoring", "Queue" };
var featureResult = await client.AskMultipleChoiceAsync(
"Select essential features for a production API",
features,
minSelections: 2,
maxSelections: 4
);
5. AskEnumAsync
Extract enum values from responses.
Signature:
Task<EnumResult<TEnum>> AskEnumAsync<TEnum>(
string question,
List<ChatMessage>? history = null
) where TEnum : struct, Enum
Returns: EnumResult<TEnum>
Value(TEnum?): The extracted enum valueHasValue(bool): True if an answer was receivedIsValid(bool): Schema validation statusValidationErrors(List<SchemaValidationError>): Validation errors if anyChatHistory(List<ChatMessage>): The conversation history
Examples:
public enum Priority
{
Low,
Medium,
High,
Critical
}
var result = await client.AskEnumAsync<Priority>(
"What priority should we assign to a security breach?"
);
Console.WriteLine($"Priority: {result.Value}"); // Priority.Critical
public enum DayOfWeek
{
Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday
}
var dayResult = await client.AskEnumAsync<DayOfWeek>(
"What day comes after Wednesday?"
);
Console.WriteLine($"Day: {dayResult.Value}"); // DayOfWeek.Thursday
public enum LogLevel
{
Trace, Debug, Information, Warning, Error, Critical
}
var logResult = await client.AskEnumAsync<LogLevel>(
"What log level should we use for exceptions?"
);
6. AskSingleWordAsync
Extract exactly one word (no whitespace allowed).
Signature:
Task<SingleWordResult> AskSingleWordAsync(
string question,
List<ChatMessage>? history = null
)
Returns: SingleWordResult
Word(string?): The extracted single wordHasValue(bool): True if an answer was receivedIsValid(bool): Schema validation statusValidationErrors(List<SchemaValidationError>): Validation errors if anyChatHistory(List<ChatMessage>): The conversation history
Examples:
var result = await client.AskSingleWordAsync(
"What is the capital of France?"
);
Console.WriteLine($"Word: {result.Word}"); // "Paris"
var colorResult = await client.AskSingleWordAsync(
"What color do you get when you mix blue and yellow?"
);
Console.WriteLine($"Color: {colorResult.Word}"); // "Green"
var verbResult = await client.AskSingleWordAsync(
"What is the past tense of 'run'?"
);
Console.WriteLine($"Verb: {verbResult.Word}"); // "Ran"
7. AskStringAsync
Extract a string response with word count constraints.
Signature:
Task<StringResult> AskStringAsync(
string question,
int minWords,
int maxWords,
List<ChatMessage>? history = null
)
Returns: StringResult
Answer(string?): The text responseHasValue(bool): True if an answer was receivedIsValid(bool): Schema validation statusValidationErrors(List<SchemaValidationError>): Validation errors if anyChatHistory(List<ChatMessage>): The conversation history
Examples:
// Get a brief summary (5-20 words)
var result = await client.AskStringAsync(
"Summarize the benefits of exercise",
minWords: 5,
maxWords: 20
);
Console.WriteLine($"Summary: {result.Answer}");
// Get a detailed explanation (50-100 words)
var detailed = await client.AskStringAsync(
"Explain how photosynthesis works",
minWords: 50,
maxWords: 100
);
8. AskWithRegexPatternAsync
Extract data matching a specific regex pattern.
Signature:
Task<RegexResult> AskWithRegexPatternAsync(
string question,
string regexPattern,
string patternDescription = "",
List<ChatMessage>? history = null
)
Returns: RegexResult
Value(string): The extracted value matching the patternHasValue(bool): True if an answer was receivedIsValid(bool): Schema validation statusValidationErrors(List<SchemaValidationError>): Validation errors if anyChatHistory(List<ChatMessage>): The conversation history
Examples:
// Extract email address
var emailResult = await client.AskWithRegexPatternAsync(
"What is the support email? Contact us at support@example.com",
@"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
"Valid email address format"
);
Console.WriteLine($"Email: {emailResult.Value}"); // "support@example.com"
// Extract phone number
var phoneResult = await client.AskWithRegexPatternAsync(
"Call us at (555) 123-4567",
@"^\(\d{3}\) \d{3}-\d{4}$",
"US phone number format (XXX) XXX-XXXX"
);
Console.WriteLine($"Phone: {phoneResult.Value}"); // "(555) 123-4567"
// Extract ISBN
var isbnResult = await client.AskWithRegexPatternAsync(
"Book ISBN: 978-0-123456-78-9",
@"^\d{3}-\d{1}-\d{6}-\d{2}-\d{1}$",
"ISBN-13 format"
);
Console.WriteLine($"ISBN: {isbnResult.Value}"); // "978-0-123456-78-9"
// Extract hex color code
var colorResult = await client.AskWithRegexPatternAsync(
"The primary color is #FF5733",
@"^#[0-9A-Fa-f]{6}$",
"Hex color code"
);
Console.WriteLine($"Color: {colorResult.Value}"); // "#FF5733"
// Extract version number
var versionResult = await client.AskWithRegexPatternAsync(
"Current version: v2.3.1",
@"^v?\d+\.\d+\.\d+$",
"Semantic version format"
);
9. AskNumberAsync
Extract numeric values with optional min/max constraints.
Signature:
Task<NumberResult> AskNumberAsync(
string question,
double? minimum = null,
double? maximum = null,
List<ChatMessage>? history = null
)
Returns: NumberResult
Value(double?): The extracted numberHasValue(bool): True if an answer was receivedIsValid(bool): Schema validation statusValidationErrors(List<SchemaValidationError>): Validation errors if anyChatHistory(List<ChatMessage>): The conversation history
Examples:
// Without constraints
var result = await client.AskNumberAsync(
"How many days are in a week?"
);
Console.WriteLine($"Number: {result.Value}"); // 7
// With minimum
var ageResult = await client.AskNumberAsync(
"What is the minimum voting age in the US?",
minimum: 0
);
Console.WriteLine($"Age: {ageResult.Value}"); // 18
// With maximum
var percentResult = await client.AskNumberAsync(
"What percentage of Earth is covered by water?",
maximum: 100
);
Console.WriteLine($"Percent: {percentResult.Value}"); // ~71
// With both constraints
var scoreResult = await client.AskNumberAsync(
"Rate this product on a scale of 1-10",
minimum: 1,
maximum: 10
);
Console.WriteLine($"Score: {scoreResult.Value}"); // e.g., 8.5
// Decimal values
var priceResult = await client.AskNumberAsync(
"What is the average price of coffee in USD?",
minimum: 0,
maximum: 20
);
Console.WriteLine($"Price: ${priceResult.Value}"); // e.g., 4.50
10. AskNumberRangeAsync
Extract a range of numbers (minimum and maximum values).
Signature:
Task<NumberRangeResult> AskNumberRangeAsync(
string question,
double? absoluteMinimum = null,
double? absoluteMaximum = null,
List<ChatMessage>? history = null
)
Returns: NumberRangeResult
Minimum(double?): The lower bound of the rangeMaximum(double?): The upper bound of the rangeHasValue(bool): True if an answer was receivedIsValid(bool): Schema validation statusValidationErrors(List<SchemaValidationError>): Validation errors if anyChatHistory(List<ChatMessage>): The conversation history
Examples:
// Without constraints
var result = await client.AskNumberRangeAsync(
"What is the normal human body temperature range in Celsius?"
);
Console.WriteLine($"Range: {result.Minimum}°C - {result.Maximum}°C");
// e.g., 36.5 - 37.5
// With absolute constraints
var salaryResult = await client.AskNumberRangeAsync(
"What is the typical salary range for a software engineer in USD?",
absoluteMinimum: 0,
absoluteMaximum: 1000000
);
Console.WriteLine($"Salary Range: ${salaryResult.Minimum} - ${salaryResult.Maximum}");
// e.g., 70000 - 150000
// Temperature range
var tempResult = await client.AskNumberRangeAsync(
"What is the ideal temperature range for wine storage in Fahrenheit?",
absoluteMinimum: 0,
absoluteMaximum: 100
);
// Age range
var ageRangeResult = await client.AskNumberRangeAsync(
"What age range is considered 'middle age'?",
absoluteMinimum: 0,
absoluteMaximum: 120
);
11. AskDateTimeAsync
Extract dates and times in ISO 8601 format.
Signature:
Task<DateTimeResult> AskDateTimeAsync(
string question,
List<ChatMessage>? history = null
)
Returns: DateTimeResult
DateTime(DateTime?): The extracted date/timeHasValue(bool): True if an answer was receivedIsValid(bool): Schema validation statusValidationErrors(List<SchemaValidationError>): Validation errors if anyChatHistory(List<ChatMessage>): The conversation history
Examples:
var result = await client.AskDateTimeAsync(
"When did World War II end in Europe?"
);
Console.WriteLine($"DateTime: {result.DateTime}");
// 1945-05-08T00:00:00
var moonResult = await client.AskDateTimeAsync(
"When did humans first land on the moon?"
);
Console.WriteLine($"Moon Landing: {moonResult.DateTime:yyyy-MM-dd}");
// 1969-07-20
var independenceResult = await client.AskDateTimeAsync(
"When did the United States declare independence?"
);
Console.WriteLine($"Independence: {independenceResult.DateTime}");
// 1776-07-04T00:00:00
// With specific times
var launchResult = await client.AskDateTimeAsync(
"When did Apollo 11 launch? (Include time)"
);
// 1969-07-16T13:32:00Z
12. AskDateTimeRangeAsync
Extract a date/time range (start and end).
Signature:
Task<DateTimeRangeResult> AskDateTimeRangeAsync(
string question,
List<ChatMessage>? history = null
)
Returns: DateTimeRangeResult
StartDate(DateTime?): The start date/timeEndDate(DateTime?): The end date/timeHasValue(bool): True if an answer was receivedIsValid(bool): Schema validation statusValidationErrors(List<SchemaValidationError>): Validation errors if anyChatHistory(List<ChatMessage>): The conversation history
Examples:
var result = await client.AskDateTimeRangeAsync(
"What was the duration of the Renaissance period?"
);
Console.WriteLine($"Start: {result.StartDate}"); // ~1300-01-01
Console.WriteLine($"End: {result.EndDate}"); // ~1600-01-01
var warResult = await client.AskDateTimeRangeAsync(
"What were the start and end dates of World War I?"
);
Console.WriteLine($"WWI: {warResult.StartDate:yyyy-MM-dd} to {warResult.EndDate:yyyy-MM-dd}");
// 1914-07-28 to 1918-11-11
var projectResult = await client.AskDateTimeRangeAsync(
"The project runs from January 15, 2024 to March 30, 2024. What are the dates?"
);
var vacationResult = await client.AskDateTimeRangeAsync(
"Extract vacation dates: We're traveling from Dec 20, 2024 through Jan 5, 2025"
);
13. AskListAsync (Strings)
Extract a list of strings with optional size constraints.
Signature:
Task<ListResult<string>> AskListAsync(
string question,
int? minItems = null,
int? maxItems = null,
List<ChatMessage>? history = null
)
Returns: ListResult<string>
Items(List<string>): The extracted list of stringsHasValue(bool): True if an answer was receivedIsValid(bool): Schema validation statusValidationErrors(List<SchemaValidationError>): Validation errors if anyChatHistory(List<ChatMessage>): The conversation history
Examples:
// Without constraints
var result = await client.AskListAsync(
"List the planets in our solar system"
);
foreach (var planet in result.Items)
{
Console.WriteLine($"- {planet}");
}
// Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus, Neptune
// With minimum items
var colorsResult = await client.AskListAsync(
"List primary colors",
minItems: 3
);
// Red, Blue, Yellow
// With maximum items
var topResult = await client.AskListAsync(
"List popular programming languages",
maxItems: 5
);
// With both constraints
var featuresResult = await client.AskListAsync(
"List essential features for a web application",
minItems: 3,
maxItems: 7
);
14. AskListAsync (Generic)
Extract a list of complex objects.
Signature:
Task<ListResult<T>> AskListAsync<T>(
string question,
int? minItems = null,
int? maxItems = null,
List<ChatMessage>? history = null
)
Returns: ListResult<T>
Items(List<T>): The extracted list of objectsHasValue(bool): True if an answer was receivedIsValid(bool): Schema validation statusValidationErrors(List<SchemaValidationError>): Validation errors if anyChatHistory(List<ChatMessage>): The conversation history
Examples:
public class Book
{
public string Title { get; set; }
public string Author { get; set; }
public int Year { get; set; }
}
var result = await client.AskListAsync<Book>(
"List three classic novels with their authors and publication years",
minItems: 3,
maxItems: 3
);
foreach (var book in result.Items)
{
Console.WriteLine($"{book.Title} by {book.Author} ({book.Year})");
}
// To Kill a Mockingbird by Harper Lee (1960)
// 1984 by George Orwell (1949)
// Pride and Prejudice by Jane Austen (1813)
public class City
{
public string Name { get; set; }
public string Country { get; set; }
public int Population { get; set; }
}
var citiesResult = await client.AskListAsync<City>(
"List the 5 most populous cities in the world",
maxItems: 5
);
public class Recipe
{
public string Name { get; set; }
public List<string> Ingredients { get; set; }
public int PrepTimeMinutes { get; set; }
}
var recipesResult = await client.AskListAsync<Recipe>(
"Suggest 3 quick breakfast recipes",
minItems: 3,
maxItems: 3
);
15. AskStructuredAsync (Auto Schema)
Extract complex structured data using custom classes. The schema is automatically generated from the class definition.
Signature:
Task<StructuredResult<T>> AskStructuredAsync<T>(
string question,
List<ChatMessage>? history = null
) where T : class
Returns: StructuredResult<T>
Data(T?): The extracted structured dataHasValue(bool): True if an answer was receivedIsValid(bool): Schema validation statusValidationErrors(List<SchemaValidationError>): Validation errors if anyChatHistory(List<ChatMessage>): The conversation history
Examples:
// Basic example
public class Person
{
public string Name { get; set; }
public int Age { get; set; }
public string Email { get; set; }
public List<string> Hobbies { get; set; }
}
var result = await client.AskStructuredAsync<Person>(
@"Extract information about John:
John Doe is 30 years old.
His email is john.doe@example.com.
He enjoys reading, hiking, and photography."
);
var person = result.Data;
Console.WriteLine($"Name: {person.Name}");
Console.WriteLine($"Age: {person.Age}");
Console.WriteLine($"Email: {person.Email}");
Console.WriteLine($"Hobbies: {string.Join(", ", person.Hobbies)}");
// Complex nested example
public class Address
{
public string Street { get; set; }
public string City { get; set; }
public string State { get; set; }
public string ZipCode { get; set; }
}
public class Company
{
public string Name { get; set; }
public string Industry { get; set; }
public int EmployeeCount { get; set; }
public Address HeadquartersAddress { get; set; }
public List<string> Products { get; set; }
}
var companyResult = await client.AskStructuredAsync<Company>(
@"Extract company info:
TechCorp is a software company with 500 employees.
They are located at 123 Main St, San Francisco, CA 94105.
Their main products are CloudService, DataAnalyzer, and SecuritySuite."
);
16. AskStructuredAsync (Custom JsonSchema)
Extract structured data using a custom JsonSchema.Net schema.
Signature:
Task<StructuredResult<T>> AskStructuredAsync<T>(
string question,
JsonSchema customSchema,
List<ChatMessage>? history = null
) where T : class
Examples:
using Json.Schema;
// Custom schema with validation rules
var customSchema = new JsonSchemaBuilder()
.Type(SchemaValueType.Object)
.Properties(
("productName", new JsonSchemaBuilder()
.Type(SchemaValueType.String)
.MinLength(3)),
("price", new JsonSchemaBuilder()
.Type(SchemaValueType.Number)
.Minimum(0)
.Maximum(10000)),
("inStock", new JsonSchemaBuilder()
.Type(SchemaValueType.Boolean))
)
.Required("productName", "price")
.AdditionalProperties(false)
.Build();
public class Product
{
public string ProductName { get; set; }
public double Price { get; set; }
public bool InStock { get; set; }
}
var result = await client.AskStructuredAsync<Product>(
"Extract: The laptop costs $999 and is currently in stock",
customSchema
);
Console.WriteLine($"{result.Data.ProductName}: ${result.Data.Price}");
Console.WriteLine($"In Stock: {result.Data.InStock}");
17. AskStructuredAsync (Schema as JSON String)
Extract structured data using a JSON schema provided as a string.
Signature:
Task<StructuredResult<T>> AskStructuredAsync<T>(
string question,
string schemaJson,
List<ChatMessage>? history = null
) where T : class
Examples:
public class Product
{
public string Name { get; set; }
public double Price { get; set; }
public bool Available { get; set; }
}
// Define schema as JSON string
var schemaJson = """
{
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 3
},
"price": {
"type": "number",
"minimum": 0,
"maximum": 10000
},
"available": {
"type": "boolean"
}
},
"required": ["name", "price"],
"additionalProperties": false
}
""";
var result = await client.AskStructuredAsync<Product>(
"Extract: Premium Laptop costs $1299 and is available",
schemaJson
);
Console.WriteLine($"{result.Data.Name}: ${result.Data.Price}");
Console.WriteLine($"Available: {result.Data.Available}");
18. AskStructuredAsync (Schema as Anonymous Object)
Extract structured data using a JSON schema provided as an anonymous object.
Signature:
Task<StructuredResult<T>> AskStructuredAsync<T>(
string question,
object schemaObject,
List<ChatMessage>? history = null
) where T : class
Examples:
public class Person
{
public string Name { get; set; }
public int Age { get; set; }
public string Email { get; set; }
}
// Define schema using anonymous object
var schemaObject = new
{
type = "object",
properties = new
{
name = new
{
type = "string",
minLength = 2,
maxLength = 100
},
age = new
{
type = "integer",
minimum = 0,
maximum = 150
},
email = new
{
type = "string",
pattern = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"
}
},
required = new[] { "name", "age", "email" },
additionalProperties = false
};
var result = await client.AskStructuredAsync<Person>(
"Extract: Alice Smith, 28 years old, email: alice@example.com",
schemaObject
);
Console.WriteLine($"{result.Data.Name}, {result.Data.Age}, {result.Data.Email}");
19. AskStructuredAsync (Schema as Dictionary)
Extract structured data using a JSON schema provided as a dictionary.
Signature:
Task<StructuredResult<T>> AskStructuredAsync<T>(
string question,
Dictionary<string, object> schemaDictionary,
List<ChatMessage>? history = null
) where T : class
Examples:
public class Book
{
public string Isbn { get; set; }
public string Title { get; set; }
public string Author { get; set; }
public int YearPublished { get; set; }
}
// Define schema using Dictionary
var schemaDictionary = new Dictionary<string, object>
{
["type"] = "object",
["properties"] = new Dictionary<string, object>
{
["isbn"] = new Dictionary<string, object>
{
["type"] = "string",
["pattern"] = "^[0-9]{3}-[0-9]{10}$"
},
["title"] = new Dictionary<string, object>
{
["type"] = "string",
["minLength"] = 1
},
["author"] = new Dictionary<string, object>
{
["type"] = "string"
},
["yearPublished"] = new Dictionary<string, object>
{
["type"] = "integer",
["minimum"] = 1000,
["maximum"] = 2100
}
},
["required"] = new[] { "isbn", "title", "author", "yearPublished" },
["additionalProperties"] = false
};
var result = await client.AskStructuredAsync<Book>(
"Extract: ISBN 978-0123456789, '1984' by George Orwell, published 1949",
schemaDictionary
);
Console.WriteLine($"{result.Data.Title} by {result.Data.Author} ({result.Data.YearPublished})");
Working with JsonSchema.Net Attributes
StructuredChat fully supports JsonSchema.Net.Generation attributes for fine-grained schema control.
Available Attributes
using Json.Schema.Generation;
public class AdvancedPerson
{
[Required]
[MinLength(2)]
[MaxLength(100)]
public string Name { get; set; }
[Minimum(0)]
[Maximum(150)]
public int Age { get; set; }
[Pattern(@"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")]
public string Email { get; set; }
[MinItems(1)]
[MaxItems(10)]
[UniqueItems(true)]
public List<string> Skills { get; set; }
[Description("Person's biography or description")]
[MinLength(10)]
public string Bio { get; set; }
[Title("Contact Number")]
[Pattern(@"^\+?[1-9]\d{1,14}$")]
public string PhoneNumber { get; set; }
}
var result = await client.AskStructuredAsync<AdvancedPerson>(
"Extract: John Smith, 35 years old, email john@example.com..."
);
Common JsonSchema.Net Attributes
| Attribute | Applies To | Description |
|---|---|---|
[Required] |
Properties | Marks property as required |
[Description("...")] |
All | Adds description for AI context |
[Title("...")] |
All | Sets display title |
[MinLength(n)] |
String | Minimum string length |
[MaxLength(n)] |
String | Maximum string length |
[Pattern("regex")] |
String | Regex pattern validation |
[Minimum(n)] |
Number | Minimum numeric value |
[Maximum(n)] |
Number | Maximum numeric value |
[ExclusiveMinimum(n)] |
Number | Exclusive minimum value |
[ExclusiveMaximum(n)] |
Number | Exclusive maximum value |
[MultipleOf(n)] |
Number | Value must be multiple of n |
[MinItems(n)] |
Array | Minimum array length |
[MaxItems(n)] |
Array | Maximum array length |
[UniqueItems(true)] |
Array | Array items must be unique |
Chat History
StructuredChat supports maintaining conversation context across multiple interactions. This is useful for:
- Follow-up questions
- Multi-turn conversations
- Context-aware responses
Manual History Management
var history = new List<ChatMessage>();
// First question
var result1 = await client.AskAsync("What is the capital of France?", history);
// history now contains the conversation
// Follow-up question using the same history
var result2 = await client.AskAsync("What is its population?", history);
// AI understands "its" refers to Paris from previous context
// Continue the conversation
var result3 = await client.AskYesNoAsync("Is it larger than London?", history);
Accessing Chat History from Results
Every result includes the chat history:
var result = await client.AskAsync("Tell me about machine learning");
// Access the history from the result
foreach (var message in result.ChatHistory)
{
Console.WriteLine($"{message.Role}: {message.Text}");
}
Workflow Engine
The Workflow Engine allows you to build complex, multi-step AI interactions with branching logic, loops, parallel execution, and data transformation.
Creating a Workflow
var workflow = client.CreateWorkflow()
.AskYesNo("interested", "Are you interested in technology?")
.If(
ctx => ctx.Get<YesNoResult>("interested")?.Answer == true,
then => then
.AskSingleChoice("language", "What's your favorite programming language?",
new[] { "C#", "Python", "JavaScript", "Go" })
.AskNumber("experience", "How many years of experience do you have?",
minimum: 0, maximum: 50),
@else => @else
.Ask("alternative", "What field are you interested in instead?")
)
.Build();
var context = await workflow.ExecuteAsync();
// Access results
var isInterested = context.Get<YesNoResult>("interested");
var language = context.Get<SingleChoiceResult<string>>("language");
Workflow with Maintained History
Enable conversation history across all workflow steps:
var workflow = client.CreateWorkflow(maintainHistory: true)
.Ask("intro", "Introduce yourself briefly")
.Ask("followup", "What are your main skills?")
.Ask("detail", "Tell me more about your most impressive project")
.Build();
var context = await workflow.ExecuteAsync();
// Each step has access to the previous conversation context
Dynamic Questions
Use context data to generate dynamic questions:
var workflow = client.CreateWorkflow()
.AskSingleWord("name", "What is your first name?")
.Ask("greeting", ctx => $"Hello {ctx.Get<SingleWordResult>("name")?.Word}! How are you today?")
.AskNumber("rating", ctx =>
$"On a scale of 1-10, how would you rate your day, {ctx.Get<SingleWordResult>("name")?.Word}?",
minimum: 1, maximum: 10)
.Build();
Conditional Branching (If/Else)
var workflow = client.CreateWorkflow()
.AskNumber("age", "What is your age?", minimum: 0, maximum: 120)
.If(
ctx => ctx.Get<NumberResult>("age")?.Value >= 18,
then => then
.Ask("adult", "What are your career goals?"),
@else => @else
.Ask("minor", "What do you want to be when you grow up?")
)
.Build();
Switch Statement
public enum Department { Engineering, Sales, HR, Marketing }
var workflow = client.CreateWorkflow()
.AskEnum<Department>("dept", "Which department do you work in?")
.Switch(
ctx => ctx.Get<EnumResult<Department>>("dept")?.Value ?? Department.Engineering,
cases =>
{
cases.Case(Department.Engineering, b => b
.AskList("skills", "List your technical skills", minItems: 3));
cases.Case(Department.Sales, b => b
.AskNumber("quota", "What is your monthly sales quota?", minimum: 0));
cases.Case(Department.HR, b => b
.AskMultipleChoice("focus", "Select your HR focus areas",
new[] { "Recruiting", "Training", "Benefits", "Compliance" }));
cases.Default(b => b
.Ask("general", "Describe your role"));
}
)
.Build();
Loops
For Loop
var workflow = client.CreateWorkflow()
.For(0, 3, (builder, index) =>
{
builder.Ask($"item_{index}", $"Enter item #{index + 1}");
})
.Build();
var context = await workflow.ExecuteAsync();
var item0 = context.Get<StringResult>("item_0");
var item1 = context.Get<StringResult>("item_1");
var item2 = context.Get<StringResult>("item_2");
ForEach Loop
var workflow = client.CreateWorkflow()
.AskList("topics", "List 3 topics you want to learn about", minItems: 3, maxItems: 3)
.Do(ctx =>
{
var topics = ctx.Get<ListResult<string>>("topics")?.Items ?? new List<string>();
ctx.Set("topicList", topics);
})
.ForEach<string>("topicList", "currentTopic", builder =>
{
builder.Ask("explanation", ctx =>
$"Explain {ctx.Get<string>("currentTopic")} in simple terms");
})
.Build();
While Loop
var workflow = client.CreateWorkflow()
.Do(ctx => ctx.Set("attempts", 0))
.While(
ctx => ctx.Get<int>("attempts") < 3,
builder => builder
.AskNumber("guess", "Guess a number between 1 and 10", minimum: 1, maximum: 10)
.Do(ctx =>
{
var attempts = ctx.Get<int>("attempts");
ctx.Set("attempts", attempts + 1);
})
)
.Build();
Parallel Execution
Execute multiple branches simultaneously:
var workflow = client.CreateWorkflow()
.Parallel(
branch1 => branch1
.AskList("benefits", "List 3 benefits of exercise", maxItems: 3),
branch2 => branch2
.AskList("risks", "List 3 risks of a sedentary lifestyle", maxItems: 3),
branch3 => branch3
.AskNumber("hours", "Recommended weekly exercise hours?", minimum: 0, maximum: 40)
)
.Build();
var context = await workflow.ExecuteAsync();
// All three branches executed in parallel
Data Transformation
Transform data between steps:
var workflow = client.CreateWorkflow()
.AskNumber("celsius", "What is the temperature in Celsius?")
.Transform<NumberResult, double>(
"celsius",
"fahrenheit",
result => (result.Value ?? 0) * 9 / 5 + 32
)
.Do(ctx =>
{
var fahrenheit = ctx.Get<double>("fahrenheit");
Console.WriteLine($"That's {fahrenheit}°F");
})
.Build();
Custom Actions
Synchronous Action
var workflow = client.CreateWorkflow()
.AskStructured<Person>("person", "Extract person info from: John, 30, john@email.com")
.Do(ctx =>
{
var person = ctx.Get<StructuredResult<Person>>("person")?.Data;
if (person != null)
{
// Log, save to database, etc.
Console.WriteLine($"Processing: {person.Name}");
ctx.Set("processed", true);
}
})
.Build();
Asynchronous Action
var workflow = client.CreateWorkflow()
.AskStructured<Order>("order", "Extract order details...")
.DoAsync(async ctx =>
{
var order = ctx.Get<StructuredResult<Order>>("order")?.Data;
if (order != null)
{
// Async operations like API calls, database saves
await SaveToDatabase(order);
await SendNotification(order);
}
})
.Build();
Complete Workflow Example
Here's a comprehensive example showing multiple features together:
public class CustomerInfo
{
public string Name { get; set; }
public string Email { get; set; }
public string Company { get; set; }
}
public enum ProductCategory { Software, Hardware, Services, Consulting }
var workflow = client.CreateWorkflow(maintainHistory: true)
// Step 1: Gather customer information
.AskStructured<CustomerInfo>("customer",
"Please provide your name, email, and company name")
// Step 2: Determine product interest
.AskEnum<ProductCategory>("category",
ctx => $"Hello {ctx.Get<StructuredResult<CustomerInfo>>("customer")?.Data?.Name}! " +
"What category of products are you interested in?")
// Step 3: Branch based on category
.Switch(
ctx => ctx.Get<EnumResult<ProductCategory>>("category")?.Value ?? ProductCategory.Software,
cases =>
{
cases.Case(ProductCategory.Software, b => b
.AskMultipleChoice("features", "Select desired features",
new[] { "Cloud", "On-premise", "Mobile", "API", "Analytics" },
minSelections: 1, maxSelections: 3)
.AskNumber("users", "Expected number of users?", minimum: 1, maximum: 100000));
cases.Case(ProductCategory.Hardware, b => b
.AskSingleChoice("type", "What type of hardware?",
new[] { "Servers", "Networking", "Storage", "Workstations" })
.AskNumberRange("budget", "What is your budget range in USD?",
absoluteMinimum: 1000, absoluteMaximum: 1000000));
cases.Case(ProductCategory.Services, b => b
.AskList("services", "List the services you need", minItems: 1, maxItems: 5));
cases.Default(b => b
.Ask("consulting", "Describe your consulting needs"));
}
)
// Step 4: Timeline
.AskDateTimeRange("timeline", "When do you need the solution implemented?")
// Step 5: Final confirmation
.AskYesNo("confirm", ctx =>
{
var customer = ctx.Get<StructuredResult<CustomerInfo>>("customer")?.Data;
return $"Thank you {customer?.Name}! Would you like us to contact you at {customer?.Email}?";
})
// Step 6: Process based on confirmation
.If(
ctx => ctx.Get<YesNoResult>("confirm")?.Answer == true,
then => then
.DoAsync(async ctx =>
{
var customer = ctx.Get<StructuredResult<CustomerInfo>>("customer")?.Data;
// Send confirmation email, create CRM entry, etc.
await SendConfirmationEmail(customer);
ctx.Set("status", "Lead captured successfully");
}),
@else => @else
.Do(ctx => ctx.Set("status", "Customer declined contact"))
)
.Build();
// Execute the workflow
var context = await workflow.ExecuteAsync();
// Access all collected data
var customer = context.Get<StructuredResult<CustomerInfo>>("customer")?.Data;
var category = context.Get<EnumResult<ProductCategory>>("category")?.Value;
var timeline = context.Get<DateTimeRangeResult>("timeline");
var status = context.Get<string>("status");
Console.WriteLine($"Lead Status: {status}");
Console.WriteLine($"Customer: {customer?.Name} from {customer?.Company}");
Console.WriteLine($"Category: {category}");
Console.WriteLine($"Timeline: {timeline?.StartDate:d} to {timeline?.EndDate:d}");
Workflow Context Data Management
The StructuredWorkflowContext is a thread-safe data container for storing and managing state throughout workflow execution.
Context Methods
| Method | Signature | Description |
|---|---|---|
Set<T> |
void Set<T>(string key, T value) |
Store a value with the specified key |
Get<T> |
T? Get<T>(string key) |
Retrieve a value by key, cast to type T |
Contains |
bool Contains(string key) |
Check if a key exists in the context |
Remove |
void Remove(string key) |
Remove a specific key from the context |
RemoveAll |
void RemoveAll() |
Clear all data from the context |
Example
var workflow = client.CreateWorkflow()
// Store various types
.Do(ctx =>
{
ctx.Set("counter", 0);
ctx.Set("userName", "John");
ctx.Set("config", new AppConfig { MaxRetries = 3 });
})
// Use stored data in questions
.Ask("greeting", ctx => $"Hello {ctx.Get<string>("userName")}! How can I help?")
// Update values based on results
.AskNumber("score", "Rate from 1-10", minimum: 1, maximum: 10)
.Do(ctx =>
{
var score = ctx.Get<NumberResult>("score")?.Value ?? 0;
ctx.Set("grade", score >= 7 ? "Pass" : "Fail");
ctx.Set("normalizedScore", score / 10.0);
})
.Build();
var context = await workflow.ExecuteAsync();
Console.WriteLine($"Grade: {context.Get<string>("grade")}");
Validation Results
All methods return result objects that include validation information:
public class BaseResult
{
public bool HasValue { get; }
public bool IsValid { get; }
public List<SchemaValidationError> ValidationErrors { get; }
public List<ChatMessage> ChatHistory { get; }
}
Understanding HasValue vs IsValid
HasValue: Checks if the response contains actual datatrue= Data exists (not null, not empty, not default value)false= No data received or empty response
IsValid: Checks if the data passes schema validationtrue= Data conforms to all schema rules and constraintsfalse= Data violates one or more schema requirements
ValidationErrors: Detailed error information when validation fails- Empty when
IsValidistrue - Contains specific error messages and property paths when
IsValidisfalse
- Empty when
Handling Validation Results
var result = await client.AskStructuredAsync<Product>("...");
if (result.IsValid && result.HasValue)
{
// Safe to use result.Data
ProcessProduct(result.Data);
}
else if (!result.IsValid)
{
// Handle validation errors
foreach (var error in result.ValidationErrors)
{
Console.WriteLine($"Error at {error.PropertyPath}:");
foreach (var message in error.ErrorMessages)
{
Console.WriteLine($" - {message}");
}
}
}
else
{
// No data received
Console.WriteLine("No data was extracted");
}
Schema Method Comparison
| Method | Best For | Pros | Cons |
|---|---|---|---|
| Auto Schema | Simple POCOs with attributes | Zero configuration, Type-safe | Less flexible |
| JsonSchema Object | Complex validation rules | Full JsonSchema.Net features | More verbose |
| JSON String | External schema files | Easy to store/version | No compile-time validation |
| Anonymous Object | Quick inline schemas | Clean syntax, Easy to read | No IntelliSense |
| Dictionary | Runtime schema generation | Fully dynamic | Type-unsafe |
Contributing
We welcome contributions to make StructuredChat even better!
🌟 Star this repository if you find it useful!
🔧 Pull Requests Welcome
Please feel free to fork the repository and submit a pull request.
📝 Reporting Issues
Found a bug or have a suggestion? Please open an issue with:
- A clear description of the problem or enhancement
- Steps to reproduce (for bugs)
- Sample code demonstrating the issue
- Expected vs actual behavior
📝 License
This project is licensed under the MIT License.
Copyright (c) 2025 Hamed Fathi
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- JsonSchema.Net (>= 7.4.0)
- JsonSchema.Net.Generation (>= 5.1.1)
- Microsoft.Extensions.AI.Abstractions (>= 10.0.0)
- System.Text.Json (>= 10.0.0)
-
net10.0
- JsonSchema.Net (>= 7.4.0)
- JsonSchema.Net.Generation (>= 5.1.1)
- Microsoft.Extensions.AI.Abstractions (>= 10.0.0)
-
net8.0
- JsonSchema.Net (>= 7.4.0)
- JsonSchema.Net.Generation (>= 5.1.1)
- Microsoft.Extensions.AI.Abstractions (>= 10.0.0)
-
net9.0
- JsonSchema.Net (>= 7.4.0)
- JsonSchema.Net.Generation (>= 5.1.1)
- Microsoft.Extensions.AI.Abstractions (>= 10.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.