Skip to content

Full user-flow trace + "is Semantic Kernel used on the Claude path" — answered

Short answer to the SK question

No — not even indirectly, and one more piece of it is provably dead code.

On the Claude path, Semantic Kernel contributes exactly two things, both trivial: 1. ChatMessageContent / AuthorRole are used as a shared DTO type for chat history — nothing more. ChatRequestHandler builds List<ChatMessageContent> from DB rows purely because that was presumably the existing type before the native SDK migration, and nothing has forced changing it. 2. SunriseSqlPlugin.ExecuteSqlAsync still carries the [KernelFunction("execute_sql")] attribute (needed for when SK genuinely IS driving — the Gemini/OpenAI/Groq paths). On the Claude path it's called as a plain C# methodawait sqlPlugin.ExecuteSqlAsync(sql) — completely bypassing SK's function-invocation pipeline. The attribute is inert metadata in this code path, not exercised.

No Kernel object is ever constructed for Claude. Proof, directly in AgentService. RunAsync:

if (_aiSettings.ActiveProvider.ToLower() == "claude")
{
    return await RunWithAnthropicSdkAsync(...);   // <-- returns HERE
}

var (kernel, executionSettings) = _kernelFactory.Create(sqlPlugin, onStatusUpdate); // never reached for claude

The claude branch returns before _kernelFactory.Create(...) is ever called.

Bonus finding, follow-on from that: KernelFactory.Create itself still has a case "claude": branch (comment: "Native Anthropic SDK path. No SK chat completion connector registered.") and a claude-specific executionSettings branch further down that builds an empty PromptExecutionSettings(). Both are unreachable in the current codebaseKernelFactory.Create is simply never invoked when the provider is Claude, because AgentService short-circuits first. This looks like leftover scaffolding from before the native Anthropic SDK path existed (when Claude presumably did run through SK's own Anthropic-via-OpenAI-compat connector or similar), never cleaned up after the migration to the hand-rolled loop. Functionally harmless (dead code, not wrong code) but a good "I read the whole call graph, not just the method I was pointed at" answer if this ever comes up.

Where SK is genuinely still load-bearing: the Gemini/OpenAI/Groq branches in KernelFactory.Create register real SK connectors (AddGoogleAIGeminiChatCompletion, AddOpenAIChatCompletion ×2) and rely on SK's FunctionChoiceBehavior.Auto() for automatic tool-calling — that's the "legacy/alternate provider path" CLAUDE.md refers to. SK is fully real and fully used there; it's specifically absent from the path that matters in production.

Full request trace, endpoint to persisted row

POST /api/chat/stream  (or /api/chat, non-streaming twin)
    
    ├─ MerchantResolutionMiddleware (host header  MerchantConfig, stashed in HttpContext.Items)
    ├─ JwtBearer auth  RequestUser (ContactId, Username, ChannelId, ...)
    
    
ChatEndpoints  validates request, opens SseWriter if streaming
    
    
ChatRequestHandler.HandleAsync
    
    ├─ new MariaDbExecutor + EnsureTablesExistAsync()
         idempotent  creates ai_chat_sessions / ai_token_usage if missing, every call
    
    ├─ Load session (or create new) from MariaDB
    ├─ Load last 10 messages for this session, strip to ChatMessageContent
         (only Role + ContentText survive  no raw SQL, no chart JSON, no metadata
          carried into the next turn's context — deliberate token-cost trim)


AgentService.RunAsync

    ├─ Open READONLY DuckDB connection for this merchant  (per-request, disposed via `using`)
    ├─ new QueryExecutor + new SunriseSqlPlugin  (also per-request — no shared/singleton state)
    ├─ branch on ActiveProvider → RunWithAnthropicSdkAsync for "claude"


RunWithAnthropicSdkAsync   (see 02-agentic-loop.md for the loop internals)

    ├─ build cached system prompt + message history
    ├─ while (tool_use): run SQL / hit sqlResultCache, call onStatusUpdate(...), call Claude again
    ├─ extract + parse final JSON → AgentResponse


back in ChatRequestHandler

    ├─ build assistantMessage (ContentText + serialized Charts) — NOT saved yet
    ├─ build tokenRecord (input/output/cache tokens, tool rounds, api calls, model name)

    ├─ return agentResponse to the endpoint IMMEDIATELY (200 OK / final SSE "result" event)
    │      ▲ this happens BEFORE persistence below — response latency to the user is not
    │        gated on any DB write

    └─ _ = Task.Run(async () => { ... })   ← fire-and-forget background persistence

             ├─ builds its OWN fresh MariaDbExecutor/repos from a raw connection string
             │     — NOT the scoped ones used above. Deliberate: HttpContext and any
             │     scoped DI services are gone/disposed by the time this background task
             │     actually runs (request may have already completed), so it can't touch
                  anything HttpContext-bound. Thread-safety is achieved by only ever
                  passing POCOs + a raw connection string across that boundary.
             
             ├─ SaveSessionAsync, SaveMessageAsync ×2 (user + assistant), LogTokenUsageAsync
             └─ any failure here is caught, logged as a warning, and silently swallowed 
                   the user already has their answer; a failed audit write never surfaces
                   to them or retries. (Worth knowing this trade-off: session/message/
                   token-usage rows can be lost on a transient MariaDB blip with zero
                   visibility beyond a log line.)

Streaming vs non-streaming — what actually differs

Both endpoints call the exact same ChatRequestHandler.HandleAsync. The only difference is whether an onStatusUpdate callback is passed: - Non-streaming (/api/chat): onStatusUpdate = null — no status callback exists, so every if (onStatusUpdate != null) await onStatusUpdate(...) call site in RunWithAnthropicSdkAsync is simply skipped. Client gets one response, no interim status. - Streaming (/api/chat/stream): callback wraps SseWriter.WriteAsync("status", ...). Same agent loop, same code path — streaming is purely "is anyone listening for status events," not a structurally different execution.

SseWriter itself is intentionally minimal — raw event:/data: SSE framing, newline-escaped to avoid breaking the wire format, no reconnection/event-id/retry semantics (no Last-Event-ID support). Fine for a single request/response chat turn that either completes or the connection just drops; not built to be a resumable stream.

Quick recall drill

Q: Is Semantic Kernel used anywhere in the Claude request path? A: Only as a source of two shared types (ChatMessageContent, AuthorRole) for the chat-history DTO — no Kernel is built, no plugin invocation pipeline runs, no execution settings apply. KernelFactory.Create isn't even called for Claude; the "claude" branch inside it is unreachable dead code from before the native SDK migration.

Q: Why does the background persistence task build its own fresh DB executor instead of reusing the scoped one from the request? A: Thread/lifetime safety — the background Task.Run can outlive the HTTP request and its scoped DI container. It only crosses that boundary with POCOs and a raw connection string, never a scoped service or HttpContext reference.

Q: What happens if the fire-and-forget session/message save fails? A: Logged as a warning, silently swallowed. The user already has their answer by then (the response was returned before the background task even started) — there's no retry and no user-visible signal that the audit trail didn't persist.

Q: What's structurally different between the streaming and non-streaming endpoints? A: Nothing in the agent loop itself — same ChatRequestHandler.HandleAsync call either way. The only difference is whether a non-null onStatusUpdate callback exists for the loop to invoke at each status checkpoint.