Mert Özen Her Satırda Daha İleri
Yapay Zeka Claude API ile Uygulama Geliştirme — #05

Claude ile Tool Use: Client Tool'lar, Tool Loop, Strict Mode ve Server Tool'lar

Mert Özen 15 Kas 2026 18 dk 5 görüntülenme
Claude ile Tool Use: Client Tool'lar, Tool Loop, Strict Mode ve Server Tool'lar

Claude'da tool use için tool'u name, description ve strict JSON Schema ile tanımlar, stop_reason tool_use geldikçe tool'ları çalıştırıp bütün tool_result'ları tek user mesajında geri gönderirsin; yetki kontrolü tool'un içinde olmalı. tool_choice any'nin Opus 5.5'te 400 vermesi, parallel çağrılar, web search ve tool search, SDK'nın tool runner'ı ve loop'u sahte HttpMessageHandler ile test etmek.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Anthropic C# SDK 13.8.0 ve Claude API version 2026-10-21 ile derlenmiş, birim testleri çalıştırılmıştır. Tool loop testleri SDK'nın gerçek client'ını, ağa çıkmayan sahte bir HttpMessageHandler ile kullanıyor; cevaplar belgedeki örneklerden türetilmiş fixture'lar. Tool use, strict mode, server tool ve tool runner bilgileri 15 Kasım 2026'da platform.claude.com belgelerinden alınmıştır. Bu yazıda resmî belgelerden yararlanılmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için alışveriş asistanına sipariş sorgulama ekleniyor. İlk denemede Emre, system prompt'a "Kullanıcıya yalnız kendi siparişlerini göster" yazıyor ve get_order_status tool'una sipariş numarasını verip bütün siparişlere erişim tanıyor. Testte Alan, Ada'nın 1042 numaralı siparişini soruyor ve cevabı alıyor. Emre ardından tool'u zorunlu kılmak için tool_choice: {"type": "any"} ekliyor; model Opus 5.5'e çevrildiğinde istekler 400 dönmeye başlıyor.

Tahminin ne? Yetki kuralı prompt'ta durursa ne kadar güvenilir? Ve neden zorunlu tool seçimi bazı modellerde hiç kabul edilmiyor? Tool calling'in sağlayıcıdan bağımsız mantığını ("model fonksiyon çağırmaz, çağrı ister") LLM Temelleri serisinin on birinci ve on ikinci yazılarında, agent loop'unu ve durma koşulunu AI Agent'ları ve MCP (Model Context Protocol; modele tool ve veri kaynaklarını standart bir protokolle bağlama yöntemi) serisinin ikinci yazısında anlattık. Bu yazıda Claude API'deki karşılıklarına bakıyoruz: client tool tanımı, tool loop'u, strict mode, server tool'lar ve SDK'nın tool runner'ı.

Client Tool Tanımı: name, description, input_schema, strict

Client tool, senin kodunda çalışan tool. İsteğin tools alanında name, description ve JSON Schema biçiminde bir input_schema ile tanımlanıyor. İsim ^[a-zA-Z0-9_-]{1,128}$ kalıbına uymalı. Önce Bookstore'un sipariş modeli:

namespace Bookstore.Api.Ordering;

public sealed record Order(string Id, Guid CustomerId, string Status, DateTime PlacedAtUtc, IReadOnlyList<string> Isbns);

public interface IOrderRepository
{
    Order? Find(string orderId);
}

public sealed class InMemoryOrderRepository : IOrderRepository
{
    public static readonly Guid Ada = Guid.Parse("6f1c2a54-0d7e-4b8a-9a3f-1d2e3c4b5a60");
    public static readonly Guid Alan = Guid.Parse("0b9e8d7c-6a5f-4e3d-8c2b-1a0f9e8d7c6b");

    private static readonly Order[] Orders =
    [
        new("1042", Ada, "Shipped", new DateTime(2026, 11, 12, 9, 30, 0, DateTimeKind.Utc), ["9780441172719"]),
        new("1043", Alan, "Preparing", new DateTime(2026, 11, 14, 16, 5, 0, DateTimeKind.Utc), ["9780141439587", "9780547928227"]),
    ];

    public Order? Find(string orderId) => Orders.FirstOrDefault(o => o.Id == orderId);
}

// Oturumdaki müşteri; authentication'ın kendisi ASP.NET Core Web API serisinde anlatıldı.
public sealed record CurrentCustomer(Guid Id);

Sonra iki tool:

using System.Text.Json;
using Anthropic.Models.Messages;
using Bookstore.Api.Catalog;
using Bookstore.Api.Ordering;

namespace Bookstore.Api.Assistant;

public sealed class BookstoreTools(IBookRepository books, IOrderRepository orders)
{
    public const string SearchBooks = "search_books";
    public const string GetOrderStatus = "get_order_status";

    public static readonly ToolUnion SearchBooksTool = new Tool
    {
        Name = SearchBooks,
        Description = "Searches the Bookstore catalog by title, author or topic words. Returns up to 5 books with ISBN, title, author and price in TRY. "
                      + "Use it whenever the customer asks for a book recommendation or whether a book is in stock. Do not use it for order questions.",
        Strict = true,
        InputSchema = new InputSchema(new Dictionary<string, JsonElement>
        {
            ["type"] = JsonSerializer.SerializeToElement("object"),
            ["properties"] = JsonSerializer.SerializeToElement(new
            {
                query = new { type = "string", description = "Words to search for, for example 'fantasy' or 'Austen'." },
                max_price = new { type = "number", description = "Optional upper price limit in TRY." },
            }),
            ["required"] = JsonSerializer.SerializeToElement(new[] { "query" }),
            ["additionalProperties"] = JsonSerializer.SerializeToElement(false),
        }),
    };

    public static readonly ToolUnion GetOrderStatusTool = new Tool
    {
        Name = GetOrderStatus,
        Description = "Returns the status, order date and ISBNs of one order of the signed-in customer. "
                      + "Use it when the customer asks where an order is. It only sees the signed-in customer's own orders.",
        Strict = true,
        InputSchema = new InputSchema(new Dictionary<string, JsonElement>
        {
            ["type"] = JsonSerializer.SerializeToElement("object"),
            ["properties"] = JsonSerializer.SerializeToElement(new
            {
                order_id = new { type = "string", description = "The order number, for example '1042'." },
            }),
            ["required"] = JsonSerializer.SerializeToElement(new[] { "order_id" }),
            ["additionalProperties"] = JsonSerializer.SerializeToElement(false),
        }),
    };

    public static IReadOnlyList<ToolUnion> Definitions => [SearchBooksTool, GetOrderStatusTool];

    // ...

Define tools sayfası, tool performansındaki en önemli etkenin ayrıntılı açıklama olduğunu söylüyor ve tool başına en az 3-4 cümle öneriyor: tool ne yapar, ne zaman kullanılır (ve ne zaman kullanılmaz), her parametre ne anlama gelir, neyi döndürmez. Sayfanın diğer önerileri de işimize yarıyor: birbirine yakın işlemleri az sayıda tool'da toplamak, tool sayısı arttıkça isimlere servis öneki koymak, tool cevabında yalnız modelin bir sonraki adım için ihtiyaç duyduğu alanları döndürmek. Bir uyarı da var: bir parametre modelden "neden bu çağrıyı yaptığını" istiyorsa akıl yürütme değil kısa bir açıklama iste; aksi hâlde ikinci yazıda gördüğümüz reasoning_extraction refusal'ı gelebilir. Tool tasarımının ayrıntısı AI Agent'ları ve MCP serisinin üçüncü yazısında.

Strict Mode ve tool_choice

strict: true, modelin tool input'unu şemaya uyacak şekilde üretmesini garanti ediyor. Strict tool use sayfası bunu grammar-constrained sampling (modelin token seçimini şemaya uyan çıktılarla sınırlamak) diye tarif ediyor. Strict olmadan model "2" gibi string bir sayı gönderebilir ya da zorunlu bir alanı atlayabilir; strict ile input her zaman şemaya uyuyor ve tool adı her zaman geçerli. İki koşul var: object'lerde additionalProperties: false olmalı ve şema, structured outputs ile aynı JSON Schema alt kümesinde kalmalı. Sınırlar da aynı: bir istekte en fazla 20 strict tool, bütün strict şemalarda toplam 24 opsiyonel parametre ve anyOf ya da tip dizisi kullanan en fazla 16 parametre. Belge bir ayrıntıyı ayrıca belirtiyor: enum değerlerinin büyük-küçük harfi garanti değil, karşılaştırmayı büyük-küçük harfe duyarsız yap.

Emre'nin ikinci sorunu tool_choice'taydı. Define tools sayfasına göre Fable 5.2, Opus 5.5, Sonnet 5.5 ve Fable 5.1, tool_choice tipi any ya da tool olan istekleri 400 ile reddediyor. Bu modellerde auto (varsayılan) ya da none kullanılıyor; şemaya uyan input için strict, sabit bir JSON çıktısı için altıncı yazıdaki structured outputs öneriliyor. Haiku 5.5'te zorunlu tool seçimi çalışıyor ama model değiştirdiğinde kırılmasın diye Bookstore'da auto ile kalıyoruz ve tool'u ne zaman kullanacağını açıklamada ve system prompt'ta söylüyoruz.

Tool Loop: tool_use, tool_result ve Parallel Çağrılar

Model bir tool çağırmak istediğinde cevap stop_reason: "tool_use" ile geliyor ve content içinde bir ya da birden fazla tool_use block'u var; her birinde id, name ve input. Dokümandaki örnek yanıt:

{
  "id": "msg_01Aq9w938a90dw8q",
  "model": "claude-opus-5-5",
  "stop_reason": "tool_use",
  "role": "assistant",
  "content": [
    {
      "type": "text",
      "text": "I'll check the current weather in San Francisco for you."
    },
    {
      "type": "tool_use",
      "id": "toolu_01A09q90qw90lq917835lq9",
      "name": "get_weather",
      "input": { "location": "San Francisco, CA", "unit": "celsius" }
    }
  ]
}

Sen tool'u çalıştırıp sonucu bir tool_result block'u olarak, tool_use_id ile eşleştirerek geri gönderiyorsun. Handle tool calls ve parallel tool use sayfalarındaki kurallar kısa ama katı: assistant'ın tool_use mesajından hemen sonra tool sonuçlarını taşıyan bir user mesajı gelmeli, araya başka mesaj giremez; bütün tool_result'lar tek bir user mesajında olmalı; o mesajda tool_result block'ları en başta durmalı, varsa metin onlardan sonra gelmeli. Stop reasons sayfası bir tuzağı da yazıyor: tool sonuçlarının hemen arkasına metin eklemek, modelin turn'ü boş bir cevapla bitirmesine yol açabiliyor.

Claude 4 ve sonraki modeller faydalı olduğunda birden fazla tool'u aynı cevapta çağırıyor. Belge çalıştırma sırasını sana bırakıyor: salt okuyan ve birbirinden bağımsız tool'ları paralel, yan etkisi olanları sırayla çalıştırmak makul. tool_choice içindeki disable_parallel_tool_use: true ile model bir turda en fazla bir tool çağırmakla sınırlanabiliyor.

Loop'u kendimiz yazıyoruz:

using Anthropic;
using Anthropic.Models.Messages;
using Bookstore.Api.Ai;
using Bookstore.Api.Ordering;
using Microsoft.Extensions.Options;

namespace Bookstore.Api.Assistant;

public sealed record AgentAnswer(string Text, ReplyOutcome Outcome, int Rounds, IReadOnlyList<string> ToolCalls, TokenUsage Usage);

public sealed class AssistantAgent(
    IAnthropicClient claude,
    BookstoreTools tools,
    IOptions<ClaudeOptions> options,
    ILogger<AssistantAgent> logger)
{
    public const int MaxRounds = 5;

    public const string SystemPrompt = """
        You are the shopping assistant of Bookstore, an online bookstore. Answer in the customer's language.
        Use search_books for book questions and get_order_status for order questions; never guess prices or order states.
        Use web search only for questions about an author's life, and cite the source.
        If a tool returns an error, explain it briefly and ask the customer for what is missing.
        """;

    public MessageCreateParams BuildRequest(IReadOnlyList<MessageParam> messages) => new()
    {
        Model = options.Value.Model,
        MaxTokens = 4096,
        System = SystemPrompt,
        Tools = [.. BookstoreTools.Definitions, BookstoreTools.AuthorWebSearchTool],
        Messages = messages,
    };

    public async Task<AgentAnswer> AskAsync(string question, CurrentCustomer customer, CancellationToken cancellationToken)
    {
        List<MessageParam> messages = [new UserMessageParam { Content = question }];
        List<string> toolCalls = [];
        long input = 0, output = 0;

        for (int round = 1; round <= MaxRounds; round++)
        {
            Message response = await claude.Messages.Create(BuildRequest(messages), cancellationToken);
            ClaudeReply reply = ClaudeReplyReader.Read(response);
            input += reply.Usage.Input;
            output += reply.Usage.Output;
            TokenUsage usage = new(input, output, 0, 0, 0);

            if (reply.Outcome is not (ReplyOutcome.NeedsTools or ReplyOutcome.Paused))
            {
                return new AgentAnswer(reply.Text, reply.Outcome, round, toolCalls, usage);
            }

            // Assistant turn'ü thinking ve tool_use block'larıyla birlikte değiştirilmeden geri gider.
            messages.Add(new AssistantMessageParam { Content = reply.AssistantContent.ToList() });

            if (reply.Outcome == ReplyOutcome.Paused)
            {
                // pause_turn: server tool döngüsü sınırına geldi; turn'ü geri göndermek Claude'un devam etmesini sağlar.
                continue;
            }

            List<ContentBlockParam> results = [];
            foreach (ContentBlock block in response.Content)
            {
                if (block.TryPickToolUse(out ToolUseBlock? call))
                {
                    toolCalls.Add(call.Name);
                    results.Add(tools.Execute(call, customer));
                }
            }

            // Bütün tool_result'lar TEK bir user mesajında gider ve önlerinde başka bir şey olmaz.
            messages.Add(new UserMessageParam { Content = results });
            logger.LogInformation("Round {Round}: ran {Count} tool call(s)", round, results.Count);
        }

        logger.LogWarning("Stopped after {MaxRounds} rounds", MaxRounds);
        return new AgentAnswer("I could not finish this request. A team member will help you.", ReplyOutcome.Unknown,
            MaxRounds, toolCalls, new TokenUsage(input, output, 0, 0, 0));
    }
}

05-tool-loop-tr.png

Bu loop'ta dört karar var. En fazla 5 tur: model tool çağırmaya devam etse de loop duruyor ve kullanıcıya sabit bir mesaj gidiyor; durma koşulu olmayan bir agent, LLM Uygulama Güvenliği serisinin yedinci yazısındaki "denial of wallet" sorununun en kısa yolu. Assistant turn'ü, ikinci yazıdaki gibi block'larıyla olduğu gibi geri gidiyor; thinking belgesi tool kullanan bir turn içinde thinking block'larını geri göndermeyi zorunlu tutuyor. pause_turn gelirse (server tool döngüsü sınırına geldiğinde) turn'ü geri gönderip devam ediyoruz. Ve her tool_use için bir tool_result, hepsi tek mesajda.

Yetki Kontrolü Tool'un İçinde

get_order_status'un hangi müşteri adına çalıştığı modelden gelmiyor. Model yalnız sipariş numarasını veriyor; müşteri, endpoint'te kimliği doğrulanmış kullanıcıdan alınıyor ve tool'a koddan geçiyor:

        // Müşteri, request body'den ya da modelden değil, kimliği doğrulanmış kullanıcıdan gelir.
        app.MapPost("/api/assistant/ask", async Task<IResult> (
            AssistantQuestion body, ClaimsPrincipal user, AssistantAgent agent, CancellationToken ct) =>
        {
            if (!Guid.TryParse(user.FindFirstValue(ClaimTypes.NameIdentifier), out Guid customerId))
            {
                return TypedResults.Unauthorized();
            }

            return TypedResults.Ok(await agent.AskAsync(body.Question, new CurrentCustomer(customerId), ct));
        });

Tool çalışırken, siparişin bu müşteriye ait olup olmadığını kodla kontrol ediyor:

    public string Run(string name, IReadOnlyDictionary<string, JsonElement> input, CurrentCustomer customer) => name switch
    {
        SearchBooks => RunSearch(input),
        GetOrderStatus => RunOrderStatus(input, customer),
        _ => throw new ToolInputException($"Unknown tool '{name}'."),
    };

    public ToolResultBlockParam Execute(ToolUseBlock call, CurrentCustomer customer)
    {
        try
        {
            return new ToolResultBlockParam(call.ID) { Content = Run(call.Name, call.Input, customer) };
        }
        catch (ToolInputException ex)
        {
            // Model hatayı sonuç olarak alır ve kendini düzeltebilir; API açısından bu bir exception değil.
            return new ToolResultBlockParam(call.ID) { Content = ex.Message, IsError = true };
        }
    }

    private string RunSearch(IReadOnlyDictionary<string, JsonElement> input)
    {
        string query = input["query"].GetString() ?? string.Empty;
        decimal? maxPrice = input.TryGetValue("max_price", out JsonElement price) ? price.GetDecimal() : null;

        var found = books.Search(query, maxPrice).Take(5)
            .Select(b => new { isbn = b.Isbn, title = b.Title, author = b.Author, price_try = b.Price });
        return JsonSerializer.Serialize(found);
    }

    private string RunOrderStatus(IReadOnlyDictionary<string, JsonElement> input, CurrentCustomer customer)
    {
        string orderId = input["order_id"].GetString() ?? string.Empty;
        Order? order = orders.Find(orderId);

        // Yetki kontrolü prompt'ta değil kodda: başka müşterinin siparişi, olmayan bir sipariş gibi görünür.
        if (order is null || order.CustomerId != customer.Id)
        {
            throw new ToolInputException($"No order {orderId} was found for this customer. Ask the customer to check the number.");
        }

        return JsonSerializer.Serialize(new { order_id = order.Id, status = order.Status, placed_at_utc = order.PlacedAtUtc, isbns = order.Isbns });
    }
}

public sealed class ToolInputException(string message) : Exception(message);

Başka bir müşterinin siparişi "bulunamadı" olarak dönüyor; böylece model, bir siparişin var olduğunu bile öğrenemiyor. Önce Tahmin Et'teki sorunun cevabı bu: system prompt'taki "yalnız kendi siparişlerini göster" kuralı bir istek, kontrol değil. Kullanıcının mesajı ya da bir tool sonucunun içindeki metin bu kuralı aşmaya çalışabilir. Excessive agency ve tool yetkisini daraltmanın ayrıntısı LLM Uygulama Güvenliği serisinin dördüncü yazısında. Handle tool calls sayfası bir uyarı daha yapıyor: tool sonuçları senin kontrolünde olmayan içerik taşıyabilir (bir web sayfası, gelen bir e-posta); bu içeriği system prompt'a ya da düz user metnine değil, tool_result block'unun içine koy.

Hatalar exception olarak değil, bir tool sonucu olarak dönüyor: is_error: true olan bir tool_result. Belge hata mesajının ne olduğunu ve modelin bundan sonra ne yapması gerektiğini söylemesini öneriyor; bizim mesajımız "müşteriden numarayı kontrol etmesini iste" diyor.

Server tool'ları Anthropic'in sunucusu çalıştırıyor. Sen yalnız tanımı gönderiyorsun; model aracı kullanırsa sonuç, server_tool_use ve ona karşılık gelen sonuç block'u (örneğin web_search_tool_result) olarak aynı cevapta geliyor. Senin tarafında tool_result göndermen gerekmiyor. Bookstore asistanına yazar soruları için web search ekledik:

    // Yazar soruları için: Anthropic'in çalıştırdığı bir server tool; sonuçları aynı cevapta gelir.
    public static readonly ToolUnion AuthorWebSearchTool = new WebSearchTool20260318
    {
        MaxUses = 3,
        AllowedDomains = ["en.wikipedia.org"],
        AllowedCallers = [WebSearchTool20260318AllowedCaller.Direct],
    };

Sürüm adlarına dikkat. Web search belgesi üç sürüm sayıyor: web_search_20250305 temel arama, web_search_20260209 dynamic filtering ekliyor (model sonuçları kendi yazdığı kodla süzüp context'e yalnız ilgili kısmı alıyor), web_search_20260318 buna ek olarak response inclusion ayarı getiriyor. 20260209 ve sonrası varsayılan olarak aramayı code execution içinden çağırıyor; doğrudan çağrı için allowed_callers: ["direct"] gerekiyor. Belgeye göre programmatic tool calling desteklemeyen modellerde bu ayar zorunlu, ayrıca dynamic filtering olmayan bu yapılandırma ZDR (zero data retention; isteklerin saklanmadığı veri anlaşması) için de uygun. Biz bu yüzden Direct verdik. allowed_domains ile aramayı en.wikipedia.org ile sınırladık; max_uses bir turdaki arama sayısını kesiyor.

Server tool hataları da HTTP hatası değil. Arama başarısız olursa API yine 200 dönüyor ve hata sonucun içinde (dokümandaki örnek):

{
  "type": "web_search_tool_result",
  "tool_use_id": "srvtoolu_a93jad",
  "content": {
    "type": "web_search_tool_result_error",
    "error_code": "max_uses_exceeded"
  }
}

İki durum daha var. Server tool döngüsü bir istekte varsayılan 10 iterasyon sınırına gelirse stop_reason: "pause_turn" geliyor; cevabı olduğu gibi geri gönderip devam ettiriyorsun. Model aynı turda hem bir server tool hem bir client tool çağırırsa API server tool'u çalıştırmadan dönüyor; cevapta sonucu olmayan bir server_tool_use oluyor ve stop_reason tool_use. Server tools sayfasına göre bu durumda yalnız client tool'ların tool_result'larını içeren bir user mesajı gönderiyorsun ve tools dizisini aynı tutuyorsun; API bekleyen server tool'u bir sonraki istekte çalıştırıyor. Bizim loop'umuz bu ikisini de doğru ele alıyor.

Fiyatlar 15 Kasım 2026 itibarıyla şöyle: web search 1.000 arama başına 10 dolar artı arama sonuçlarının input token'ları; web fetch için token dışında ek ücret yok. Code execution, web search ya da web fetch'in 20260209 ve sonraki sürümleriyle birlikte kullanılınca ücretsiz; tek başına kullanılınca çalışma süresi üzerinden ücretleniyor (organization başına ayda 1.550 saat ücretsiz, sonrası container başına saatte 0,05 dolar). Code execution ve skill'leri dokuzuncu yazıda ayrıntılı göreceğiz.

Tool sayısı büyüdükçe bir server tool daha işe yarıyor: tool search. Tool search sayfasına göre model, tool sayısı 30-50'yi geçince doğru tool'u seçmekte zorlanmaya başlıyor ve büyük tool tanımları context'i dolduruyor. Tanımlara defer_loading: true verdiğinde bu tool'lar context'e baştan girmiyor; model ihtiyaç duyunca tool_search_tool_regex_20251119 ya da tool_search_tool_bm25_20251119 ile arayıp yalnız bulduklarını yüklüyor. Tool search tool'unun kendisi deferred olamaz.

SDK'nın Tool Runner'ı (beta)

C# SDK'da loop'u senin yerine yürüten bir yardımcı var: client.Beta.Messages.ToolRunner(...). Tool runner sayfası bunu beta olarak listeliyor. Her tool bir BetaRunnableTool: bir Definition ve tool çağrıldığında çalışan bir Run delegate'i. Aynı sipariş tool'u runner ile:

using System.Text.Json;
using Anthropic;
using Anthropic.Helpers.Beta;
using Anthropic.Models.Beta.Messages;
using Bookstore.Api.Ai;
using Bookstore.Api.Ordering;
using Microsoft.Extensions.Options;
using BetaInputSchema = Anthropic.Models.Beta.Messages.InputSchema;
using BetaMessageCreateParams = Anthropic.Models.Beta.Messages.MessageCreateParams;

namespace Bookstore.Api.Assistant;

// Aynı asistan, SDK'nın tool runner'ıyla (beta): loop'u SDK yürütür.
public sealed class ToolRunnerAssistant(IAnthropicClient claude, BookstoreTools tools, IOptions<ClaudeOptions> options)
{
    public async Task<string> AskAsync(string question, CurrentCustomer customer, CancellationToken cancellationToken)
    {
        BetaRunnableTool orderStatus = new()
        {
            Name = BookstoreTools.GetOrderStatus,
            Definition = new BetaTool
            {
                Name = BookstoreTools.GetOrderStatus,
                Description = "Returns the status of one order of the signed-in customer.",
                Strict = true,
                InputSchema = new BetaInputSchema(new Dictionary<string, JsonElement>
                {
                    ["type"] = JsonSerializer.SerializeToElement("object"),
                    ["properties"] = JsonSerializer.SerializeToElement(new { order_id = new { type = "string" } }),
                    ["required"] = JsonSerializer.SerializeToElement(new[] { "order_id" }),
                    ["additionalProperties"] = JsonSerializer.SerializeToElement(false),
                }),
            },
            Run = (call, _) =>
            {
                try
                {
                    return Task.FromResult<BetaToolResultBlockParamContent>(tools.Run(call.Name, call.Input, customer));
                }
                catch (ToolInputException ex)
                {
                    // Runner, BetaToolError'ı is_error: true olan bir tool_result'a çevirir.
                    throw new BetaToolError(ex.Message);
                }
            },
        };

        BetaToolRunner runner = claude.Beta.Messages.ToolRunner(
            new BetaMessageCreateParams
            {
                Model = options.Value.Model,
                MaxTokens = 4096,
                System = AssistantAgent.SystemPrompt,
                Messages = [new BetaUserMessageParam { Content = question }],
            },
            [orderStatus],
            maxIterations: AssistantAgent.MaxRounds);

        BetaMessage final = await runner.RunUntilDoneAsync(cancellationToken);
        return string.Concat(final.Content.Select(b => b.TryPickText(out BetaTextBlock? text) ? text.Text : string.Empty));
    }
}

RunUntilDoneAsync, model tool çağırmayı bırakana ya da maxIterations sınırına gelene kadar loop'u yürütüyor. Hata içeriğini kontrol etmek için belge BetaToolError fırlatmayı öneriyor; runner bunu is_error: true olan bir sonuca çeviriyor. Belge ne zaman manuel loop'a dönülmesi gerektiğini de söylüyor: insan onayı, özel loglama ya da koşullu çalıştırma gerektiğinde. Bookstore'da sipariş iptali gibi bir tool eklendiğinde onay adımı gerekecek; bu yüzden ana yolumuz manuel loop, runner ise kısa ve salt okuyan senaryolar için bir seçenek. İnsan onayının tasarımı AI Agent'ları ve MCP serisinin yedinci yazısında.

Loop'u API'ye Gitmeden Test Etmek

AnthropicClient'ın HttpClient property'si set edilebiliyor. Bunu, hazır JSON dönen ve SDK'nın gönderdiği gövdeyi kaydeden bir HttpMessageHandler ile kullanınca SDK'nın gerçek serileştirmesini ve gerçek cevap okuma kodunu ağa çıkmadan test edebiliyoruz:

using System.Net;
using System.Text;
using Anthropic;

namespace Bookstore.Api.Tests;

// SDK isteklerine hazır JSON'la cevap verir ve SDK'nın gönderdiğini kaydeder; process dışına bir şey çıkmaz.
public sealed class FakeClaudeHandler(params string[] responses) : HttpMessageHandler
{
    private readonly Queue<string> _responses = new(responses);

    public List<string> RequestBodies { get; } = [];
    public List<HttpRequestMessage> Requests { get; } = [];

    public AnthropicClient CreateClient() =>
        new() { ApiKey = "fake-api-key-for-tests", HttpClient = new HttpClient(this), MaxRetries = 0 };

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        Requests.Add(request);
        RequestBodies.Add(await request.Content!.ReadAsStringAsync(cancellationToken));
        return new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StringContent(_responses.Dequeue(), Encoding.UTF8, "application/json"),
        };
    }
}

İlk cevap, belgedeki tool_use cevabının şeklinde ama bizim iki tool'umuzu paralel çağıran bir fixture; ikincisi end_turn:

using System.Text.Json;
using Anthropic.Models.Messages;
using Bookstore.Api.Ai;
using Bookstore.Api.Assistant;
using Bookstore.Api.Catalog;
using Bookstore.Api.Ordering;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;

namespace Bookstore.Api.Tests;

public sealed class AssistantAgentTests
{
    // "Handle tool calls" sayfasındaki tool_use cevabının şekli; bizim iki tool paralel çağrılmış hâlde.
    private const string ToolUseResponse = """
        {"id":"msg_01","type":"message","role":"assistant","model":"claude-haiku-5-5",
         "content":[
           {"type":"text","text":"Let me check both."},
           {"type":"tool_use","id":"toolu_01","name":"get_order_status","input":{"order_id":"1042"}},
           {"type":"tool_use","id":"toolu_02","name":"search_books","input":{"query":"dragon","max_price":350}}],
         "stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":900,"output_tokens":120}}
        """;

    private const string FinalResponse = """
        {"id":"msg_02","type":"message","role":"assistant","model":"claude-haiku-5-5",
         "content":[{"type":"text","text":"Order 1042 has shipped. The Hobbit is 310 TRY."}],
         "stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":1150,"output_tokens":40}}
        """;

    private static AssistantAgent CreateAgent(FakeClaudeHandler handler) => new(
        handler.CreateClient(),
        new BookstoreTools(new InMemoryBookRepository(), new InMemoryOrderRepository()),
        Options.Create(new ClaudeOptions()),
        NullLogger<AssistantAgent>.Instance);

    [Fact]
    public async Task AskAsync_RunsBothToolsAndSendsResultsInOneUserMessage()
    {
        FakeClaudeHandler handler = new(ToolUseResponse, FinalResponse);

        AgentAnswer answer = await CreateAgent(handler).AskAsync(
            "Where is order 1042, and do you have a dragon book under 350 TRY?",
            new CurrentCustomer(InMemoryOrderRepository.Ada), CancellationToken.None);

        Assert.Equal(ReplyOutcome.Complete, answer.Outcome);
        Assert.Equal(2, answer.Rounds);
        Assert.Equal(["get_order_status", "search_books"], answer.ToolCalls);

        JsonElement second = JsonDocument.Parse(handler.RequestBodies[1]).RootElement;
        JsonElement[] messages = second.GetProperty("messages").EnumerateArray().ToArray();
        Assert.Equal(["user", "assistant", "user"], messages.Select(m => m.GetProperty("role").GetString()));

        JsonElement[] results = messages[2].GetProperty("content").EnumerateArray().ToArray();
        Assert.All(results, r => Assert.Equal("tool_result", r.GetProperty("type").GetString()));
        Assert.Equal(["toolu_01", "toolu_02"], results.Select(r => r.GetProperty("tool_use_id").GetString()));
        Assert.Contains("Shipped", results[0].GetProperty("content").GetString());
        Assert.Contains("The Hobbit", results[1].GetProperty("content").GetString());
    }

    [Fact]
    public async Task AskAsync_AnotherCustomersOrder_IsAnErrorResultNotData()
    {
        FakeClaudeHandler handler = new(ToolUseResponse, FinalResponse);

        await CreateAgent(handler).AskAsync("Where is order 1042?",
            new CurrentCustomer(InMemoryOrderRepository.Alan), CancellationToken.None);

        JsonElement orderResult = JsonDocument.Parse(handler.RequestBodies[1]).RootElement
            .GetProperty("messages")[2].GetProperty("content")[0];
        Assert.True(orderResult.GetProperty("is_error").GetBoolean());
        Assert.DoesNotContain("Shipped", orderResult.GetProperty("content").GetString());
    }
}

Üçüncü test, SDK'nın gönderdiği header'ları ve tool tanımlarını kontrol ediyor:

    [Fact]
    public async Task AskAsync_SdkSendsApiVersionAndStrictTools()
    {
        FakeClaudeHandler handler = new(FinalResponse);

        await CreateAgent(handler).AskAsync("Hi", new CurrentCustomer(InMemoryOrderRepository.Ada), CancellationToken.None);

        Assert.Equal("2026-10-21", handler.Requests[0].Headers.GetValues("anthropic-version").Single());
        Assert.Equal("Bearer", handler.Requests[0].Headers.Authorization?.Scheme);

        JsonElement tools = JsonDocument.Parse(handler.RequestBodies[0]).RootElement.GetProperty("tools");
        Assert.True(tools[0].GetProperty("strict").GetBoolean());
        Assert.False(tools[0].GetProperty("input_schema").GetProperty("additionalProperties").GetBoolean());
        Assert.Equal("web_search_20260318", tools[2].GetProperty("type").GetString());
    }
Passed!  - Failed:     0, Passed:    21, Skipped:     0, Total:    21, Duration: 58 ms - Bookstore.Api.Tests.dll (net10.0)

Bu testler bize üç şeyi doğruladı: SDK 13.8.0 gerçekten anthropic-version: 2026-10-21 ve Authorization: Bearer gönderiyor, strict tool tanımı additionalProperties: false ile gidiyor ve loop'un ikinci isteğinde iki tool sonucu tek bir user mesajında. O mesajın kendisi:

{
  "role": "user",
  "content": [
    {
      "type": "tool_result",
      "tool_use_id": "toolu_01",
      "content": "{\"order_id\":\"1042\",\"status\":\"Shipped\",\"placed_at_utc\":\"2026-11-12T09:30:00Z\",\"isbns\":[\"9780441172719\"]}"
    },
    {
      "type": "tool_result",
      "tool_use_id": "toolu_02",
      "content": "[{\"isbn\":\"9780547928227\",\"title\":\"The Hobbit\",\"author\":\"J. R. R. Tolkien\",\"price_try\":310.00}]"
    }
  ]
}
rounds=2 tools=get_order_status,search_books input=2050 output=160

Üretimde Dikkat

Tur ve token sınırı. MaxRounds tek başına yetmiyor; her turun input'u bir öncekinden büyük, çünkü geçmiş ve tool sonuçları birikiyor. Tur başına usage'ı topla ve bir bütçe aşılınca dur. Agent döngüleri için üçüncü yazıdaki task budgets (beta) da bir seçenek.

Tool sonucunun boyutu. Bir arama tool'unun bin satır döndürmesi bütün context'i doldurur. Sonuçları sınırla (bizde 5 kitap) ve yalnız gereken alanları gönder.

Yan etkili tool'lar. Okuyan tool'ları paralel çalıştırmak güvenli, yazan tool'ları değil. Sipariş iptali, iade başlatma gibi tool'lar idempotent olmalı ve kullanıcı onayı istemeli; model aynı çağrıyı iki kez yapabilir.

Server tool maliyeti. Web search hem arama başına ücretleniyor hem sonuçlarıyla input token'ını büyütüyor. max_uses ve domain listesi koy, kullanımı usage.server_tool_use alanından izle.

Tool sonuçlarına güvenmemek. Bir web sayfası ya da müşteri notu "önceki talimatları unut" gibi bir metin içerebilir. Tool sonuçlarını tool_result içinde tut ve yetki kararlarını hiçbir zaman bu içeriğe bırakma.

Sık Düşülen Tuzaklar

Tool input'unu string olarak eşleştirmek. input bir JSON object; alanları JsonElement olarak oku, string birleştirip parse etme.

Tool sonuçlarını ayrı user mesajlarına bölmek. Bütün tool_result'lar tek mesajda, en başta olmalı; aksi hâlde API hata döner ya da paralel çağrı davranışı bozulur.

Yetki kontrolünü modele bırakmak. Prompt'taki kural kontrol değildir; müşteri kimliği koddan gelmeli ve tool içinde kontrol edilmeli.

Opus 5.5 ya da Sonnet 5.5'te tool_choice: any kullanmak. 400 döner; auto ve strict kullan.

Server tool hatasını exception sanmak. Arama hataları 200 cevabın içinde, sonuç block'unda gelir.

Durma koşulu koymamak. Tur sınırı olmayan bir loop, bir tool hatasında sonsuza kadar aynı çağrıyı tekrarlayabilir.

Küçük Bir Deneme

★☆☆ AssistantAgentTests'e, model var olmayan bir tool adı çağırdığında (get_wishlist) sonucun is_error: true olduğunu doğrulayan bir test ekle.

★★☆ BookstoreTools'a get_book_details(isbn) tool'unu strict olarak ekle. 13 haneli ISBN (kitapların uluslararası standart numarası) için şemada bir pattern kullan ve belgedeki desteklenen regex özellikleriyle uyumlu olduğunu kontrol et.

★★★ Loop'a bir onay adımı ekle: cancel_order tool'u çağrıldığında loop dursun, kullanıcıya onay sorusu dönsün, onay gelince aynı tool_use_id ile tool_result gönderilip devam edilsin. Konuşma durumunu ikinci yazıdaki IConversationStore ile sakla.

Tool'larla modelin çağrılarını şemaya bağladık. Altıncı yazıda modelin cevabının kendisini şemaya bağlıyoruz: output_config.format ile structured outputs, C# record'larından JSON Schema üretmek ve şemaya uyan ama iş kuralına uymayan bir iade talebini yakalamak.