DEV Community

Cover image for Crea un agente con Tool Use de Claude en C#: no es un chatbot con esteroides
Juan Gómez
Juan Gómez

Posted on

Crea un agente con Tool Use de Claude en C#: no es un chatbot con esteroides

Crea un agente con Tool Use de Claude en C#: no es un chatbot con esteroides

Hoy todo el mundo lanza "agentes de IA". Levanta el capó de la mayoría y encontrarás un chatbot con un prompt de personalidad y un esperanzado // TODO: que haga algo. Puede hablar de tus pedidos. Lo que no puede es consultar uno de verdad.

Un agente real cierra esa brecha. Le das al modelo un conjunto de herramientas —métodos normales de tu código— y él decide, a mitad de la conversación, llamar a una. Tu código ejecuta el trabajo real, le devuelve el resultado, y el modelo sigue hasta que puede responder de verdad. Ese bucle es todo el truco, y lo puedes montar en C# en unas sesenta líneas. Lo haremos dos veces: primero a mano con HttpClient para que veas el protocolo tal cual viaja, y luego con el SDK oficial de Anthropic para .NET, que es la versión corta que de verdad pondrías en producción.

Qué es realmente "tool use"

Tool use (o function calling) es un bucle pequeño y estricto:

  1. Declaras herramientas. Cada una es un name, una description y un JSON Schema para sus parámetros. Nada más — no le envías código a Claude, solo la forma.
  2. Claude responde con un bloque tool_use en lugar de texto. Te está diciendo: "por favor ejecuta check_stock con { "sku": "ETH-250" } y dime qué obtienes".
  3. Tu código lo ejecuta. Aquí está la parte que se le escapa a mucha gente: Claude nunca ejecuta nada. Él pide; tu harness ejecuta. La frontera de seguridad se queda de tu lado de la valla.
  4. Le devuelves la respuesta como un bloque tool_result, atado a la petición por su id.
  5. Claude continúa — quizá llamando a más herramientas, quizá respondiendo. Repites el bucle hasta que deja de pedir.

Piensa en Claude como un colega senior muy rápido que puede leer tu API pero no tiene permiso para tocar producción: te dice exactamente qué botón pulsar, tú lo pulsas y le reportas. El criterio es del modelo; las manos son tuyas.


El agente que vamos a construir

Te presento a Aurora Coffee Co., una tienda ficticia con un agente de soporte que responde preguntas como "¿Ya salió mi pedido y todavía tienen los granos de Etiopía?". Tiene dos herramientas sobre datos en memoria, así que todo corre sin ninguna configuración externa:

  • get_order_status(order_id) — consulta un pedido.
  • check_stock(sku) — revisa el inventario de un producto.

Esta es la parte compartida: los datos y el ejecutor de herramientas. Los dos enfoques de abajo reutilizan este mismo método; lo único que cambia es la fontanería de Claude.

using System.Text.Json;

// La "base de datos" de Aurora Coffee Co. — en memoria, para que corra sin setup.
var orders = new Dictionary<string, Order>
{
    ["A-1001"] = new("A-1001", "shipped", "2026-07-26"),
    ["A-1002"] = new("A-1002", "processing", "2026-07-29"),
};
var stock = new Dictionary<string, int> { ["ETH-250"] = 42, ["COL-1KG"] = 0 };

// Tus herramientas son C# normal. Claude nunca ejecuta esto — solo te pide que lo hagas.
string RunTool(string name, IReadOnlyDictionary<string, JsonElement> args) => name switch
{
    "get_order_status" =>
        orders.TryGetValue(args["order_id"].GetString()!, out var o)
            ? $"Pedido {o.Id}: {o.Status}, ETA {o.Eta}."
            : "No hay ningún pedido con ese id.",
    "check_stock" =>
        stock.TryGetValue(args["sku"].GetString()!, out var qty)
            ? $"SKU {args["sku"].GetString()}: {qty} unidades en stock."
            : "SKU desconocido.",
    _ => $"Herramienta desconocida: {name}",
};

sealed record Order(string Id, string Status, string Eta);
Enter fullscreen mode Exit fullscreen mode

Fíjate en que RunTool recibe los parámetros como un diccionario de JsonElement. Los parseamos — nunca comparamos como texto el JSON crudo que manda Claude. Eso importa, y volveremos a ello.


Enfoque 1 — HttpClient a pelo (ver el protocolo)

Sin SDK, sin dependencias — solo HttpClient y System.Net.Http.Json. Esta es la versión que te enseña qué está pasando de verdad en el cable.

Primero, describe las herramientas como JSON Schema. Este es el array exacto que viaja en la petición:

var tools = new object[]
{
    new
    {
        name = "get_order_status",
        description = "Consulta el estado y la ETA de un pedido por su id.",
        input_schema = new
        {
            type = "object",
            properties = new
            {
                order_id = new { type = "string", description = "Id del pedido, p. ej. A-1001" },
            },
            required = new[] { "order_id" },
        },
    },
    new
    {
        name = "check_stock",
        description = "Consulta cuántas unidades de un SKU hay en stock ahora mismo.",
        input_schema = new
        {
            type = "object",
            properties = new
            {
                sku = new { type = "string", description = "SKU del producto, p. ej. ETH-250" },
            },
            required = new[] { "sku" },
        },
    },
};
Enter fullscreen mode Exit fullscreen mode

Los campos description cargan con todo el peso — son la forma en que Claude decide cuándo echar mano de cada herramienta. Escríbelos como escribirías un docstring para alguien junior, no como el nombre de una variable.

Ahora el cliente y el bucle del agente. Mantenemos una lista de mensajes que va creciendo y seguimos llamando hasta que Claude deja de pedir herramientas:

using System.Net.Http.Json;

var http = new HttpClient { BaseAddress = new Uri("https://api.anthropic.com") };
http.DefaultRequestHeaders.Add("x-api-key", Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY"));
http.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01");

var json = new JsonSerializerOptions(JsonSerializerDefaults.Web);

// La conversación crece a medida que avanzamos. Empezamos con la pregunta del usuario.
var messages = new List<object>
{
    new { role = "user", content = "¿El pedido A-1001 ya salió, y todavía tienen ETH-250 en stock?" },
};

while (true)
{
    var request = new { model = "claude-opus-4-8", max_tokens = 1024, tools, messages };

    var response = await http.PostAsJsonAsync("/v1/messages", request, json);
    response.EnsureSuccessStatusCode();
    var reply = await response.Content.ReadFromJsonAsync<JsonElement>();

    var stopReason = reply.GetProperty("stop_reason").GetString();
    var content = reply.GetProperty("content");

    // Devuelve el turno completo de Claude — con sus bloques tool_use incluidos — o la
    // siguiente petición no cuadrará con el tool_result que estamos a punto de enviar.
    messages.Add(new { role = "assistant", content });

    if (stopReason != "tool_use")
    {
        foreach (var block in content.EnumerateArray())
            if (block.GetProperty("type").GetString() == "text")
                Console.WriteLine(block.GetProperty("text").GetString());
        break;
    }

    // Claude pidió una o más herramientas. Ejecútalas todas; recoge cada resultado.
    var results = new List<object>();
    foreach (var block in content.EnumerateArray())
    {
        if (block.GetProperty("type").GetString() != "tool_use") continue;

        var name = block.GetProperty("name").GetString()!;
        var toolInput = block.GetProperty("input").Deserialize<Dictionary<string, JsonElement>>(json)!;

        results.Add(new
        {
            type = "tool_result",
            tool_use_id = block.GetProperty("id").GetString(),
            content = RunTool(name, toolInput),
        });
    }

    // Todos los tool_results vuelven en UN SOLO mensaje de usuario.
    messages.Add(new { role = "user", content = results });
}
Enter fullscreen mode Exit fullscreen mode

Tres detalles que te van a morder si los saltas:

  • Devuelve el turno del asistente tal cual, con bloques tool_use y todo. La petición de continuación debe contener el tool_use original para cada tool_result, emparejados por tool_use_id.
  • Devuelve todos los resultados en un único mensaje de usuario. Si Claude pide dos herramientas y repartes las respuestas en dos mensajes, aprende calladito a dejar de pedir herramientas en paralelo.
  • No llames args a tu variable del bucle. Los top-level statements ya definen un args implícito, y el compilador no se va a andar con timideces. No preguntes cómo lo sé.

Eso es un agente completo en un solo archivo — y puedes ver cada byte que cruza el cable. Escribir el JSON a mano cansa rápido, claro, que es justo para lo que existe el SDK.


Enfoque 2 — El SDK de Anthropic para .NET (la versión corta)

El mismo agente, con muchísima menos fontanería. Añade el paquete:

dotnet add package Anthropic
Enter fullscreen mode Exit fullscreen mode

Las definiciones de herramientas pasan a ser objetos tipados. InputSchema.Type se pone en "object" por ti — no lo asignes a mano:

using Anthropic;
using Anthropic.Models.Messages;
using System.Text.Json;

AnthropicClient client = new(); // lee ANTHROPIC_API_KEY

List<ToolUnion> tools =
[
    new Tool
    {
        Name = "get_order_status",
        Description = "Consulta el estado y la ETA de un pedido por su id.",
        InputSchema = new()
        {
            Properties = new Dictionary<string, JsonElement>
            {
                ["order_id"] = JsonSerializer.SerializeToElement(
                    new { type = "string", description = "Id del pedido, p. ej. A-1001" }),
            },
            Required = ["order_id"],
        },
    },
    new Tool
    {
        Name = "check_stock",
        Description = "Consulta cuántas unidades de un SKU hay en stock ahora mismo.",
        InputSchema = new()
        {
            Properties = new Dictionary<string, JsonElement>
            {
                ["sku"] = JsonSerializer.SerializeToElement(
                    new { type = "string", description = "SKU del producto, p. ej. ETH-250" }),
            },
            Required = ["sku"],
        },
    },
];
Enter fullscreen mode Exit fullscreen mode

El bucle es la misma idea, ahora con bloques tipados. Un detalle que conviene saber: el SDK te da bloques de respuesta (TextBlock, ToolUseBlock) pero la petición quiere bloques de parámetro (TextBlockParam, ToolUseBlockParam), y no hay atajo .ToParam() — los reconstruyes. Son unas pocas líneas, y mantienen honesto al sistema de tipos:

List<MessageParam> messages =
[
    new() { Role = Role.User, Content = "¿El pedido A-1001 ya salió, y todavía tienen ETH-250 en stock?" },
];

while (true)
{
    var response = await client.Messages.Create(new MessageCreateParams
    {
        Model = Model.ClaudeOpus4_8,
        MaxTokens = 1024,
        Tools = tools,
        Messages = messages,
    });

    List<ContentBlockParam> assistant = [];
    List<ContentBlockParam> results = [];

    foreach (var block in response.Content)
    {
        if (block.TryPickText(out TextBlock? text))
        {
            assistant.Add(new TextBlockParam { Text = text.Text });
        }
        else if (block.TryPickToolUse(out ToolUseBlock? call))
        {
            assistant.Add(new ToolUseBlockParam { ID = call.ID, Name = call.Name, Input = call.Input });
            results.Add(new ToolResultBlockParam { ToolUseID = call.ID, Content = RunTool(call.Name, call.Input) });
        }
    }

    messages.Add(new() { Role = Role.Assistant, Content = assistant });

    if (response.StopReason != "tool_use")
    {
        foreach (var t in response.Content.Select(b => b.Value).OfType<TextBlock>())
            Console.WriteLine(t.Text);
        break;
    }

    messages.Add(new() { Role = Role.User, Content = results });
}
Enter fullscreen mode Exit fullscreen mode

Fíjate en que RunTool no cambió en absoluto — ToolUseBlock.Input ya es un IReadOnlyDictionary<string, JsonElement>, así que nuestro ejecutor encaja tal cual.

Deja que el SDK maneje el bucle

Si no quieres escribir el bucle tú, el SDK trae un tool runner (en beta). A cada herramienta le das su esquema y el código que la ejecuta (un callback Run), y el runner se encarga de todo el ciclo llamar-ejecutar-continuar:

using Anthropic;
using Anthropic.Helpers.Beta;
using Anthropic.Models.Beta.Messages;
using System.Text.Json;

// Empaqueta el esquema de cada herramienta junto con el código que la responde.
BetaRunnableTool MakeTool(string name, string description, string prop, string hint) => new()
{
    Name = name,
    Definition = new BetaTool
    {
        Name = name,
        Description = description,
        InputSchema = new()
        {
            Properties = new Dictionary<string, JsonElement>
            {
                [prop] = JsonSerializer.SerializeToElement(new { type = "string", description = hint }),
            },
            Required = [prop],
        },
    },
    Run = (call, ct) => Task.FromResult<BetaToolResultBlockParamContent>(RunTool(call.Name, call.Input)),
};

List<IBetaRunnableTool> runnable =
[
    MakeTool("get_order_status", "Consulta el estado y la ETA de un pedido por id.", "order_id", "p. ej. A-1001"),
    MakeTool("check_stock", "Consulta las unidades en stock de un SKU.", "sku", "p. ej. ETH-250"),
];

var runner = client.Beta.Messages.ToolRunner(
    new MessageCreateParams
    {
        Model = "claude-opus-4-8",
        MaxTokens = 1024,
        Messages = [new() { Role = Role.User, Content = "¿A-1001 ya salió, y ETH-250 está en stock?" }],
    },
    runnable);

await foreach (BetaMessage message in runner)
    foreach (var block in message.Content)
        if (block.TryPickText(out var text))
            Console.WriteLine(text.Text);
Enter fullscreen mode Exit fullscreen mode

Sin bucle manual, sin reconstruir bloques — el runner llama a tus callbacks Run y mantiene la conversación viva hasta que Claude termina. Corras la versión que corras, la salida es la misma:

> ¿El pedido A-1001 ya salió, y todavía tienen ETH-250 en stock?
El pedido A-1001 ya salió y está previsto que llegue el 2026-07-26. Y sí — los
granos de Etiopía (ETH-250) están en stock, con 42 unidades disponibles.
Enter fullscreen mode Exit fullscreen mode

Dos herramientas, una pregunta, y Claude dedujo por su cuenta que necesitaba llamar a ambas.


Cuándo usar tool use — y cuándo no

Tool use es la opción correcta cuando el modelo necesita algo que no puede sacar solo del texto: datos en vivo o privados (tu base de datos, una API interna, el sistema de archivos) o la capacidad de realizar una acción (mandar un correo, crear un ticket, reembolsar un pedido). Si la respuesta está de verdad encerrada dentro de tus sistemas, tool use es cómo el modelo llega a ella.

Es la opción equivocada cuando bastaría con un solo prompt. Si solo necesitas JSON a partir de un texto, tira de structured outputs, no de un bucle de herramientas — te ahorras los viajes de ida y vuelta. No todo problema es un agente; algunos son solo un prompt bien formado.

Entre las dos implementaciones: ve con HttpClient a pelo cuando estés aprendiendo el protocolo, quieras cero dependencias o necesites control total del bucle — puertas de aprobación antes de que dispare una herramienta, logging a medida, revisión con humano en el circuito. Ve con el SDK para todo lo que de verdad pondrías en producción: modelos tipados, menos boilerplate y el tool runner cuando solo quieres que se ocupe del bucle. Y si te pesa más el coste que la capacidad bruta, cambia claude-opus-4-8 por claude-sonnet-5 — mismo código, ejecuciones más baratas.

Regla de oro: primero el prompt, tool use cuando el modelo necesite meter la mano en tu mundo, y un bucle de agente solo cuando necesite meterla más de una vez.

Ideas clave

  • El bucle es todo el truco, no hay magia — declaras herramientas, Claude pide una, tú la ejecutas, devuelves el resultado, repites hasta que responde. Todo lo demás es fontanería.
  • La ejecución es tuya, y ese es el punto — Claude solo pide ejecutar una herramienta; tu código decide si lo hace y cómo. La frontera de seguridad nunca sale de tu lado.
  • Parsea los parámetros, no los compares como texto — lee el JSON a valores tipados (JsonElement / un diccionario). Comparar contra la cadena serializada cruda es un bug esperando a un escape Unicode.
  • El nativo enseña el protocolo, el SDK lo pone en producciónHttpClient muestra cada byte y te da control total; el SDK de Anthropic te da bloques tipados y un tool runner que maneja el bucle.
  • Empieza simple — un prompt normal le gana a un agente en la mayoría de tareas. Añade tool use cuando el modelo necesite datos en vivo o tenga que hacer algo, y un bucle de agente solo cuando una llamada no baste.

Top comments (0)