Para usar a API do Claude no ASP.NET Core com streaming, você chama client.Messages.CreateStreaming do SDK oficial da Anthropic para .NET (pacote NuGet Anthropic), filtra os pedaços de texto num IAsyncEnumerable e devolve esse enumerável com TypedResults.ServerSentEvents, que no .NET 10 escreve o text/event-stream para o navegador. Tool use é um laço no seu servidor: o modelo pede uma função pelo nome e com argumentos em JSON, o seu código C# valida, executa e devolve o resultado. Neste artigo eu mostro as duas coisas e três armadilhas que eu rodei de verdade: um middleware que captura o corpo e mata o streaming, o CancellationToken que não chega ao modelo e o laço de ferramenta sem limite de rodadas.
Um aviso antes: a API da Anthropic foi simulada
Tudo o que está aqui rodou na minha máquina sem chave e sem custo. A API da Anthropic foi simulada por um servidor falso local, que responde ao mesmo POST /v1/messages com eventos no formato da documentação, inclusive os de ferramenta, mas com texto fixo. Não há resultado de modelo real neste artigo. O que é real: o SDK oficial (Anthropic 13.1.0), a API ASP.NET Core 10, o streaming por Server-Sent Events, o laço de ferramenta e os testes xUnit. O id de modelo no código é claude-sonnet-5-5; modelos mudam, então confira na documentação antes de copiar.
O modelo mental: navegador, minha API e o Claude
O navegador fala com a minha API, e a minha API fala com o Claude. A resposta do modelo chega em eventos, e eu repasso esses eventos para o navegador em Server-Sent Events, que um EventSource consegue ler.
Com ferramenta, o modelo nunca executa nada. Ele pede, com o nome e os argumentos, e quem decide e executa é o meu código. Essa é a fronteira de segurança: o modelo não tem permissão própria, ele herda a do usuário logado.
Cliente único e chave fora do código
O cliente do SDK é registrado uma vez, como singleton, para reaproveitar conexões. Por padrão ele aponta para a API real; a variável ANTHROPIC_BASE_URL serve para apontar para outro servidor, que no meu caso foi o falso. A chave vem de ANTHROPIC_API_KEY, nunca do código. Em produção, use User Secrets ou variável de ambiente.
// Program.cs
var options = new ChatOptions
{
BaseUrl = Environment
.GetEnvironmentVariable("ANTHROPIC_BASE_URL")
?? "https://api.anthropic.com",
ApiKey = Environment
.GetEnvironmentVariable("ANTHROPIC_API_KEY"),
};
ChatApp.Build("http://localhost:5100", options).Run();
// ChatApp.cs: one client for the whole app, reusing connections.
builder.Services.AddSingleton(_ => new AnthropicClient
{
BaseUrl = options.BaseUrl,
ApiKey = options.ApiKey,
});Streaming da resposta com o SDK oficial
O serviço monta o pedido (modelo, máximo de tokens e a mensagem do usuário) e pede o stream. O SDK entrega cada evento assim que ele chega, e eu fico só com os deltas de texto. Repare no ct: ele é o token da requisição HTTP e vai até a chamada ao modelo. Isso importa na segunda armadilha.
MessageCreateParams Request(string question) => new()
{
Model = options.Model,
MaxTokens = 1024,
Messages = [new UserMessageParam(question)],
};
public async IAsyncEnumerable<string> StreamAsync(string question,
[EnumeratorCancellation] CancellationToken ct)
{
await foreach (var ev in client.Messages
.CreateStreaming(Request(question), ct))
{
if (ev.TryPickContentBlockDelta(out var delta) &&
delta.Delta.TryPickText(out var text))
yield return text.Text;
}
}Server-Sent Events no ASP.NET Core 10
No endpoint, o .NET 10 faz o trabalho do protocolo: eu devolvo o IAsyncEnumerable e o framework escreve cada item como um evento. A primeira versão, com POST /chat, devolvia a resposta inteira de uma vez. Funciona, mas o usuário espera tudo ficar pronto para ver qualquer coisa.
// The whole answer in one response (the first version).
app.MapPost("/chat", async (AskRequest r, ClaudeChat chat,
CancellationToken ct) =>
new { answer = await chat.AskAsync(r.Question, ct) });
// Token by token, as Server-Sent Events.
app.MapGet("/chat/stream", (string q, ClaudeChat chat,
CancellationToken ct) =>
TypedResults.ServerSentEvents(
chat.StreamAsync(q, ct), "token"));Medi com um programa pequeno que faz o papel do navegador e imprime o segundo de cada evento. Resposta inteira: 4,9 segundos de tela parada e só então o texto. Com streaming: 27 eventos, o primeiro com 0,5 segundo e o último com 4,6. Lembre que os tempos vêm do servidor falso, que espera 150 ms entre um pedaço e outro.
O que passa no fio quando o modelo pede uma ferramenta
Quando a requisição declara uma ferramenta e o modelo decide usá-la, chega um bloco tool_use com id e nome. Os argumentos vêm em pedaços, em eventos input_json_delta, e o JSON só é válido quando o último pedaço chega. No fim, o stop_reason é tool_use: o modelo está esperando o meu resultado. Estas linhas vieram do servidor simulado, escritas conforme a documentação (quebrei as linhas para caber):
data: {"type":"content_block_start","index":1,
"content_block":{"type":"tool_use","id":"toolu_fake_01",
"name":"get_order","input":}}
data: {"type":"content_block_delta","index":1,
"delta":{"type":"input_json_delta",
"partial_json":"{\"order_id\""}}
data: {"type":"content_block_delta","index":1,
"delta":{"type":"input_json_delta",
"partial_json":": \"A-1042\"}"}}
data: {"type":"message_delta","delta":{"stop_reason":"tool_use",
"stop_sequence":null},"usage":{"output_tokens":15}}Tool use: a ferramenta e o argumento como entrada não confiável
A ferramenta tem nome, descrição e o esquema dos parâmetros, que é o que o modelo enxerga. A execução trata o argumento como entrada não confiável, igual a uma API pública: valida o formato do id e confere se o pedido é do usuário. Para o modelo, o pedido de outra pessoa simplesmente não existe. Na demo, o usuário é fixo ("dana"), passado pelo endpoint; numa aplicação real ele viria da autenticação.
public static Tool Definition => new()
{
Name = Name,
Description =
"Looks up an order of the current user by its id.",
InputSchema = new()
{
Properties = new Dictionary<string, JsonElement>
{
["order_id"] = JsonSerializer.SerializeToElement(
new { type = "string",
description = "e.g. A-1042" }),
},
Required = ["order_id"],
},
};
// The model's arguments are untrusted input, like a public API.
public static string Run(string inputJson, string user)
{
using var doc = JsonDocument.Parse(inputJson);
if (!doc.RootElement.TryGetProperty("order_id", out var p) ||
p.ValueKind != JsonValueKind.String ||
!IdFormat().IsMatch(p.GetString()!))
throw new ToolInputException(
"order_id must look like A-1042");
var id = p.GetString()!;
if (!Orders.TryGetValue(id, out var order) ||
order.Owner != user)
throw new ToolInputException($"order {id} not found");
// ... serializes orderId, status and eta
}O laço de tool use com streaming
A cada rodada eu chamo a API com streaming e o texto vai para o usuário na hora. Se chega um bloco tool_use, guardo id e nome e junto os pedaços do JSON. Sem pedido de ferramenta, acabou. Com pedido, envio um evento status, para a interface mostrar "consultando pedido" em vez de parecer travada, executo a ferramenta e devolvo o resultado no histórico. Se a ferramenta falha, o erro volta como tool_result com IsError = true: eu nunca engulo a exceção, senão o modelo inventa uma resposta.
for (var round = 1; round <= options.MaxToolRounds; round++)
{
var calls = new Dictionary<long, Call>();
await foreach (var ev in client.Messages.CreateStreaming(
Request(history), ct))
{
if (ev.TryPickContentBlockStart(out var start) &&
start.ContentBlock.TryPickToolUse(out var use))
calls[start.Index] = new(use.ID, use.Name);
else if (ev.TryPickContentBlockDelta(out var delta))
{
if (delta.Delta.TryPickText(out var text))
yield return new("token", text.Text);
else if (delta.Delta.TryPickInputJson(out var json))
calls[delta.Index].Json.Append(json.PartialJson);
}
}
if (calls.Count == 0)
{
yield return new("done", "");
yield break;
}
// status event, Execute(call, input, user), history updated
}
yield return new("error",
$"stopped after {options.MaxToolRounds} tool rounds");Omiti o acúmulo do texto dito pelo modelo, que volta no histórico. Na saída medida do /agent/stream, o texto começa com 1,1 segundo, o evento status calling get_order... aparece com 1,7 segundo, e a resposta com o status do pedido A-1042 termina com 3,0 segundos, em 14 eventos.
Armadilha: o middleware que captura o corpo e mata o streaming
Eu queria registrar o tamanho de cada resposta e escrevi um middleware que troca o Response.Body por um MemoryStream. Para saber o tamanho, ele segura o corpo inteiro até o fim. Com ele, o stream entregou 27 eventos, mas o primeiro chegou com 4,9 segundos, junto com o último. Um proxy com buffering faz o mesmo. A correção: as rotas de stream passam direto, sem captura.
app.Use(async (ctx, next) =>
{
// A stream must reach the client while it is being written.
if (ctx.Request.Path.Value!.EndsWith("/stream"))
{
await next();
return;
}
var original = ctx.Response.Body;
await using var buffer = new MemoryStream();
ctx.Response.Body = buffer;
await next();
// ... logs buffer.Length and copies it to the original body
});Também testei a compressão de resposta com text/event-stream, e no .NET 10 ela não segurou os eventos. Esse caso eu não consegui reproduzir.
Armadilha: o CancellationToken que não chega ao modelo
Na primeira versão, a chamada era client.Messages.CreateStreaming(request), sem o token. O teste simula uma aba que fecha: o cliente lê três eventos e desconecta sem ler o resto. Na API de verdade, a geração que continua depois disso são tokens cobrados por uma resposta que ninguém vai ler. A correção é a que já apareceu no StreamAsync: passar o ct da requisição até o modelo.
Armadilha: o laço de ferramenta sem limite de rodadas
O meu primeiro laço era um while (true) que continuava enquanto o modelo pedisse ferramenta. Pedi o pedido X-9999, que não existe, e a ferramenta devolveu erro. O servidor falso foi programado para insistir na ferramenta depois de um erro; um modelo de verdade pode ou não fazer isso, eu forcei para mostrar a defesa. Cada chamada reenvia o histórico inteiro, então em produção isso é custo e latência. A correção é o for com MaxToolRounds (5 por padrão) e uma saída clara, o evento error.
As saídas reais das três armadilhas, antes e depois da correção:
buffering antes: events: 27 | first: 4.9 s | last: 4.9 s
buffering depois: events: 27 | first: 0.3 s | last: 5.1 s
cancel antes: model deltas generated: 27 of 27
model streams cancelled: 0
cancel depois: model deltas generated: 3 of 27
model streams cancelled: 1
loop antes: loop still running after 2 s: stopped
model calls: 53
loop depois: model calls: 5
last event: error - stopped after 5 tool roundsTestes xUnit para cada regra
Os testes sobem a minha API e o servidor falso em portas locais e conversam por HTTP de verdade. A preparação esquenta o JIT, para o tempo medido ser o do streaming. São seis: a primeira palavra chega antes da metade do tempo do stream; o cliente desconecta depois de três eventos e o servidor falso registra o cancelamento; com o pedido que falha, o servidor recebe exatamente cinco chamadas; o resultado da ferramenta chega na resposta; a ferramenta rejeita o pedido de outro usuário; e rejeita o id malformado.
[Fact]
public async Task Tool_loop_stops_at_the_round_limit()
{
FakeServer.TokenDelayMs = 5;
var events = await ReadAll("/agent/stream?q=order%20X-9999");
Assert.Equal(5, FakeServer.Requests);
Assert.Contains("stopped after 5 tool rounds", events.Last());
}Com dotnet test, seis aprovados. Depois eu estraguei o limite, trocando 5 por 50, e o teste quebrou:
Passed! - Failed: 0, Passed: 6, Skipped: 0, Total: 6,
Duration: 6 s
Failed Tool_loop_stops_at_the_round_limit [4 s]
Assert.Equal() Failure: Values differ
Expected: 5
Actual: 50
Failed! - Failed: 1, Passed: 5, Skipped: 0, Total: 6,
Duration: 10 sO que não foi reproduzido
A API foi simulada, então respostas, tempos e custo de um modelo de verdade vão ser outros, e os eventos foram escritos conforme a documentação, sem captura da API real. Não testei um navegador de verdade (o cliente foi um programa .NET) nem um proxy como o nginx, onde o buffering costuma aparecer. Meça com a API real antes de confiar nos números daqui.
Resumo
CreateStreamingdo SDKAnthropicmaisTypedResults.ServerSentEventsentregam a resposta enquanto ela é gerada.- Nada no caminho pode capturar o corpo de um stream: nem middleware, nem proxy com buffering.
- Passe o
CancellationTokenda requisição até a chamada ao modelo. - No tool use, o modelo só pede; o seu código valida o argumento como entrada não confiável, limita as rodadas e devolve o erro como resultado com
IsError. - Um teste xUnit protege cada regra.
Se você quer ir além, leia também MCP em .NET, para expor ferramentas C# a modelos por um protocolo padrão, a ordem dos middlewares no .NET, que explica por que a posição do middleware de log importa, e RAG em .NET. Se precisa de ajuda para levar uma API de IA para produção, veja o serviço de backend .NET.
Referências
- Streaming de mensagens (Anthropic): https://docs.claude.com/en/docs/build-with-claude/streaming
- Tool use (Anthropic): https://docs.claude.com/en/docs/agents-and-tools/tool-use/overview
- Pacote NuGet Anthropic: https://www.nuget.org/packages/Anthropic
- Respostas em Minimal APIs, incluindo Server-Sent Events: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses
- Novidades do ASP.NET Core 10: https://learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-10.0
