OpenAI isn’t the only game in town. Anthropic’s Claude models are a strong choice for many workloads, and calling them from C# is straightforward. This guide covers authenticating, sending a message, streaming the reply, and — importantly for .NET teams — using Claude through a provider-agnostic abstraction so you can switch models without rewriting your app.
Getting a key
Create an API key in the Anthropic Console and store it in an environment variable — never in source:
setx ANTHROPIC_API_KEY "sk-ant-..." # reopen the terminal afterwards
Two things are worth getting right on day one. The key is a bearer credential that isn’t scoped to a particular model or endpoint, so treat it the way you’d treat a database password: environment variables or user-secrets in development, a key vault in production, and never a literal in appsettings.json that ends up in source control. And issue a separate key per environment. The day one leaks into a log file you want to revoke staging without taking production down alongside it.
Option 1: Call the API directly
Anthropic’s Messages API is a simple HTTP endpoint. If a dedicated SDK version isn’t pinned for your project, you can call it with HttpClient:
using System.Net.Http.Json;
using System.Text.Json;
var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY"));
http.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01");
var body = new
{
model = "claude-sonnet-4-5",
max_tokens = 1024,
messages = new[] { new { role = "user", content = "Explain the actor model in two sentences." } }
};
var res = await http.PostAsJsonAsync("https://api.anthropic.com/v1/messages", body);
var json = await res.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine(json.GetProperty("content")[0].GetProperty("text").GetString());
The key differences from OpenAI’s shape: the x-api-key header (not a bearer token), the required anthropic-version header, and an explicit max_tokens.
Why that snippet isn’t production code
It’s the right shape for a first call and the wrong shape for anything users depend on. Three specific problems, each of which fails quietly rather than loudly.
content is an array of typed blocks, not a string. content[0] happens to be a text block for a plain question, but the moment you add tools to the request the model can put a tool_use block first, and indexing blindly either throws or hands you the wrong thing. Filter by block type and concatenate.
Nothing checks stop_reason. This is the field that tells you whether you received a whole answer. end_turn means the model finished; tool_use means it wants you to run a tool and come back; max_tokens means it was cut off mid-sentence. That last one is the dangerous case, because a truncated reply is a perfectly valid string — it just stops halfway. Skip the check and you get a summariser that works fine until documents grow past your output budget, at which point it starts silently emitting summaries that end mid-word and nothing in your logs says why.
A non-2xx response deserialises into a JsonElement too. An error body carries an error object rather than content, so GetProperty("content") throws a KeyNotFoundException — you’ll spend the first ten minutes debugging a JSON shape complaint instead of the expired key that actually caused it.
The corrected read looks like this:
res.EnsureSuccessStatusCode();
var json = await res.Content.ReadFromJsonAsync<JsonElement>();
if (json.GetProperty("stop_reason").GetString() == "max_tokens")
{
// The reply was cut off. Retry with a larger budget or fail loudly —
// do not pass a half-finished answer to the rest of the pipeline.
}
var text = string.Concat(
json.GetProperty("content").EnumerateArray()
.Where(b => b.GetProperty("type").GetString() == "text")
.Select(b => b.GetProperty("text").GetString()));
One more detail on max_tokens: it’s required, there is no default, and it’s a hard ceiling rather than a hint. Set it low and you truncate. Set it high on a non-streaming request and the call can sit open long enough to hit HttpClient’s default 100-second timeout, surfacing as a TaskCanceledException that looks like a network fault and isn’t.
Option 2: Use Microsoft.Extensions.AI (recommended)
For real apps, go through Microsoft.Extensions.AI’s IChatClient so Claude is just one interchangeable provider behind a common interface. Then the rest of your code — and any agent built on IChatClient — doesn’t care which model it’s talking to:
// Behind IChatClient, your app code is identical whether the model is Claude, GPT, or local.
IChatClient chat = /* an Anthropic-backed IChatClient */;
var response = await chat.GetResponseAsync("Summarize dependency injection for a junior dev.");
Console.WriteLine(response);
This is the same portability argument from choosing a model: swapping providers should be a config change, not a rewrite.
It’s worth being honest about where that portability stops. The common path is genuinely portable — messages in, text or tool calls out, streaming, structured output. What doesn’t travel is the provider-specific knobs: Claude’s caching hints, its safety and thinking configuration, anything that only one vendor exposes. Those reach the wire through per-provider options rather than the shared surface, so the moment a call site uses one, “switching provider is a config change” stops being true for that call site.
The answer isn’t to avoid those features — they’re often the reason you chose the provider. It’s to keep them in one place. Put the two or three provider-specific behaviours you actually depend on behind a small wrapper of your own, and a provider swap becomes a rewrite of one class instead of a grep across the solution.
Streaming
For user-facing responses, stream tokens as they arrive rather than waiting for the whole reply. Both the direct API (via server-sent events) and the IChatClient streaming method support it — the pattern mirrors streaming responses to a web UI.
On the wire, the stream is a sequence of typed events rather than raw text: a message_start carrying metadata, then content_block_start / content_block_delta / content_block_stop triples for each block, then a message_delta that carries the final stop_reason and output token count, then message_stop. If you’re parsing SSE yourself, that message_delta is where truncation detection lives — the deltas themselves give you no signal that the model was cut short.
Streaming isn’t only a UX decision. It’s also your defence against timeouts. A non-streaming request asking for a long output holds a connection open with nothing travelling on it until generation completes, and anything in the path — HttpClient’s timeout, a load balancer’s idle timeout, a reverse proxy — is entitled to kill it. With a stream, bytes flow continuously, so idle timeouts never fire. Above a few thousand output tokens, streaming stops being optional.
Two consequences to plan for. Pass a CancellationToken through and cancel it when the client disconnects, so you stop generating output nobody will read. And decide up front what happens when a stream dies halfway: you’re left holding partial text and no stop_reason, and “show the fragment” versus “discard and retry” is a product decision, not something to work out in an incident.
System prompts and multi-turn
Claude takes a top-level system parameter for behaviour, and you continue a conversation by appending prior turns to the messages array — the same mental model as other chat APIs. Keep the system prompt focused; a tight instruction outperforms a rambling one.
If you’re porting from an OpenAI integration, the system difference bites here: pushing a { "role": "system" } entry into the messages array returns a 400 rather than being quietly ignored. That’s the good outcome — it fails immediately rather than shipping an integration whose system prompt was silently discarded.
The more expensive detail is that the API is stateless. Every turn resends the entire conversation, so a thirty-turn chat isn’t thirty small requests — it’s thirty requests where the last one carries all twenty-nine prior turns plus the system prompt plus your tool definitions. Input tokens per turn grow with conversation length, and total spend grows roughly with the square of it. Two levers help: trim history (keep the opening turns and the last few, summarise the middle), or make the stable part cacheable.
Rate limits and retries
Anthropic’s limits are two-dimensional: requests per minute and tokens per minute. Teams are usually surprised by the second one, because you can sit comfortably under the request limit and still get throttled — a nightly job firing ten concurrent requests, each carrying a fifty-page document, is a handful of requests and an enormous number of tokens.
Split failures into retryable and not, because retrying the wrong ones is worse than not retrying at all. A 429 (rate_limit_error), a 529 (overloaded_error) and 5xx server errors are transient and worth another attempt. A 400 (invalid_request_error), 401 (authentication_error) or 403 (permission_error) will fail identically every time; a blanket “retry three times” turns one malformed request into three and delays the error message by however long your backoff takes.
When the response carries a retry-after header, honour it. Otherwise use exponential backoff with jitter — without jitter, a fleet of instances that all back off by exactly two seconds retries in perfect lockstep and reconstructs the burst that caused the throttling. Polly is the idiomatic .NET answer and slots in as a delegating handler on the HttpClient. Check first whether your client library already retries, though: layering your own policy on top of a built-in one multiplies the worst-case wall-clock time, and a request you expected to fail in ten seconds now blocks a request thread for two minutes.
What actually drives the bill
Before concluding that your model choice is the problem, measure input tokens per turn. On an agent, output is often a couple of hundred tokens against tens of thousands of input tokens — the system prompt, the tool definitions and the accumulated history, re-sent on every single turn.
That’s what prompt caching exists to fix. You mark a stable prefix as cacheable, and subsequent requests sharing that prefix read it back at a small fraction of the normal input rate, against a modest premium on the request that wrote it. Break-even arrives after a handful of requests, which any conversation or agent loop clears immediately.
The catch is that caching is a prefix match, byte for byte. A change anywhere invalidates everything after it, and the failure is silent — no error, no warning, just a bill that never improves. The usual culprits:
- interpolating
DateTime.UtcNowor a request ID into the system prompt - serialising tool definitions from a
Dictionary<string, object>whose enumeration order isn’t stable - putting per-user context at the front of the prompt rather than the back
The fix is an ordering discipline: stable content first (system prompt, tool schemas), volatile content last (the user’s question, anything time-dependent). Verify rather than assume — the response’s usage object reports how many tokens were read from cache, and if that stays at zero across requests you believe are identical, something in your prefix is moving.
When to skip the abstraction
IChatClient earns its place in most applications, but not all of them:
A spike or prototype. If portability isn’t a requirement, the indirection buys nothing and costs a layer of types between you and the docs.
A product built around one provider’s exclusive behaviour. If half your call sites reach past the interface for vendor-specific options, the abstraction has stopped describing your system and started lying about it. Own the coupling instead of pretending it isn’t there.
Debugging and replay. When you need to see exactly what went over the wire, go direct — or add a logging decorator, which is the abstraction paying for itself.
The pragmatic middle ground is to build on IChatClient and drop to the provider client at the two or three call sites that genuinely need it, rather than treating the choice as all-or-nothing.
Note: model names (e.g.
claude-sonnet-4-5) and SDK details change over time. Verify against the Anthropic API docs for current model IDs and any official .NET SDK; the approach — key + version headers, messages array, orIChatClientfor portability — is stable.
Takeaway
Calling Claude from C# is a simple HTTP request with an x-api-key and anthropic-version header — or, better, a call through Microsoft.Extensions.AI so Claude is one swappable provider behind IChatClient. What separates the demo from the deployment is the unglamorous half: read stop_reason so truncation fails loudly, stream anything long so timeouts can’t cut you off, retry only what’s actually transient, and order your prompt so the cacheable part stays still. Build on the abstraction and you can compare Claude, GPT, and local models on your own eval set and pick the best fit per task without touching your app code.
