Files
AgenticCode/openspec/exports/migrate-to-semantic-kernel-recipe.md
local d3300c7db9 feat: add extract-feature and export-spec portability skills
Two new OpenSpec skills for porting features to sandboxed codebases:
- /opsx:extract-feature generates minimal, printable code recipes
- /opsx:export-spec generates compact specs for AI-assisted reimplementation
Both support cumulative dependency analysis across archived changes.

Includes first export of migrate-to-semantic-kernel in all three formats:
code recipe (~120 lines), portable spec (~40 lines), OpenSpec variant (~25 lines).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 00:59:06 +01:00

8.6 KiB

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:

<PackageReference Include="Microsoft.SemanticKernel" Version="1.74.0" />
<PackageReference Include="Microsoft.SemanticKernel.Connectors.OpenAI" Version="1.74.0" />

Step 1: New file — Shared/Models/ChatMessage.cs (~6 lines)

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)

namespace __YOUR_NAMESPACE__.Shared.Models
{
    public class ChatRequest
    {
        public List<ChatMessage> Messages { get; set; } = new();
    }
}

Step 3: New file — Shared/Models/ExtractedFields.cs (~12 lines)

// 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)

namespace __YOUR_NAMESPACE__.Shared.Models
{
    public class ValidationResult
    {
        public bool IsValid { get; set; }
        public List<string> Errors { get; set; } = new();
    }
}

Step 5: New file — Plugins/ExtractionPlugin.cs (~35 lines)

// 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<ExtractedFields>(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)

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<IChatCompletionService>();
                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<ExtractionPlugin>();
                _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();:

// 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:

"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()