Real-world scenarios with step-by-step implementation guides. Each tutorial includes sample code and links to working examples.
- 1. Support/Triage Bot
- 2. Form-Based Chat
- 3. Streaming & Real-Time Updates (SSE)
- 4. Widget Actions & Interactions
Build an intelligent support bot that classifies user questions and routes them to specialized agents (billing, technical, general inquiry).
- Intent classification with
TriageAgent - Routing to specialized agents
- Using inter-agent communication metadata
- Handling different conversation contexts
public enum UserIntent
{
TechnicalSupport,
BillingInquiry,
GeneralQuestion
}using BbQ.ChatWidgets.Agents.Abstractions;
public class SupportClassifier : IClassifier<UserIntent>
{
private readonly IChatClient _chatClient;
public async Task<UserIntent> ClassifyAsync(string message, CancellationToken cancellationToken)
{
var systemPrompt = """
Classify the user's message into one of these categories:
- TechnicalSupport: Issues with software, bugs, errors, login problems
- BillingInquiry: Questions about payments, invoices, subscriptions, pricing
- GeneralQuestion: Everything else (product info, how-to, general questions)
Reply with ONLY the category name.
""";
var options = new ChatOptions { ToolMode = ChatToolMode.None };
var response = await _chatClient.GetResponseAsync(
[new ChatMessage(ChatRole.User, systemPrompt + "\n\nUser message: " + message)],
options,
cancellationToken);
return Enum.Parse<UserIntent>(response.Text.Trim(), ignoreCase: true);
}
}Use InterAgentCommunicationContext.GetUserMessage(request) to read the user message — the triage agent writes it there automatically.
using BbQ.ChatWidgets.Agents;
using BbQ.ChatWidgets.Agents.Abstractions;
using BbQ.ChatWidgets.Models;
using BbQ.Outcome;
using Microsoft.Extensions.AI;
public class TechnicalSupportAgent : IAgent
{
private readonly IChatClient _chatClient;
public TechnicalSupportAgent(IChatClient chatClient) => _chatClient = chatClient;
public async Task<Outcome<ChatTurn>> InvokeAsync(ChatRequest request, CancellationToken cancellationToken)
{
var userMessage = InterAgentCommunicationContext.GetUserMessage(request) ?? string.Empty;
var systemPrompt = """
You are a technical support specialist. Help users troubleshoot issues.
Use buttons for common actions like 'reset_password', 'clear_cache', 'contact_tech'.
Wrap widgets in <widget>...</widget> tags with JSON inside.
""";
var response = await _chatClient.GetResponseAsync(
[
new ChatMessage(ChatRole.System, systemPrompt),
new ChatMessage(ChatRole.User, userMessage)
],
cancellationToken: cancellationToken);
var turn = new ChatTurn(ChatRole.Assistant, response.Text, ThreadId: request.ThreadId ?? "");
return Outcome<ChatTurn>.From(turn);
}
}
public class BillingAgent : IAgent
{
private readonly IChatClient _chatClient;
public BillingAgent(IChatClient chatClient) => _chatClient = chatClient;
public async Task<Outcome<ChatTurn>> InvokeAsync(ChatRequest request, CancellationToken cancellationToken)
{
var userMessage = InterAgentCommunicationContext.GetUserMessage(request) ?? string.Empty;
var systemPrompt = """
You are a billing specialist. Help with invoices, payments, and subscriptions.
Use buttons for: 'view_invoices', 'update_payment', 'cancel_subscription'.
Wrap widgets in <widget>...</widget> tags with JSON inside.
""";
var response = await _chatClient.GetResponseAsync(
[
new ChatMessage(ChatRole.System, systemPrompt),
new ChatMessage(ChatRole.User, userMessage)
],
cancellationToken: cancellationToken);
var turn = new ChatTurn(ChatRole.Assistant, response.Text, ThreadId: request.ThreadId ?? "");
return Outcome<ChatTurn>.From(turn);
}
}Agents are registered as keyed DI services via AddAgent<TAgent>(name). The IAgentRegistry implementation resolves them from the DI container — there is no AgentRegistry constructor or Register() method.
// In Program.cs
using BbQ.ChatWidgets.Agents;
using BbQ.ChatWidgets.Agents.Abstractions;
// 1. Register the classifier
services.AddScoped<IClassifier<UserIntent>, SupportClassifier>();
// 2. Register specialized agents as keyed DI services
services.AddAgent<TechnicalSupportAgent>("tech-support");
services.AddAgent<BillingAgent>("billing");
services.AddAgent<GeneralAgent>("general");
// 3. Register the triage agent with routing mapping
services.AddScoped(sp =>
{
var classifier = sp.GetRequiredService<IClassifier<UserIntent>>();
var registry = sp.GetRequiredService<IAgentRegistry>();
var threadService = sp.GetService<IThreadService>();
Func<UserIntent, string?> routingMapping = intent => intent switch
{
UserIntent.TechnicalSupport => "tech-support",
UserIntent.BillingInquiry => "billing",
UserIntent.GeneralQuestion => "general",
_ => null // falls back to fallbackAgentName
};
return new TriageAgent<UserIntent>(
classifier,
registry,
routingMapping,
fallbackAgentName: "general",
threadService: threadService
);
});Set the user message via InterAgentCommunicationContext before invoking the triage agent.
// The triage agent automatically classifies and routes
app.MapPost("/api/chat/support", async (
string message,
string threadId,
HttpContext httpContext,
TriageAgent<UserIntent> triageAgent) =>
{
var chatRequest = new ChatRequest(threadId, httpContext.RequestServices);
InterAgentCommunicationContext.SetUserMessage(chatRequest, message);
var outcome = await triageAgent.InvokeAsync(chatRequest, CancellationToken.None);
return outcome.IsSuccess
? Results.Ok(outcome.Value)
: Results.Problem(outcome.Error?.ToString());
});Run the Console Sample or React Sample which both include triage examples.
# Console sample
cd Sample/BbQ.ChatWidgets.Sample.Console
dotnet run
# Try these messages:
# "I can't log in" → Routes to TechnicalSupportAgent
# "Where is my invoice?" → Routes to BillingAgent
# "What features do you offer?" → Routes to GeneralAgentCollect structured information from users through conversational forms (contact forms, surveys, registration, etc.).
- Creating forms with multiple input types
- Handling form submissions
- Validating user input
- Providing feedback
public record ContactFormAction : IWidgetAction<ContactFormPayload>
{
public string ActionId => "submit_contact";
}
public record ContactFormPayload(
string Name,
string Email,
string Message,
bool Subscribe);public class ContactFormHandler : IActionWidgetActionHandler<ContactFormAction, ContactFormPayload>
{
private readonly ILogger<ContactFormHandler> _logger;
public async Task<string> HandleAsync(
ContactFormAction action,
ContactFormPayload payload,
string threadId,
CancellationToken cancellationToken)
{
// Validate
if (string.IsNullOrWhiteSpace(payload.Email) || !payload.Email.Contains("@"))
{
return "❌ Please provide a valid email address.";
}
// Process the form (save to DB, send email, etc.)
_logger.LogInformation(
"Contact form submitted: {Name} ({Email}), Subscribe: {Subscribe}",
payload.Name, payload.Email, payload.Subscribe);
// Simulate processing
await Task.Delay(500, cancellationToken);
// Return confirmation with a button
return $"""
✅ Thank you, {payload.Name}! We've received your message and will get back to you soon.
<widget>
{{
"type": "button",
"label": "Submit Another",
"action": "new_contact_form"
}}
</widget>
""";
}
}// In Program.cs
services.AddBbQChatWidgets(options =>
{
options.ChatClientFactory = sp => chatClient;
// Register the action handler
options.ActionRegistry.RegisterHandler<
ContactFormAction,
ContactFormPayload,
ContactFormHandler>();
});
services.AddScoped<ContactFormHandler>();When the user says "I want to contact support" or "Show me a contact form", the LLM will generate:
<widget>
{
"type": "form",
"title": "Contact Us",
"action": "submit_contact",
"fields": [
{"name": "Name", "label": "Name", "type": "input", "required": true, "placeholder": "Your full name"},
{"name": "Email", "label": "Email", "type": "input", "required": true, "placeholder": "you@example.com"},
{"name": "Message", "label": "Message", "type": "textarea", "required": true, "rows": 4, "placeholder": "How can we help?"},
{"name": "Subscribe", "label": "Subscribe to newsletter", "type": "toggle", "required": false}
],
"actions": [
{"type": "submit", "label": "Send Message"},
{"type": "cancel", "label": "Cancel"}
]
}
</widget>The WidgetManager automatically handles form submission:
import { WidgetManager } from '@bbq-chat/widgets';
const manager = new WidgetManager();
// Render the form widget
manager.render(formWidget, container);
// When user clicks submit, WidgetManager automatically:
// 1. Collects all input values
// 2. POSTs to /api/chat/action with action="submit_contact" and payload
// 3. Displays the responseRun the React Sample:
cd Sample/BbQ.ChatWidgets.Sample.React
dotnet run
# In the browser, type:
# "Show me a contact form"
# Fill out the form and submitPush live updates to the client without polling (stock prices, progress updates, notifications, live charts).
- Server-Sent Events (SSE) integration
- Publishing widget updates from background services
- Creating SSE-powered widgets
- Real-time data streaming
public record ClockWidget(string Label, string StreamId)
: ChatWidget(Label, "clock_action")
{
public override string Purpose => "Displays real-time clock updates via SSE";
}services.AddBbQChatWidgets(options =>
{
options.ChatClientFactory = sp => chatClient;
options.WidgetRegistryConfigurator = registry =>
{
registry.Register(new ClockWidget("Live Clock", "clock-stream"));
};
options.StreamValidationRules = new StreamValidationRules
{
AllowedStreamIds = new[] { "clock-stream", "weather-stream" },
MaxPublishRatePerMinute = 60
};
});public class ClockPublisher : BackgroundService
{
private readonly IWidgetSseService _sseService;
private readonly ILogger<ClockPublisher> _logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
var now = DateTime.Now;
var clockWidget = new ClockWidget(
$"🕐 {now:HH:mm:ss}",
"clock-stream");
await _sseService.PublishWidgetAsync(
"clock-stream",
clockWidget,
stoppingToken);
await Task.Delay(1000, stoppingToken); // Update every second
}
catch (Exception ex)
{
_logger.LogError(ex, "Error publishing clock update");
}
}
}
}
// Register in Program.cs
services.AddHostedService<ClockPublisher>();import { SseManager } from '@bbq-chat/widgets';
const sseManager = new SseManager('/api/chat/widgets/streams');
// Subscribe to the clock stream
sseManager.subscribe('clock-stream', (widget) => {
console.log('Clock update:', widget);
// Update UI with new time
document.getElementById('clock').textContent = widget.label;
});
// Clean up when done
// sseManager.unsubscribe('clock-stream');When the user asks "Show me a live clock", the LLM responds:
I'll show you a real-time clock that updates every second:
<widget type="clock" label="Live Clock" streamId="clock-stream" />
The clock will update automatically via Server-Sent Events.Perfect for long-running operations:
public class FileUploadService
{
private readonly IWidgetSseService _sseService;
public async Task UploadFileAsync(Stream fileStream, string streamId)
{
var totalBytes = fileStream.Length;
var uploadedBytes = 0L;
var buffer = new byte[8192];
while (uploadedBytes < totalBytes)
{
var bytesRead = await fileStream.ReadAsync(buffer);
uploadedBytes += bytesRead;
var progress = (int)((uploadedBytes / (double)totalBytes) * 100);
// Push progress update
var progressWidget = new ProgressBarWidget(
"Uploading...",
"upload_progress",
progress,
$"{uploadedBytes:N0} / {totalBytes:N0} bytes");
await _sseService.PublishWidgetAsync(streamId, progressWidget);
}
// Send completion widget
var completeWidget = new ButtonWidget("View File", "view_uploaded_file");
await _sseService.PublishWidgetAsync(streamId, completeWidget);
}
}Run the React Sample with SSE:
cd Sample/BbQ.ChatWidgets.Sample.React
dotnet run
# Try: "Show me a live clock" or "Show me live weather"Handle user clicks, form submissions, and custom widget interactions on the server.
- Creating typed action handlers
- Passing payloads with actions
- Chaining widgets (one action triggers another widget)
- Error handling and validation
public record ApproveAction : IWidgetAction<ApprovePayload>
{
public string ActionId => "approve_request";
}
public record ApprovePayload(); // Empty payload for simple button
public class ApproveHandler : IActionWidgetActionHandler<ApproveAction, ApprovePayload>
{
public async Task<string> HandleAsync(
ApproveAction action,
ApprovePayload payload,
string threadId,
CancellationToken cancellationToken)
{
// Process approval
await Task.Delay(100, cancellationToken);
return """
✅ Request approved!
<widget>
{
"type": "button",
"label": "View Details",
"action": "view_details"
}
</widget>
<widget>
{
"type": "button",
"label": "Notify User",
"action": "send_notification"
}
</widget>
""";
}
}public record SelectPlanAction : IWidgetAction<SelectPlanPayload>
{
public string ActionId => "select_plan";
}
public record SelectPlanPayload(string PlanId, string PlanName, decimal Price);
public class SelectPlanHandler : IActionWidgetActionHandler<SelectPlanAction, SelectPlanPayload>
{
public async Task<string> HandleAsync(
SelectPlanAction action,
SelectPlanPayload payload,
string threadId,
CancellationToken cancellationToken)
{
return $"""
You've selected the **{payload.PlanName}** plan at ${payload.Price}/month.
<widget>
{{
"type": "form",
"title": "Complete Purchase",
"action": "confirm_purchase",
"fields": [
{{"name": "cardNumber", "label": "Card Number", "type": "input", "required": true, "placeholder": "1234 5678 9012 3456"}},
{{"name": "expiry", "label": "Expiry", "type": "input", "required": true, "placeholder": "MM/YY"}},
{{"name": "cvv", "label": "CVV", "type": "input", "required": true, "placeholder": "123"}}
],
"actions": [
{{"type": "submit", "label": "Confirm"}},
{{"type": "cancel", "label": "Cancel"}}
]
}}
</widget>
""";
}
}// Step 1: Show plans
public class ShowPlansHandler : IActionWidgetActionHandler<ShowPlansAction, ShowPlansPayload>
{
public async Task<string> HandleAsync(...)
{
return """
Choose your plan:
<widget>
{
"type": "card",
"title": "Basic",
"description": "$9/month",
"label": "Select",
"action": "select_plan"
}
</widget>
<widget>
{
"type": "card",
"title": "Pro",
"description": "$29/month",
"label": "Select",
"action": "select_plan"
}
</widget>
""";
}
}
// Step 2: Select plan (see above)
// Step 3: Confirm purchase
public class ConfirmPurchaseHandler : IActionWidgetActionHandler<ConfirmPurchaseAction, ConfirmPurchasePayload>
{
public async Task<string> HandleAsync(...)
{
// Process payment...
return """
🎉 Payment successful! Welcome to the Pro plan.
<widget>
{
"type": "button",
"label": "Go to Dashboard",
"action": "open_dashboard"
}
</widget>
""";
}
}services.AddBbQChatWidgets(options =>
{
var registry = options.ActionRegistry;
registry.RegisterHandler<ApproveAction, ApprovePayload, ApproveHandler>();
registry.RegisterHandler<SelectPlanAction, SelectPlanPayload, SelectPlanHandler>();
registry.RegisterHandler<ShowPlansAction, ShowPlansPayload, ShowPlansHandler>();
registry.RegisterHandler<ConfirmPurchaseAction, ConfirmPurchasePayload, ConfirmPurchaseHandler>();
});public class PaymentHandler : IActionWidgetActionHandler<PaymentAction, PaymentPayload>
{
public async Task<string> HandleAsync(...)
{
try
{
await ProcessPaymentAsync(payload);
return "✅ Payment successful!";
}
catch (PaymentFailedException ex)
{
return $"""
❌ Payment failed: {ex.Message}
<widget>
{{
"type": "button",
"label": "Try Again",
"action": "retry_payment"
}}
</widget>
<widget>
{{
"type": "button",
"label": "Contact Support",
"action": "contact_support"
}}
</widget>
""";
}
}
}Run any of the samples and interact with buttons:
cd Sample/BbQ.ChatWidgets.Sample.Console
dotnet run
# Try: "Show me some buttons"
# Click the buttons to trigger actions- Custom Widgets Guide - Build your own widget types
- Custom Action Handlers - Advanced action handling
- Chat History Summarization - Manage long conversations
- Triage Agents Deep Dive - Advanced routing patterns
All tutorials reference these working samples:
- Console Sample - Command-line chat with triage
- React Sample - Web UI with forms and SSE
- Angular Sample - Angular components
- Blazor Sample - Server-side Blazor
Each sample includes multiple use cases and is fully runnable.