How a .NET Engineer Actually Calls an AI Model

In Chapter 1, AI helped you write code. In this one, your code talks to AI directly, and it turns out that's just an API integration you already know how to build. We wire Claude into a real .NET endpoint, wrap it in DI, and go parameter by parameter on what actually matters and why.

This is Chapter 2 of the series AI for .NET Engineers
Chapter 1: How a .NET Engineer Actually Starts With AI

In the last chapter, you installed Claude Code and used it to build a small ASP.NET Core endpoint. You typed in plain English, reviewed a plan, approved some code, and corrected it when it missed a detail. That was AI helping you write software.

This chapter is where the direction flips. You're no longer using AI to help you write a .NET application. You're going to write a .NET application that talks to AI directly, as part of what it does at runtime.

And here's the thing I want you to hold onto for the whole chapter, because it's easy to lose it under all the noise around "AI integration": calling the Claude API from .NET is not a new skill. It's an HTTP call to a JSON API, secured with an API key, wrapped in an SDK, called from code you already know how to write. If you've ever integrated Stripe for payments, Twilio for SMS, or SendGrid for email, you've already done the hard part of this chapter. Today you're just pointing that same muscle at a different vendor.

That sentence is going to sound almost too simple. Good. That's the point.

One more thing before we go further: we're using Claude in this chapter because it's what this series is built around, not because any of this is Claude-specific. If you swapped in OpenAI's SDK or Google's Gemini SDK instead, you'd install a different NuGet package and construct a different client object, and everything else, the DI registration, the interface wrapping it, the configuration, the error handling, would look almost identical. The vendor name changes. The shape of the work doesn't.


Why this needs saying out loud

If you only read LinkedIn and conference talks, "calling an LLM" sounds like it belongs in a different universe from "calling a REST API." People talk about prompts, tokens, context windows, temperature, sampling, all in a tone that suggests you need to relearn how software works before you're allowed to touch it.

You don't.

Strip away the vocabulary and here's what's actually happening: your .NET service sends a POST request to https://api.anthropic.com/v1/messages, with a JSON body and an API key in the header, and gets a JSON response back. That's it. That's the entire shape of the interaction. It's the same shape as every third-party integration you've shipped in your career: authenticate, send a structured request, get a structured response, handle failure, move on.

What makes it feel different is that one part of the response isn't deterministic. Ask Stripe to charge a card and, failures aside, you get the same kind of predictable outcome every time. Ask Claude to summarize a paragraph and you'll get a slightly different, equally valid summary each time you ask. That's a real difference, and we'll come back to it, but notice what it isn't: it isn't a different way of making the call. It's a different property of the response. The mechanics of authentication, request construction, error handling, retries, and configuration are exactly what you already do for every other external dependency in your systems.

Keep that distinction in your head as we go: the transport is boring and familiar. The payload is what's new.


What we're building

We're going to pick up the text-analyzer-api project from Chapter 1 exactly where you left it. It has one endpoint, POST /analyze, that counts words and characters using plain C# string operations. That endpoint isn't going anywhere. It's a good example of the kind of task that doesn't need AI at all: it's deterministic, cheap, instant, and a large language model would be a slow, expensive, and slightly unreliable way to count words.

Instead, we're going to add a second endpoint, POST /summarize, that does something your string-splitting code genuinely can't: read a piece of text and describe what it's actually about, in plain language. This is a good first real use case, because it's the kind of task where "just write more C#" stops being a realistic option. You can't regex your way to a summary.

By the end of the chapter, your project will have both endpoints living side by side, one handled entirely by your own logic, one handled by Claude, wired together the same way you'd wire in any external API client: through configuration, dependency injection, and an interface you could swap out or mock in a test.

One quick note before we start: this chapter is built on .NET 10, same as the project you set up in Chapter 1. Nothing here depends on a version-specific quirk, minimal APIs and everything we're using have been stable for a while, but worth stating plainly so the code you're copying matches what's actually running.


Step 1: Get an API key

Head to platform.claude.com and sign in to the Console. Under API keys, generate a new key. This is precisely the same ritual as generating a secret key in the Stripe dashboard or an auth token in Twilio's console: a string you'll treat as a password, never commit to source control, and rotate if it ever leaks.

Anthropic's usage is metered per token (roughly, per chunk of text processed and generated), and different model tiers cost different amounts, the same way different third-party APIs have different pricing tiers for different service levels. We'll get to model selection shortly.


Step 2: Install the SDK

Anthropic publishes an official C# SDK on NuGet, and it's the one you want:

dotnet add package Anthropic

That's the whole installation step, no different from adding the Twilio or SendGrid SDK to a project. Under the hood, it's a typed HTTP client that builds the request, sets the right headers, deserializes the response into C# objects, and gives you retry and timeout behavior out of the box, the same reasons you wouldn't hand-roll any other vendor's HTTP calls yourself.

One thing worth knowing if you search around: older tutorials and NuGet history reference a community-built package, now published as tryAGI.Anthropic. The Anthropic package (version 10 and above) is the current official SDK from Anthropic itself. Use that one.


Step 3: Store the key the way you already store secrets

You already have opinions about where API keys belong, and none of those opinions involve typing them directly into Program.cs. Use the same approach here that you'd use for any other third-party credential.

For local development, .NET's user secrets are the right tool:

dotnet user-secrets init
dotnet user-secrets set "Anthropic:ApiKey" "sk-ant-your-key-here"

For deployed environments, that same configuration key would come from an environment variable, Azure Key Vault, or whatever secret store your team already uses for connection strings and other third-party keys. Nothing about this chapter changes that pattern. The SDK will also happily read an ANTHROPIC_API_KEY environment variable directly if you construct the client with no explicit key, but binding it through IConfiguration like any other setting keeps it consistent with how the rest of your app already manages configuration.


Step 4: Wire it into the DI container

This is the part that will feel most familiar. You're going to register the Claude client the same way you'd register a StripeClient or a typed HttpClient for any external service: once, at startup, as a singleton, and let the container hand it to whatever needs it.

using Anthropic;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton(new AnthropicClient
{
    ApiKey = builder.Configuration["Anthropic:ApiKey"]
        ?? throw new InvalidOperationException("Anthropic API key is not configured.")
});

builder.Services.AddScoped<ITextInsightService, ClaudeTextInsightService>();

var app = builder.Build();

Notice what's happening here: AnthropicClient is just another dependency, configured once and injected wherever it's needed, exactly like a database context, an email sender, or a payment gateway client. No special AI-flavored wiring.

I've also put an interface, ITextInsightService, between the endpoint and the Claude client, for the same reason you'd wrap any third-party SDK behind your own interface: it keeps your endpoint code testable, keeps vendor-specific types out of your API layer, and gives you a seam to swap providers or mock the dependency in a test.


Step 5: Write the service

Here's the implementation, and this is where we actually talk to Claude:

using Anthropic;
using Anthropic.Models.Messages;

public interface ITextInsightService
{
    Task<string> SummarizeAsync(string text, CancellationToken cancellationToken = default);
}

public class ClaudeTextInsightService : ITextInsightService
{
    private readonly AnthropicClient _client;

    public ClaudeTextInsightService(AnthropicClient client)
    {
        _client = client;
    }

    public async Task<string> SummarizeAsync(string text, CancellationToken cancellationToken = default)
    {
        var parameters = new MessageCreateParams
        {
            Model = Model.ClaudeSonnet5,
            MaxTokens = 300,
            System = "You are a concise technical summarizer. "
                   + "Reply with exactly two sentences and nothing else.",
            Messages =
            [
                new()
                {
                    Role = Role.User,
                    Content = text,
                },
            ],
        };

        var message = await _client.Messages.Create(parameters, cancellationToken: cancellationToken);

        foreach (var block in message.Content)
        {
            if (block.TryPickText(out var textBlock))
            {
                return textBlock.Text;
            }
        }

        return string.Empty;
    }
}

And the endpoint itself, sitting right alongside your Chapter 1 code:

app.MapPost("/summarize", async (SummarizeRequest request, ITextInsightService insights, CancellationToken cancellationToken) =>
{
    if (string.IsNullOrWhiteSpace(request.Text))
    {
        return Results.BadRequest("Text field cannot be empty.");
    }

    var summary = await insights.SummarizeAsync(request.Text, cancellationToken);
    return Results.Ok(new SummarizeResponse(summary));
});

app.Run();

record SummarizeRequest(string Text);
record SummarizeResponse(string Summary);

Notice that last parameter on the endpoint, CancellationToken cancellationToken. Minimal APIs bind this automatically to the request's aborted token, no extra wiring needed. It's easy to skip on a demo endpoint, but it earns its place here for a reason specific to calling a model: if the caller closes the connection or the request times out before Claude finishes responding, that cancellation now propagates all the way down into the SDK call instead of your service quietly finishing a request nobody's waiting for. For a database query, that's wasted work. For a model call, it's wasted work you're also being billed for. Same instinct you already have around cancellation tokens on any long-running I/O, just with a slightly sharper reason to bother.

One caveat worth a mention: check your installed SDK version's IntelliSense for the exact overload. Trailing CancellationToken support is standard across the official SDKs, but the precise parameter name or position can shift slightly between releases.

Run it the normal way:

dotnet run

And in a separate terminal, hit the new endpoint with a real paragraph, something with enough substance in it that a word count wouldn't tell you much:

curl -X POST http://localhost:5000/summarize \
  -H "Content-Type: application/json" \
  -d '{"text": "Our order processing service currently handles about 200 requests per second during peak hours, but the team has noticed increased latency whenever the payment gateway response time exceeds 500 milliseconds. We are evaluating whether to introduce a circuit breaker pattern around the payment gateway calls, or to move to an asynchronous queue-based approach where order confirmation is decoupled from payment confirmation entirely."}'

Here's the actual response that comes back:

{
  "summary": "The order processing service handles roughly 200 requests per second at peak, but latency rises whenever the payment gateway takes longer than 500 milliseconds to respond. The team is weighing a circuit breaker around those payment gateway calls against decoupling order confirmation from payment confirmation with an asynchronous queue."
}

Two sentences, exactly as instructed in the system prompt, and it correctly picked out the two options actually being weighed instead of just restating the paragraph. Run the same request again and you may get slightly different phrasing, since the model doesn't guarantee identical wording between calls, but the shape and substance of the response will hold. That variability is the non-determinism we talked about earlier, showing up in practice for the first time.

From the outside, this endpoint looks exactly like any other endpoint in your API. The caller has no idea, and doesn't need to know, that the implementation behind it is an AI model instead of a SQL query or a business rule.


Now let's actually understand the parameters

This is the part most tutorials rush past, and it's the part you actually need to reason about like an engineer, not copy-paste. Here's every parameter we used, and a few we didn't, explained in terms you already have a mental model for.

model

This selects which Claude model handles the request, and it's the closest thing here to choosing a service tier from a third-party vendor, the way you'd pick between a standard and premium delivery option, or a small and large compute instance. At the time of writing, the practical choices are Claude Haiku (fastest and cheapest, good for simple, high-volume tasks), Claude Sonnet (the balanced default for most application logic, which is what we used above), and Claude Opus (the most capable, for tasks that genuinely need deeper reasoning). Start with Sonnet unless you have a specific reason not to, the same way you'd default to a standard tier until load testing tells you otherwise.

max_tokens (required)

This caps how much the model is allowed to generate in its response. Tokens are roughly chunks of text, not quite words and not quite characters, but close enough to reason about for now. Think of this the way you'd think of a pageSize or take parameter on a paginated third-party API: it's a hard ceiling on response size, and it directly affects both latency and cost, since you're billed by tokens generated. We set it to 300, which is generous for a two-sentence summary but keeps a runaway response from ballooning your bill.

Worth flagging early: max_tokens only caps the output side. What you send in through messages and system counts toward cost too, and once you're sending large documents or long conversation histories instead of a single paragraph, managing that input side becomes its own real discipline, with its own tools like prompt caching and context window management. That's a big enough topic to deserve a dedicated chapter later in this series rather than a rushed paragraph here.

messages (required)

This is the conversation history, an array of turns with a role (user or assistant) and content. The important thing to internalize: the Messages API is stateless. There's no session or conversation ID it remembers between calls. If you're building an actual back-and-forth chat feature, you're responsible for sending the entire prior conversation on every single request, the same discipline you'd already apply to any stateless REST API where you can't assume the server remembers what you told it last time.

system

A top-level field, separate from the messages array, for instructions that should shape the entire interaction, tone, role, constraints, output format. Think of it like configuration you set once rather than something you repeat in every request, similar to setting a default header or a piece of middleware behavior that applies to the whole request pipeline instead of one call.

temperature, top_p, and top_k

On most Claude models, these are the sampling controls: temperature runs from 0 to 1 and pushes output toward either the most likely phrasing (low) or more varied phrasing (high), while top_p (nucleus sampling) and top_k are alternate ways of shaping that same sampling distribution. On those models, you'd typically pick one knob, usually temperature, leave the others at their defaults, and lean it low for a business-logic task like summarization, where predictable output matters more than creative flourish.

Here's the catch, and it's the reason you don't see any of these three in the code above: as of Claude Sonnet 5, none of them accept a non-default value anymore. Send a request with temperature set to anything other than its default and the API returns a 400 error instead of quietly applying it. This is a genuine, documented change from earlier Sonnet versions, not an oversight in this chapter's code. If you're used to reaching for temperature: 0.2 out of habit from an older tutorial or a different model, Sonnet 5 will reject it outright.

There's a broader lesson sitting inside that detail. Parameters that worked in a code sample from six months ago aren't guaranteed to work today, models and their supported options evolve, and the discipline of checking a model's current capabilities before you copy a parameter into production code is the same discipline you already apply to any third-party API whose contract changes between versions. If you're working with an older Claude model that still honors these sampling controls, the guidance above still applies there. On Sonnet 5, the honest advice is simpler: don't set them, and shape the output through the system prompt instead, the way we did above by just asking for exactly two sentences.

Try it yourself: change the system instruction in SummarizeAsync to ask for one sentence instead of two, rebuild, and rerun the same curl request. Then, separately, rerun the original two-sentence version three or four times in a row without changing anything at all. You'll still see the wording shift slightly between runs, even with no sampling parameter exposed to blame it on. That's the model's underlying stochastic generation showing through directly, not a knob you turned.

stop_sequences

An optional list of strings that, if generated, immediately end the response. Useful when you want to enforce a hard boundary on output, similar to a delimiter-based cutoff you might already build into a text-processing pipeline.

stream

Not shown in our example, but worth knowing about: setting this to true switches the response to a server-sent-events stream instead of one final JSON blob, and the SDK exposes it as a method with a Streaming suffix that returns an IAsyncEnumerable, the same async streaming abstraction you likely already use for things like large EF Core query results:

await foreach (var chunk in _client.Messages.CreateStreaming(parameters))
{
    // handle each partial chunk as it arrives
}

This is what powers the "typing" effect in chat interfaces, and it's a natural fit for .NET's existing async streaming model rather than something bolted on.

metadata

An optional object where you can attach identifiers, such as a user_id, to a request for your own tracking and abuse-monitoring purposes on Anthropic's side. Functionally, this is the same instinct as attaching a correlation ID or a customer identifier to a call you make to any B2B API, so that if something goes wrong, you can trace it back.

tools and tool_choice

We didn't use these here, and I'm mentioning them mainly so you know they exist. This is how you let Claude call functions in your own code, the foundation of building agents. It deserves its own chapter later in this series, once you've got the basics of a plain request-response call fully under your fingers. For now, file it away as "the next layer up."


Handling failures like you already do

An external API call can fail, and you already know every way that can happen: bad input, rate limits, authentication problems, the vendor's servers having a bad day. The Claude SDK throws typed exceptions for exactly these cases, mapped to HTTP status codes the same way any well-designed SDK maps them:

try
{
    var summary = await insights.SummarizeAsync(request.Text);
    return Results.Ok(new SummarizeResponse(summary));
}
catch (AnthropicRateLimitException)
{
    return Results.StatusCode(StatusCodes.Status429TooManyRequests);
}
catch (AnthropicBadRequestException ex)
{
    return Results.BadRequest(ex.Message);
}
catch (Anthropic5xxException)
{
    return Results.StatusCode(StatusCodes.Status502BadGateway);
}

The SDK also retries certain failures automatically (connection errors, timeouts, rate limits, and server errors) with exponential backoff, configurable through a MaxRetries property on the client. If your team already reaches for Polly-style retry policies around other external calls, this will feel like the same conversation: how many retries, how much backoff, and what should happen once you give up.


The one genuinely new engineering concern

Here's where we come back to that non-determinism I flagged earlier, because it does change one thing about how you build and test this code, even though it doesn't change how you call the API.

To make this concrete, here's the same /summarize request from earlier, the exact same input text, fired three separate times:

// Run 1
{
  "summary": "The order processing service handles roughly 200 requests per second at peak, but latency rises whenever the payment gateway takes longer than 500 milliseconds to respond. The team is weighing a circuit breaker around those payment gateway calls against decoupling order confirmation from payment confirmation with an asynchronous queue."
}
// Run 2
{
  "summary": "At peak load of around 200 requests per second, the order processing service sees latency spikes tied to payment gateway responses slower than 500 milliseconds. Two fixes are under consideration: wrapping the payment gateway calls in a circuit breaker, or decoupling order and payment confirmation through an asynchronous queue."
}
// Run 3
{
  "summary": "The team's order processing service, which serves about 200 requests per second at peak, is experiencing latency issues whenever payment gateway calls exceed 500 milliseconds. To address this, they are considering either a circuit breaker around the gateway or an asynchronous, queue-based approach that separates order confirmation from payment confirmation."
}

Same input, same parameters, three different sentences. Read them again and notice what didn't change: the number (200 requests per second), the threshold (500 milliseconds), and the two options on the table (circuit breaker versus an async queue) show up correctly in all three. What moved around is word choice and sentence structure, not the substance. That's the pattern you're designing around: don't assert on the exact string, assert that the facts you actually care about survived the trip.

A unit test that asserts your /analyze endpoint returns exactly {"wordCount": 7} for a fixed input is a perfectly reasonable test, because that endpoint is deterministic. A test that asserts /summarize returns an exact string for a fixed input is not reasonable, as you can see above, because Claude may phrase a valid summary slightly differently between runs even with no sampling parameters set at all.

This is why wrapping the call behind ITextInsightService matters beyond tidiness. In your unit tests, you mock that interface and assert your endpoint's behavior around it (does it return 400 on empty input, does it wrap the result correctly), without needing to actually call Claude. Any integration test that does call the real API should assert on properties of the response (is it non-empty, is it under some reasonable length, does it contain expected keywords) rather than exact equality. This is precisely the same adjustment you already make when testing anything that returns a timestamp, a generated ID, or third-party data you don't fully control. You're not learning a new testing discipline, you're applying the one you have to a new kind of non-determinism.

One more distinction worth being precise about here, because it's easy to blur: variability and unreliability are not the same failure. If three valid summaries describe the same facts in three different sentences, that's variability, and it's normal, expected, and nothing to engineer away. If one of those summaries invents a detail that was never in the input, a number that doesn't appear anywhere, a system that was never mentioned, that's a correctness problem, and no amount of comfort with non-determinism should talk you out of catching it. Your job isn't to force every response to come out identical. It's to decide what "acceptable" actually means for your specific use case, and build your tests and any production monitoring around that definition rather than around exact string matching. That's a real shift in engineering judgment, not just a testing technique, and it's worth sitting with rather than skimming past.


Where this leaves you

Step back and look at what actually happened in this chapter. You picked up an existing ASP.NET Core project, added a NuGet package, stored a secret, registered a client in DI, wrapped it behind an interface, and wrote a try/catch around an external call, all the way you always do.

The only genuinely new idea is that one of your dependencies now returns a probabilistic result instead of a deterministic one, and that changes how you test it, not how you call it.

That's the whole trick to this chapter, and honestly, to a lot of what's ahead in this series. The API surface of "AI-powered application development" looks intimidating from the outside because of the vocabulary around it. From the inside, once you've made the call once, it's a REST integration with an unusually interesting payload.

If you only take a handful of things from this chapter, make it these:

  • Calling Claude is an external API integration, authentication, request, response, failure handling, nothing about the mechanics is AI-specific.
  • max_tokens and the size of what you send both affect cost, not just the response you get back.
  • The Messages API is stateless. Your application owns the conversation history, not the model.
  • Check what your specific model version actually supports before copying a parameter from an older example. Sonnet 5 dropping temperature, top_p, and top_k is proof that these contracts move.
  • Wrap the model call behind your own interface so it stays testable and swappable, the same reason you'd wrap any third-party SDK.
  • Variability in wording is normal. A response that invents facts is not. Those need different responses from you as the engineer.

You didn't need to become a different kind of engineer to get here.

You just pointed the engineer you already are at a new endpoint.


That's Chapter 2 of AI for .NET Engineers done.

Gaurav Sharma
Gaurav Sharma
20+ years shipping .NET & Azure systems. I write about production systems, career growth, communication, and AI with .NET.