# Feature Recipe: Semantic Kernel Chat with Tool Calling **Source**: migrate-to-semantic-kernel + wire-responses-api | **Lines to type**: ~120 **Included**: wire-responses-api (SSE streaming), migrate-to-semantic-kernel (SK + plugins) **Skipped**: wire-responses-api's manual HttpClient proxy (superseded by SK) **Skipped**: basic-chat-interface (UI — not selected), multi-turn-conversations (not selected) --- ## Prerequisites Add to your API `.csproj`: ```xml ``` --- ## Step 1: New file — Shared/Models/ChatMessage.cs (~6 lines) ```csharp namespace __YOUR_NAMESPACE__.Shared.Models { public class ChatMessage { public string Role { get; set; } = string.Empty; public string Content { get; set; } = string.Empty; public DateTime Timestamp { get; set; } } } ``` ## Step 2: New file — Shared/Models/ChatRequest.cs (~6 lines) ```csharp namespace __YOUR_NAMESPACE__.Shared.Models { public class ChatRequest { public List Messages { get; set; } = new(); } } ``` ## Step 3: New file — Shared/Models/ExtractedFields.cs (~12 lines) ```csharp // ADAPT: Replace these fields with your domain's extraction schema namespace __YOUR_NAMESPACE__.Shared.Models { public class ExtractedFields { public string? Client { get; set; } public string? Project { get; set; } public decimal? Hours { get; set; } public decimal? Rate { get; set; } public string? Currency { get; set; } public string? Date { get; set; } public string? Description { get; set; } public string? PoNumber { get; set; } } } ``` ## Step 4: New file — Shared/Models/ValidationResult.cs (~5 lines) ```csharp namespace __YOUR_NAMESPACE__.Shared.Models { public class ValidationResult { public bool IsValid { get; set; } public List Errors { get; set; } = new(); } } ``` ## Step 5: New file — Plugins/ExtractionPlugin.cs (~35 lines) ```csharp // ADAPT: Change RequiredFields and validation logic to match your ExtractedFields using System.ComponentModel; using System.Text.Json; using __YOUR_NAMESPACE__.Shared.Models; using Microsoft.SemanticKernel; namespace __YOUR_NAMESPACE__.Api.Plugins { public class ExtractionPlugin { private static readonly string[] RequiredFields = { "Client", "Project", "Hours", "Rate", "Currency", "Date" }; [KernelFunction("validate_extracted_fields")] [Description("Validates extracted key-value fields against the required schema. " + "Call this after extracting fields from natural language text to check " + "that all required fields (Client, Project, Hours, Rate, Currency, Date) " + "are present and correctly typed. Returns validation result with any errors.")] public string ValidateExtractedFields( [Description("JSON string of extracted fields")] string fieldsJson) { var result = new ValidationResult(); ExtractedFields? fields; try { fields = JsonSerializer.Deserialize(fieldsJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); } catch (JsonException ex) { result.IsValid = false; result.Errors.Add($"Invalid JSON: {ex.Message}"); return JsonSerializer.Serialize(result); } if (fields == null) { result.IsValid = false; result.Errors.Add("Deserialized fields object is null"); return JsonSerializer.Serialize(result); } if (string.IsNullOrWhiteSpace(fields.Client)) result.Errors.Add("Missing required field: Client"); if (string.IsNullOrWhiteSpace(fields.Project)) result.Errors.Add("Missing required field: Project"); if (fields.Hours == null || fields.Hours <= 0) result.Errors.Add("Missing or invalid required field: Hours (must be positive)"); if (fields.Rate == null || fields.Rate <= 0) result.Errors.Add("Missing or invalid required field: Rate (must be positive)"); if (string.IsNullOrWhiteSpace(fields.Currency)) result.Errors.Add("Missing required field: Currency"); if (string.IsNullOrWhiteSpace(fields.Date)) result.Errors.Add("Missing required field: Date"); result.IsValid = result.Errors.Count == 0; return JsonSerializer.Serialize(result); } } } ``` ## Step 6: New file — Controllers/ChatController.cs (~40 lines) ```csharp using System.Text.Json; using __YOUR_NAMESPACE__.Api.Plugins; using __YOUR_NAMESPACE__.Shared.Models; using Microsoft.AspNetCore.Mvc; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.Connectors.OpenAI; namespace __YOUR_NAMESPACE__.Api.Controllers { [ApiController] [Route("api/[controller]")] public class ChatController : ControllerBase { private readonly Kernel _kernel; public ChatController(Kernel kernel) { _kernel = kernel; } [HttpPost] public async Task Post([FromBody] ChatRequest request) { Response.ContentType = "text/event-stream"; Response.Headers["Cache-Control"] = "no-cache"; try { var chatService = _kernel.GetRequiredService(); var chatHistory = new ChatHistory(); foreach (var msg in request.Messages) { if (msg.Role == "user") chatHistory.AddUserMessage(msg.Content); else if (msg.Role == "assistant") chatHistory.AddAssistantMessage(msg.Content); } var plugin = HttpContext.RequestServices.GetRequiredService(); _kernel.ImportPluginFromObject(plugin, "Extraction"); var settings = new OpenAIPromptExecutionSettings { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }; await foreach (var chunk in chatService.GetStreamingChatMessageContentsAsync( chatHistory, settings, _kernel, HttpContext.RequestAborted)) { if (!string.IsNullOrEmpty(chunk.Content)) { await WriteSSEAsync($"{{\"text\":{JsonSerializer.Serialize(chunk.Content)}}}"); await Response.Body.FlushAsync(); } } await WriteSSEAsync("[DONE]"); } catch (HttpRequestException ex) { await WriteSSEAsync($"{{\"error\":{JsonSerializer.Serialize($"Failed to reach LLM service: {ex.Message}")}}}"); await WriteSSEAsync("[DONE]"); } catch (TaskCanceledException) { } } private async Task WriteSSEAsync(string data) { await Response.WriteAsync($"data: {data}\n\n"); await Response.Body.FlushAsync(); } } } ``` ## Step 7: Add to Program.cs (~12 lines) Add `using Microsoft.SemanticKernel;` at the top. Insert after `builder.Services.AddControllers();`: ```csharp // ADAPT: Change BaseUrl and Model for your proxy/LLM var baseUrl = builder.Configuration["ResponsesApi:BaseUrl"] ?? "http://localhost:8317/v1"; var model = builder.Configuration["ResponsesApi:Model"] ?? "claude-sonnet-4-6"; builder.Services.AddOpenAIChatCompletion( modelId: model, endpoint: new Uri(baseUrl), apiKey: builder.Configuration["ResponsesApi:ApiKey"] ?? "not-needed"); builder.Services.AddKernel(); builder.Services.AddSingleton<__YOUR_NAMESPACE__.Api.Plugins.ExtractionPlugin>(); ``` ## Step 8: Add to appsettings.json (~4 lines) Add this section: ```json "ResponsesApi": { "BaseUrl": "http://localhost:8317/v1", "Model": "claude-sonnet-4-6" } ``` --- ## Gotchas - **Base URL must include `/v1`** — the OpenAI SDK appends `chat/completions` directly, so without `/v1` you get a 404 - **`using Microsoft.SemanticKernel;`** is required in Program.cs for the `AddOpenAIChatCompletion` extension method to resolve - **Plugin is imported per-request** via `_kernel.ImportPluginFromObject` — do not register it in the kernel at startup - **CORS**: If your Blazor client is on a different port, add CORS policy in Program.cs before `app.Run()`