C5 — SDK Chat (POST /api/chat)
Summary — what this page covers This is the first Day 2 lab. You add the official Anthropic C# SDK to BookTracker and build a working
POST /api/chatendpoint: a BookTracker-scoped assistant with multi-turn history and prompt caching. Core stays SDK-free — onlyBookTracker.Apireferences the package.Time: ~45 minutes · Format: hands-on, solo · You start from:
checkpoint/c4-reading-progress· You end at:checkpoint/c5-sdk-chat
Day 1 used Claude Code to build BookTracker. Day 2 calls the Anthropic API from BookTracker. C5 is the foundation every later Day 2 lab builds on (C6 streaming + tools, C7 RAG, C10 hardening). You start from the full Day 1 solution and finish with a chat endpoint that talks to Claude, remembers the conversation across requests, and caches a large system prompt to save tokens.
No Docker in this lab. You do need an Anthropic API key (a separate credential from the paid Claude plan you used on Day 1). The key goes in user-secrets — never commit it.
1. Start from the C4 checkpoint
Each lab starts from the previous checkpoint; the matching tag is the answer key. If you're continuing your own branch from Day 1, make sure it's at the C4 state. Otherwise, branch from the tag:
git switch -c my-c5 checkpoint/c4-reading-progress
# everything below runs inside the solution folder
cd src/BookTracker
The full as-built solution lives at
checkpoint/c5-sdk-chat. Use it as the answer key if you get stuck, and seeSOLUTION-C5-BUILD-SHEET.mdfor the file-by-file spec.
2. Add the Anthropic SDK
Add the official Claude C# SDK to the Api project only:
dotnet add BookTracker.Api package Anthropic
The as-built solution uses Anthropic v12.31.0. Confirm it lands in BookTracker.Api.csproj and
not in Core or Data — keeping the SDK out of Core is a hard rule for this lab.
The as-built solution does not add a separate resilience package. The Anthropic SDK already retries transient
429/5xxfailures with exponential backoff, so an explicit Polly /Microsoft.Extensions.Http.Resiliencepipeline is an optional teaching add-on, not required here.
3. Add the chat DTOs and interface (Core stays pure)
These live in BookTracker.Core and use only Core/primitive types — no Anthropic types — so the
contract stays SDK-free.
Create in BookTracker.Core/Dtos:
public record ChatTurn(string Role, string Content); // "user" / "assistant"
public record ChatRequest(string SessionId, string Message);
public record ChatResponse(string Reply, int InputTokens, int OutputTokens, int CacheReadInputTokens);
Create the interface in BookTracker.Core/Services:
public interface IClaudeService
{
Task<ChatResponse> ChatAsync(string userMessage, IReadOnlyList<ChatTurn> history, CancellationToken ct);
}
ChatResponse surfaces token usage on purpose — that's how you'll see prompt caching work in step 8.
4. Add AnthropicOptions (Api)
Create BookTracker.Api/Options/AnthropicOptions.cs bound from the "Anthropic" config section:
public class AnthropicOptions
{
public string ApiKey { get; set; } = "";
public string Model { get; set; } = "claude-sonnet-4-6";
public int MaxTokens { get; set; } = 1024;
public string SystemPrompt { get; set; } = "";
}
The ApiKey is filled from user-secrets (step 6); Model, MaxTokens, and SystemPrompt come from
appsettings.json (step 7).
5. Implement ClaudeService (the SDK wrapper)
Create BookTracker.Api/Services/ClaudeService.cs implementing IClaudeService. It maps stored
history → SDK messages, appends the new user turn, sends a cached system prompt, calls
Messages.Create, and returns the reply text plus usage. Illustrative shape (full code in the answer
key):
using Anthropic;
using Anthropic.Models.Messages;
var messages = history
.Select(t => new MessageParam {
Role = t.Role == "assistant" ? Role.Assistant : Role.User,
Content = t.Content,
})
.Append(new MessageParam { Role = Role.User, Content = userMessage })
.ToList();
var response = await _client.Messages.Create(new MessageCreateParams {
Model = _opts.Model,
MaxTokens = _opts.MaxTokens,
System = new List<TextBlockParam> {
new() { Text = _opts.SystemPrompt, CacheControl = new CacheControlEphemeral() },
},
Messages = messages,
}, ct);
var reply = string.Concat(response.Content.Select(b => b.Value).OfType<TextBlock>().Select(t => t.Text));
return new ChatResponse(reply,
response.Usage.InputTokens, response.Usage.OutputTokens, response.Usage.CacheReadInputTokens);
Two things to internalize:
-
The API is stateless. The full history is sent on every call — that's why the endpoint persists it (step 7).
-
Mark the system prompt with
CacheControlEphemeral. That's what enables prompt caching — but it only fires above a minimum size (step 8's gotcha).
6. Set the API key in user-secrets
The key is a secret — keep it out of appsettings.json and out of git:
dotnet user-secrets init --project BookTracker.Api
dotnet user-secrets set "Anthropic:ApiKey" "sk-ant-..." --project BookTracker.Api
User-secrets bind into the same "Anthropic" config section, so AnthropicOptions.ApiKey is populated
at startup without ever touching a committed file.
7. Wire config, DI, and the endpoint
appsettings.json — add an "Anthropic" section. The SystemPrompt must be substantial (see
step 8):
{
"Anthropic": {
"Model": "claude-sonnet-4-6",
"MaxTokens": 1024,
"SystemPrompt": "You are the BookTracker assistant. Answer only about books, authors, reviews, and reading progress in this app. ...(pad this — see step 8)"
}
}
Program.cs — register options, the client, the service, and the cache:
builder.Services.Configure<AnthropicOptions>(builder.Configuration.GetSection("Anthropic"));
builder.Services.AddSingleton(sp =>
new AnthropicClient { ApiKey = sp.GetRequiredService<IOptions<AnthropicOptions>>().Value.ApiKey });
builder.Services.AddScoped<IClaudeService, ClaudeService>();
builder.Services.AddDistributedMemoryCache();
// ...
app.MapChatEndpoints();
AnthropicClientis a Singleton on purpose — it holds anHttpClient, so registering it Scoped or Transient risks socket exhaustion.IClaudeServiceis Scoped.
Endpoints/ChatEndpoints.cs — a thin POST /api/chat handler. It loads history from
IDistributedCache keyed by SessionId, calls IClaudeService.ChatAsync, appends the new user +
assistant turns, saves the history back, and returns the ChatResponse. Keep it async and thread the
CancellationToken. History is a JSON-serialized List<ChatTurn>, so the conversation survives across
HTTP requests even though the API itself is stateless.
8. Make prompt caching actually fire ⚠️
Marking the system prompt with CacheControlEphemeral (step 5) isn't enough on its own. Caching has
a model-dependent minimum prefix:
- Sonnet 4.6 ≈ 2,048 tokens
- Haiku 4.5 / Opus ≈ 4,096 tokens
A short BookTracker system prompt (a few hundred tokens) silently won't cache —
CacheCreationInputTokens stays 0 and CacheReadInputTokens never climbs. The demo falls flat with
no error.
Fix: pad the SystemPrompt past ~2,048 tokens with real BookTracker domain context — a schema
summary (books, authors, reviews, reading-progress), the answer style you want, and a few example
Q&A pairs. This is genuine grounding and it crosses the caching threshold.
# rebuild and run
dotnet build BookTracker.sln
dotnet run --project BookTracker.Api # → Now listening on: http://localhost:5255
In a second terminal, call the endpoint twice with the same SessionId:
curl -X POST http://localhost:5255/api/chat \
-H 'Content-Type: application/json' \
-d '{"sessionId":"s1","message":"How many books are in my library?"}'
curl -X POST http://localhost:5255/api/chat \
-H 'Content-Type: application/json' \
-d '{"sessionId":"s1","message":"Which one has the most pages?"}'
- The first call writes the cache (
CacheCreationInputTokens> 0 on the server side). - The second call should return
cacheReadInputTokens> 0 — the cached prefix bills at ~10%. - Because both calls share
sessionId: "s1", the second reply should reflect the first turn's context — multi-turn history is working.
✅ Checkpoint — you're done when:
-
dotnet buildis green and theAnthropicpackage restores. -
Anthropic:ApiKeyis set in user-secrets and is not committed anywhere. -
POST /api/chatreturns a Claude reply scoped to BookTracker (it declines off-topic asks). - Multi-turn history persists across requests that share a
SessionId. - With a sufficiently large system prompt, the second call shows
cacheReadInputTokens > 0. -
dotnet testis still green — the Day 1 tests are unaffected. - Core has no reference to the
Anthropicpackage — onlyBookTracker.Apidoes.
Tag this state checkpoint/c5-sdk-chat once the above passes.
What's next
C6 (Day 2, Lab 2): you'll add streaming responses (server-sent events) and a tool-calling
agent on top of this same AnthropicClient + ClaudeService foundation — including a tool that
updates reading progress through the C4 service.