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# method —
await 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
codebase — KernelFactory.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.