Getting Started with the Claude API from .NET: Console, API Keys, the Anthropic C# SDK and the First Request
To use the Claude API from .NET, install version 10 or later of the official Anthropic package on NuGet (13.8.0 today), keep the key in ANTHROPIC_API_KEY and register AnthropicClient as a singleton. Workspaces and key types in the Console, temperature/top_p/top_k removed in API version 2026-10-21, the Models API and a ClaudeCatalogService for Bookstore.Api.
Version note: The code in this post was built with .NET 10 (SDK 10.0.404), the official Anthropic C# SDK (NuGet Anthropic 13.8.0) and Claude API version 2026-10-21, and its unit tests were run. The Console steps, model names and API behavior were checked on 15 November 2026 against the platform.claude.com documentation and the SDK's GitHub repository (github.com/anthropics/anthropic-sdk-csharp). This post is based on the official documentation.
Guess First
Emre wants to add a two-sentence summary to the book pages of Bookstore, the online bookstore used as the shared example across this site's series. He searches NuGet for "Anthropic", installs the package at the top of the list, copies a sample he found online and adds a Temperature = 0.2 line "for consistent answers". The project does not compile. He downgrades the package, and now it compiles, but the first request comes back with a 400.
What is your guess? Is the problem the package, the copied sample, or the temperature line? By the end of this post you will have seen the right package, the first request, why the API version matters and how to register the Claude client in ASP.NET Core. We covered the provider-neutral basics (tokens, sampling, system prompts) in the LLM Fundamentals series and general use through IChatClient in the AI with .NET series. This series focuses on the Claude API's own surface.
Preparing the Console: Organization, Workspace and API Key
The Claude API is managed in the Claude Console (platform.claude.com). Signing up creates an organization, and every organization has a Default Workspace that cannot be deleted or renamed. A workspace groups API keys, members and limits; by default an organization can have up to 100 workspaces. According to the Workspaces page, you can give your own workspaces a monthly spend limit and per-minute limits on requests, input tokens and output tokens. These limits cannot exceed the organization's limits, only stay below them. The Default Workspace cannot have a spend limit.
A sensible layout for Bookstore: two workspaces, bookstore-dev and bookstore-prod, with a low monthly spend limit and an e-mail notification on prod. That way a runaway loop in development does not eat the production budget.
You create the API key on the Console's API keys page (platform.claude.com/settings/keys) with Create key. The Authentication page (15 November 2026) lists three key types:
| Key type | Acts as | When |
|---|---|---|
| Personal key | You, with your roles and permissions | Your own development and scripts |
| Service account key | A service account | Shared work such as CI and production services |
| Workspace key (legacy) | No one; it belongs to the workspace | Not preferred for new integrations |
The documentation recommends identity-backed keys (personal and service account): when the key's owner leaves the organization, the key stops working. That is why the production key of Bookstore.Api should belong to a service account; the service should not stop when Emre leaves. When you create a key you also pick a lifetime: 3 hours, 1 day, 7 days, 30 days, a custom duration or Never. It cannot be changed later, and an expired key returns 401 authentication_error. If you bind the key to a workspace, requests do not need a workspace ID. A key that is not bound to one needs the anthropic-workspace-id header on every request.
In local development the key goes neither into code nor into appsettings.json. The SDKs read it from the ANTHROPIC_API_KEY environment variable:
# PowerShell (Windows): this session only
$env:ANTHROPIC_API_KEY = "<your-api-key>"
# bash / zsh
export ANTHROPIC_API_KEY="<your-api-key>"
The Get started page offers one more option for local development: run ant auth login with the ant CLI and sign in through the browser. The SDKs then find the credentials the CLI stored. It is cleaner than everyone on the team carrying their own key, but in this post we use the environment variable so that readers can follow the same steps on Windows.
Installing the Right Package: Anthropic 13.x and the Package Name Trap
On NuGet, the Anthropic package has been Anthropic's official C# SDK since version 10. The same package name used to belong to the community-built tryAGI SDK; versions 3.x and below are that code, and it is now published as tryAGI.Anthropic. The project that compiled after Emre's downgrade was most likely that old package: a different project with a different API. The C# SDK page puts this warning at the top. The rule is simple: Anthropic 10 or later, today 13.x.
dotnet new web -n Bookstore.Api -o src/Bookstore.Api --framework net10.0
dotnet add src/Bookstore.Api package Anthropic --version 13.8.0
dotnet list src/Bookstore.Api package
Project 'Bookstore.Api' has the following package references
[net10.0]:
Top-level Package Requested Resolved
> Anthropic 13.8.0 13.8.0
13.8.0 was released on 10 November 2026. The package ships net8.0, net9.0 and netstandard2.0 targets; a .NET 10 project uses the net9.0 build with no extra setting.
The First Request: AnthropicClient, MessageCreateParams and TryPickText
The smallest example is a console app. A client created without arguments takes the key from the environment:
using Anthropic;
using Anthropic.Models.Messages;
// Reads ANTHROPIC_API_KEY from the environment.
AnthropicClient client = new();
MessageCreateParams parameters = new()
{
Model = Model.ClaudeHaiku5_5,
MaxTokens = 300,
Messages = [new UserMessageParam { Content = "Suggest one classic novel for a long train trip." }],
};
Message message = await client.Messages.Create(parameters);
foreach (ContentBlock block in message.Content)
{
if (block.TryPickText(out TextBlock? text))
{
Console.WriteLine(text.Text);
}
}
Console.WriteLine($"stop_reason={message.StopReason?.Raw()} input={message.Usage.InputTokens} output={message.Usage.OutputTokens}");
Three things to notice. The model comes from the SDK's Model enum (Model.ClaudeHaiku5_5, claude-haiku-5-5 in JSON); a string works too, because the property is an ApiEnum<string, Model> with implicit conversions in both directions. MaxTokens is required: it is the upper bound of the answer, not a target. The answer is not one string but a list of blocks in content. Because blocks of different types can arrive (text, thinking, tool_use and so on), the SDK gives each block as a union, and TryPickText takes the text one. Assuming content[0] is always text is the first trap we will look at in the second post.
The sample response in the documentation, for a simple "Hello, Claude" request:
{
"id": "msg_01XFDUDYJgAACzvnptvVoYEL",
"type": "message",
"role": "assistant",
"content": [
{
"type": "text",
"text": "Hello!"
}
],
"model": "claude-opus-5-5",
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": {
"input_tokens": 12,
"output_tokens": 6
}
}
stop_reason says why the model stopped, and usage gives the billed tokens. We will read both throughout this series; the details come in the second post.
The API Version and the Removed Sampling Parameters
Every request to the Claude API goes with an API version. If you use an SDK, you do not pick it: the SDK is pinned to the API version that was current when its major version came out. According to the C# SDK page, 13.0.0 is the first version to use 2026-10-21. If you call the API over HTTP without the SDK, you send the anthropic-version: 2026-10-21 header yourself. Moving to a new API version means moving to the SDK's new major version.
The most visible change in 2026-10-21 is the sampling parameters. The migration guide is clear: a request that sends temperature, top_p or top_k gets a 400, even with the default value. SDK 13 no longer has these three properties at all, which is why Emre's project did not compile. Claude 4.7 and later models already accepted only the default values of these parameters. So the "lower the temperature for consistent answers" setting we tried with a local model in the sampling post of the LLM Fundamentals series has no counterpart on Claude's current models. The guide also says nothing else replaces it and that the behavior should be steered through the prompt. If you want a consistent format, structured outputs in the sixth post is the right tool.
The second change is in authentication. SDK 13 sends the key in an Authorization: Bearer header instead of X-Api-Key; the API accepts both. If a proxy, gateway or log filter in between reads the key from X-Api-Key, it needs an update. The third is the message types: in v13, MessageParam is a union of one class per role (UserMessageParam, AssistantMessageParam, SystemMessageParam). The v12 code new() { Role = Role.User, ... } no longer compiles.
ClaudeCatalogService in Bookstore.Api: Options, DI and a Singleton Client
The console example teaches the basics, but in Bookstore.Api we do not want to create the client with new on every request. AnthropicClient holds the connections and the retry settings; the WithOptions section of the C# SDK page also says the same client reuses its connection and thread pools. So we register it in the dependency injection container as a singleton. Settings come through the options pattern:
using System.ComponentModel.DataAnnotations;
namespace Bookstore.Api.Ai;
public sealed class ClaudeOptions
{
public const string SectionName = "Claude";
// When empty, the SDK reads the key from the ANTHROPIC_API_KEY environment variable.
public string? ApiKey { get; set; }
[Required]
public string Model { get; set; } = "claude-haiku-5-5";
[Range(1, 128_000)]
public int MaxTokens { get; set; } = 400;
[Range(0, 10)]
public int MaxRetries { get; set; } = 2;
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(60);
}
{
"Claude": {
"Model": "claude-haiku-5-5",
"MaxTokens": 400,
"MaxRetries": 2,
"Timeout": "00:01:00"
}
}
The ApiKey property is empty: the key does not go into appsettings.json. In development it comes from an environment variable or user secrets; in production from an environment variable or a secret store such as Azure Key Vault. We covered secret management in detail in the ASP.NET Core Web API series. If you use user secrets, the command is below; the value binds to the options as Claude:ApiKey:
dotnet user-secrets set "Claude:ApiKey" "<your-api-key>" --project src/Bookstore.Api
The registration method:
using Anthropic;
using Microsoft.Extensions.Options;
namespace Bookstore.Api.Ai;
public static class ClaudeServiceCollectionExtensions
{
public static IServiceCollection AddClaude(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<ClaudeOptions>()
.Bind(configuration.GetSection(ClaudeOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
// One client for the whole app: it shares the connection pool and the retry settings.
services.AddSingleton<IAnthropicClient>(sp =>
{
ClaudeOptions options = sp.GetRequiredService<IOptions<ClaudeOptions>>().Value;
return string.IsNullOrWhiteSpace(options.ApiKey)
? new AnthropicClient { MaxRetries = options.MaxRetries, Timeout = options.Timeout }
: new AnthropicClient { ApiKey = options.ApiKey, MaxRetries = options.MaxRetries, Timeout = options.Timeout };
});
return services;
}
}
There are two branches because we want to leave ApiKey unset rather than pass null to it; when it is not set, the SDK looks at the environment variable. Thanks to ValidateOnStart, a wrong setting such as MaxTokens = 0 is caught when the app starts, not on the first user request. Registering the client through the IAnthropicClient interface will also help us in later posts.
Now the service itself. The part that builds the request is a separate static method, because we want to test the shape of the request without going to the API:
using Anthropic;
using Anthropic.Exceptions;
using Anthropic.Models.Messages;
using Bookstore.Api.Ai;
using Microsoft.Extensions.Options;
namespace Bookstore.Api.Catalog;
public sealed class ClaudeCatalogService(
IAnthropicClient claude,
IOptions<ClaudeOptions> options,
ILogger<ClaudeCatalogService> logger)
{
private const string SystemPrompt = """
You write catalog summaries for Bookstore, an online bookstore.
Write exactly two sentences in Turkish. No spoilers, no marketing superlatives.
Use only the facts in the description; do not invent awards or sales figures.
""";
public static MessageCreateParams BuildRequest(Book book, ClaudeOptions settings) => new()
{
Model = settings.Model,
MaxTokens = settings.MaxTokens,
System = SystemPrompt,
Messages =
[
new UserMessageParam
{
Content = $"Title: {book.Title}\nAuthor: {book.Author}\nDescription: {book.Description}",
},
],
};
public async Task<BookSummary?> SummarizeAsync(Book book, CancellationToken cancellationToken)
{
MessageCreateParams parameters = BuildRequest(book, options.Value);
try
{
Message message = await claude.Messages.Create(parameters, cancellationToken);
// content is a list of blocks; join only the text blocks.
string summary = string.Concat(message.Content
.Select(block => block.TryPickText(out TextBlock? text) ? text.Text : string.Empty));
logger.LogInformation(
"Summary for {Isbn}: model={Model} stop={StopReason} input={InputTokens} output={OutputTokens}",
book.Isbn, message.Model.Raw(), message.StopReason?.Raw(),
message.Usage.InputTokens, message.Usage.OutputTokens);
return new BookSummary(book.Isbn, summary.Trim(), message.Model.Raw(),
message.Usage.InputTokens, message.Usage.OutputTokens);
}
catch (AnthropicApiException ex)
{
// A failed call does not crash the request; the endpoint returns 503.
logger.LogWarning(ex, "Claude call failed for {Isbn}", book.Isbn);
return null;
}
}
}
The system prompt is in English, but we ask for the answer in Turkish; this also fits our rule that string literals in code stay in English. The "use only the facts in the description" line makes it harder for the model to invent awards or sales figures. On failure, AnthropicApiException is caught, logged and null is returned. It is the base class of all API errors in the SDK; there are subtypes such as AnthropicBadRequestException for 400 and AnthropicRateLimitException for 429. We will look at how to handle each error in detail in the last post.
The endpoint and Program.cs:
namespace Bookstore.Api.Catalog;
public static class CatalogEndpoints
{
public static IEndpointRouteBuilder MapCatalogEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/books/{isbn}/summary", async Task<IResult> (
string isbn, IBookRepository books, ClaudeCatalogService catalog, CancellationToken ct) =>
{
Book? book = books.FindByIsbn(isbn);
if (book is null)
{
return TypedResults.NotFound();
}
BookSummary? summary = await catalog.SummarizeAsync(book, ct);
return summary is null
? TypedResults.Problem("The summary service is not available right now.", statusCode: StatusCodes.Status503ServiceUnavailable)
: TypedResults.Ok(summary);
});
return app;
}
}
using Bookstore.Api.Ai;
using Bookstore.Api.Catalog;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Services.AddClaude(builder.Configuration);
builder.Services.AddSingleton<IBookRepository, InMemoryBookRepository>();
builder.Services.AddScoped<ClaudeCatalogService>();
WebApplication app = builder.Build();
app.UseExceptionHandler();
app.MapCatalogEndpoints();
if (app.Environment.IsDevelopment())
{
app.MapModelEndpoints();
}
app.Run();
The service is scoped and the client is a singleton. The service holds no state itself; making it scoped will help later when we add per-request information such as the signed-in customer.
Seeing the Request Without Sending It
The SDK's Params classes keep the body to be sent in the RawBodyData property. Serializing it shows the JSON that would go to the API without touching the network. The BuildRequest output for Dune:
{
"model": "claude-haiku-5-5",
"max_tokens": 400,
"system": "You write catalog summaries for Bookstore, an online bookstore.\nWrite exactly two sentences in Turkish. No spoilers, no marketing superlatives.\nUse only the facts in the description; do not invent awards or sales figures.",
"messages": [
{
"role": "user",
"content": "Title: Dune\nAuthor: Frank Herbert\nDescription: On the desert planet Arrakis, young Paul Atreides is caught in a struggle over the spice that controls the galaxy."
}
]
}
There is no temperature in the body, system is a top-level field, and messages holds a single user message. We put the same check into a test:
using System.Text.Json;
using Anthropic.Models.Messages;
using Bookstore.Api.Ai;
using Bookstore.Api.Catalog;
namespace Bookstore.Api.Tests;
public sealed class ClaudeCatalogServiceTests
{
private static readonly Book Dune = new InMemoryBookRepository().FindByIsbn("9780441172719")!;
[Fact]
public void BuildRequest_UsesConfiguredModelAndLimit()
{
MessageCreateParams request = ClaudeCatalogService.BuildRequest(Dune, new ClaudeOptions { Model = "claude-sonnet-5-5", MaxTokens = 300 });
Assert.Equal("claude-sonnet-5-5", request.Model.Raw());
Assert.Equal(300, request.MaxTokens);
Assert.Single(request.Messages);
}
[Fact]
public void BuildRequest_SendsNoSamplingParameters()
{
string body = JsonSerializer.Serialize(ClaudeCatalogService.BuildRequest(Dune, new ClaudeOptions()).RawBodyData);
// API version 2026-10-21 rejects temperature, top_p and top_k with a 400.
Assert.DoesNotContain("temperature", body);
Assert.DoesNotContain("top_p", body);
Assert.DoesNotContain("top_k", body);
}
}
dotnet build
dotnet test --no-build
Bookstore.Api -> src/Bookstore.Api/bin/Debug/net10.0/Bookstore.Api.dll
Bookstore.Api.Tests -> tests/Bookstore.Api.Tests/bin/Debug/net10.0/Bookstore.Api.Tests.dll
Build succeeded.
0 Warning(s)
0 Error(s)
Passed! - Failed: 0, Passed: 2, Skipped: 0, Total: 2, Duration: 70 ms - Bookstore.Api.Tests.dll (net10.0)
Which Models Are Available? The Models API
Before hard-coding model names, it is a good habit to see which models your key can reach. The Models API (GET /v1/models) returns for each model its id, display_name, max_input_tokens (the context window), max_tokens (the output limit), lifecycle (active, deprecated, retired) and a capabilities object. According to the models overview page, capabilities includes effort levels, thinking types, structured outputs and server tool support. We will use these fields for model choice in the third post. A small endpoint that is only mapped in Development:
using Anthropic;
using Anthropic.Models.Models;
namespace Bookstore.Api.Ai;
public static class ModelEndpoints
{
public sealed record ModelRow(string Id, string DisplayName, long? MaxInputTokens, long? MaxTokens, string Lifecycle);
// Development only: lists the models this key can use.
public static IEndpointRouteBuilder MapModelEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/ai/models", async (IAnthropicClient claude, CancellationToken ct) =>
{
List<ModelRow> rows = [];
ModelListPage page = await claude.Models.List(cancellationToken: ct);
await foreach (ModelInfo model in page.Paginate(ct))
{
rows.Add(new ModelRow(model.ID, model.DisplayName, model.MaxInputTokens, model.MaxTokens, model.Lifecycle.Raw()));
}
return TypedResults.Ok(rows);
});
return app;
}
}
By default the list contains active and deprecated models; retired models appear only when you ask for them explicitly. The documentation recommends reading the line field (opus, sonnet and so on) instead of deriving the model family from the id. As of 15 November 2026, the current models on the models overview page are Claude Fable 5.2, Opus 5.5, Sonnet 5.5 and Haiku 5.5; all four have a 1M token context window and a 128K token output limit on the synchronous Messages API. In this post we chose Haiku 5.5: a short, frequent task such as a two-sentence summary matches the documentation's description of it.
In Production
Key ownership. The production key should belong to a service account rather than a person, be bound to a separate workspace, and that workspace should have a spend limit. According to the Rate limits page, organizations are placed in a usage tier and each tier has a monthly spend cap (Start 500, Build 1,000, Scale 200,000 USD). Once the cap is reached, requests return 429 until the end of the month, and that 429 has no retry-after header; the SDK's automatic retries do not help either. Keep the spend notification on.
Timeout and retry. By default the SDK uses a 10-minute timeout and 2 retries; it retries only connection errors, 408, 409, 429 and 5xx. Waiting 10 minutes for a two-sentence summary makes no sense, so we set 60 seconds in the options. If the user closes the page, the CancellationToken cancels the request; that is why the endpoint passes the token to the service.
Token counts in the log. We log usage.input_tokens and usage.output_tokens on every request. The bill is computed from these numbers, and we will build cost tracking on top of these logs in the last post. As book descriptions get longer, input tokens grow too; compare the logs with the Usage page in the Console from time to time.
Cache. The summary of the same book should not be generated again on every page view. A summary is data that does not change; once generated, it should be written to the database or a cache. We will solve this in the eighth post with bulk generation through Message Batches.
Common Pitfalls
The wrong NuGet package. Anthropic 3.x and below is the community SDK; the official SDK is 10 and later. A sample that does not compile often comes from a package mix-up.
Creating the client on every request. Every new AnthropicClient() builds its own HTTP plumbing. Register it in the container as a singleton, and use WithOptions if you need different settings.
Committing the key to appsettings.json. Even if the repo is not public, the key will one day end up in a log, a screen share or a fork. The key belongs in an environment variable or a secret store; disable or delete any key you suspect has leaked in the Console.
Copying old samples. Samples with temperature, top_p, top_k and Role = Role.User predate 2026-10-21. Check which SDK version the code you copy was written for.
Inventing model names. Current model IDs such as claude-haiku-5-5 carry no date suffix; the models overview page says that from the 4.6 generation on, the dateless IDs are themselves pinned snapshots. Adding a date suffix as on older models gives you a model ID that does not exist.
A Small Experiment
★☆☆ Build the project in the snapshot and run the tests. Add StopSequences = ["\n\n"] to BuildRequest and see how the stop_sequences field looks in the RawBodyData output.
★★☆ Add an allow list for Model to ClaudeOptions (claude-haiku-5-5, claude-sonnet-5-5) and see the app fail at startup with a value that is not on the list. Hint: a custom rule with Validate.
★★★ Extend the /api/ai/models endpoint: for each model, also return the supported levels under capabilities.effort and the value of capabilities.structured_outputs.supported. Find the C# names of these fields in the SDK through compiler errors.
In this post we set up the client and sent a single request. In the second post we go inside the Messages API: multi-turn conversations, system messages, content blocks other than text (PDF included), what to do with each stop_reason value and what each usage field tells you.