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/chat endpoint: a BookTracker-scoped assistant with multi-turn history and prompt caching. Core stays SDK-free — only BookTracker.Api references 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 see SOLUTION-C5-BUILD-SHEET.md for 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/5xx failures with exponential backoff, so an explicit Polly / Microsoft.Extensions.Http.Resilience pipeline 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();

AnthropicClient is a Singleton on purpose — it holds an HttpClient, so registering it Scoped or Transient risks socket exhaustion. IClaudeService is 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 cacheCacheCreationInputTokens 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 build is green and the Anthropic package restores.
  • Anthropic:ApiKey is set in user-secrets and is not committed anywhere.
  • POST /api/chat returns 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 test is still green — the Day 1 tests are unaffected.
  • Core has no reference to the Anthropic package — only BookTracker.Api does.

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.