Programmatic Tool Calling: implementación paso a paso

Hay una técnica que permite ejecutar docenas de herramientas en paralelo sin que el modelo vea ningún resultado intermedio. Nadie habla de ella porque está enterrada en la especificación. Cuando la descubras, vas a replantear cómo diseñas cualquier pipeline de agentes.

Colaboradores: Ivan Garcia Villar

Imagina que tienes un agente que verifica el cumplimiento presupuestario de 20 empleados. Con tool calling tradicional, cada consulta es un viaje completo a la API de Anthropic: Claude pide el resultado, tú lo devuelves, Claude lo procesa y pide el siguiente. Son 20+ llamadas al modelo, el contexto acumula cientos de kilobytes de datos intermedios que Claude apenas necesita. Programmatic Tool Calling (PTC) elimina ese ping-pong. En este post vas a ver el loop completo en TypeScript, qué ocurre exactamente en el contexto de Claude, y los errores que más se cometen al implementarlo.

El coste oculto de llamar herramientas una por una

El patrón tradicional es intuitivo: Claude decide qué herramienta llamar, tú la ejecutas, devuelves el resultado, y el ciclo continúa. El problema es que cada paso obliga al modelo a recibir el resultado, procesarlo, y decidir el siguiente paso. Para una herramienta, razonable. Para veinte, el patrón muestra sus costuras.

En el caso de 20 empleados, el flujo tradicional acumula así:

  • 20 llamadas al modelo, cada una con una inferencia completa
  • El contexto crece con cada respuesta: si cada resultado pesa 500 tokens, al mensaje 20 ya llevas 10.000 tokens de datos intermedios que Claude no necesita ver para generar el resumen final
  • La latencia total es la suma de ~22 inferencias, cada una con sus cientos de milisegundos

El modelo en realidad no necesita esos datos intermedios. Solo necesita el resumen final — quiénes superaron el límite y cuánto fue el exceso total. Todo lo demás es ruido que ocupa espacio en el contexto sin aportar valor a la respuesta.

PTC cambia la ecuación de raíz: en lugar de que Claude procese cada resultado, Claude escribe código que procesa todos los resultados dentro de un sandbox. Solo el output final llega al contexto.

Cómo funciona Programmatic Tool Calling

La idea central: en lugar de que Claude llame a cada herramienta directamente y espere el resultado en el contexto, Claude escribe código Python que orquesta todas las llamadas dentro de un sandbox de ejecución. Los resultados intermedios se procesan en el sandbox, sin tocar el contexto de Claude. Solo el output final —el resumen, la lista filtrada, el número agregado— llega al modelo.

El flujo tiene cinco pasos:

  1. Envías el mensaje con las herramientas configuradas como programáticas (campo allowed_callers).
  2. Claude escribe código Python que llama tus herramientas en un bucle, con condicionales, con lógica de filtrado.
  3. El sandbox ejecuta ese código. Cuando necesita un resultado de tu herramienta, pausa y te devuelve un tool_use block.
  4. Tú ejecutas la herramienta y devuelves el resultado. El sandbox continúa sin pasar por una nueva inferencia del modelo.
  5. Cuando el código termina, Claude recibe solo el output final y genera la respuesta.

El paso 4 es la clave económica: los 20 resultados intermedios de las consultas de empleados no pasan por el contexto de Claude. El modelo solo ve el resumen que el código Python genera al final. En benchmarks de investigación multi-step, esta reducción de contexto bajó el consumo de tokens de 43.588 a 27.297 — un 37% menos [1].

Para que funcione, necesitas dos cosas: el code_execution tool habilitado, y tus herramientas marcadas con allowed_callers.

Implementación paso a paso en TypeScript

Este es un script completo que puedes ejecutar con npx tsx ptc-empleados.ts. Solo necesitas ANTHROPIC_API_KEY en tu entorno y @anthropic-ai/sdk instalado.

Paso 1: definir las herramientas con allowed_callers

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();

// Herramienta real — esta versión simula la consulta a tu BD
async function verificarPresupuesto(empleado_id: string): Promise<string> {
  const gasto = 1500 + parseInt(empleado_id.replace("E", "")) * 173;
  return JSON.stringify({
    empleado_id,
    gasto,
    limite: 3000,
    excedido: gasto > 3000,
  });
}

const tools = [
  // El sandbox donde Claude escribe y ejecuta código Python
  { type: "code_execution_20260120", name: "code_execution" },
  {
    name: "verificar_presupuesto",
    // La descripción del formato de salida es crítica: Claude escribirá código que procesa este JSON
    description:
      "Verifica el presupuesto de un empleado. " +
      "Devuelve JSON: { empleado_id: string, gasto: number, limite: number, excedido: boolean }",
    input_schema: {
      type: "object",
      properties: {
        empleado_id: { type: "string", description: "ID del empleado, ej: E01" },
      },
      required: ["empleado_id"],
    },
    // Esta línea convierte la herramienta en callable desde el sandbox
    allowed_callers: ["code_execution_20260120"],
  },
] as any[];

El campo allowed_callers es lo que habilita PTC para esa herramienta. Los valores posibles son ["direct"] (solo Claude la llama directamente), ["code_execution_20260120"] (solo el sandbox), o ambos. Los docs de Anthropic recomiendan elegir uno: mezclar ambos confunde al modelo sobre cuándo y cómo usar la herramienta.

Paso 2: el loop completo con handler de herramientas

async function main() {
  const messages: Anthropic.MessageParam[] = [
    {
      role: "user",
      content:
        "Verifica el presupuesto de los empleados E01 a E20. " +
        "Identifica quiénes excedieron el límite y calcula el exceso total.",
    },
  ];

  let containerId: string | undefined;

  while (true) {
    const response = await (client.messages.create as any)({
      model: "claude-opus-4-6",
      max_tokens: 4096,
      // Reutilizar el container mantiene el estado del sandbox entre iteraciones
      ...(containerId && { container: containerId }),
      messages,
      tools,
    });

    // Capturar el container ID para pasarlo en el siguiente request
    if (response.container?.id) containerId = response.container.id;

    messages.push({ role: "assistant", content: response.content });

    if (response.stop_reason === "end_turn") {
      const texto = response.content
        .filter((b: any) => b.type === "text")
        .map((b: any) => b.text)
        .join("\n");
      console.log(texto);
      break;
    }

    const toolUses = response.content.filter((b: any) => b.type === "tool_use");

    if (toolUses.length === 0) break;

    const toolResults = await Promise.all(
      toolUses.map(async (toolUse: any) => {
        if (toolUse.name === "verificar_presupuesto") {
          try {
            const resultado = await verificarPresupuesto(toolUse.input.empleado_id);
            return {
              type: "tool_result" as const,
              tool_use_id: toolUse.id,
              content: resultado,
            };
          } catch (err: any) {
            return {
              type: "tool_result" as const,
              tool_use_id: toolUse.id,
              is_error: true,
              content: err.message,
            };
          }
        } else {
          return {
            type: "tool_result" as const,
            tool_use_id: toolUse.id,
            is_error: true,
            content: `Herramienta desconocida: ${toolUse.name}`,
          };
        }
      })
    );

    // CRÍTICO: solo tool_results aquí. Texto adicional provoca error de API.
    messages.push({ role: "user", content: toolResults });
  }
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

El container es el sandbox donde corre el código de Claude. Pasarlo en cada request mantiene el estado entre iteraciones del loop — sin esto, cada llamada arranca un sandbox fresco y el código de Claude pierde contexto de ejecución. El dispatch por toolUse.name garantiza que cada llamada invoque la función correcta; el try/catch dentro del handler comunica los errores al sandbox en lugar de dejar expirar el timeout.

Nota sobre tipos: PTC es relativamente reciente y el SDK de TypeScript aún no expone tipos completos para allowed_callers, container, o el campo caller. Los as any son temporales — revisa el changelog del SDK para ver cuándo se añaden los tipos oficiales.

El campo caller: programático vs directo

Cada tool_use block en la respuesta incluye un campo caller que indica su origen:

  • { "type": "direct" } — Claude llamó a la herramienta directamente
  • { "type": "code_execution_20260120", "tool_id": "srvtoolu_..." } — la llamada viene del sandbox

En el ejemplo de arriba no hace falta distinguirlos porque todas las herramientas son programáticas. Si mezclaras herramientas directas y programáticas, necesitarías leer caller.type para saber cómo responder en cada caso (el constraint de “solo tool_results” solo aplica cuando hay llamadas programáticas pendientes).

Ciclo de vida del container

Un container dura aproximadamente 4,5 minutos de inactividad [2]. La respuesta incluye container.expires_at con el timestamp exacto. Si tu herramienta tarda en responder y el container expira, el sandbox recibe un TimeoutError que Claude ve en stderr — normalmente reintenta, pero no siempre de forma elegante. Para operaciones lentas, implementa timeouts en tu lado y comunica el error claramente en el tool_result.

Antes y después: impacto en tokens y latencia

La diferencia más importante no está en el número de llamadas a la API. Con 20 empleados, sigues haciendo 20 requests para devolver los 20 resultados. La diferencia está en lo que esas llamadas cuestan.

MétricaTool calling tradicionalProgrammatic Tool Calling
Inferencias del modelo~22 (una por tool call + inicio + fin)2 (inicio + fin)
Datos en contexto de ClaudeTodos los resultados intermediosSolo el output final
Crecimiento del contextoLineal: cada resultado acumulaConstante: solo el resumen
Tokens (benchmark real)*43.588 tokens27.297 tokens

*Datos de Anthropic en tareas de investigación multi-step [1]. La reducción real depende de cuánto pesan tus resultados intermedios.

El ahorro es mayor cuanto más grandes son los datos intermedios que no necesitas pasar al modelo. Si cada resultado de herramienta son 2KB y solo necesitas un número al final, PTC elimina casi todo ese peso del contexto.

Disponibilidad actual (marzo 2026)

PTC está disponible a través de la API de Anthropic directamente y de Azure AI Foundry [2]. Los modelos compatibles son Claude Opus 4.6, Claude Sonnet 4.6, Claude Sonnet 4.5 y Claude Opus 4.5 — todos usando el tipo code_execution_20260120. Herramientas MCP y herramientas con strict: true en el schema no son compatibles con PTC por ahora.

Cuándo usar PTC (y cuándo no)

La pregunta práctica: ¿cuándo compensa el overhead del sandbox?

Casos donde PTC aporta claro valor:

  • Necesitas 3+ llamadas a herramientas en secuencia y los datos intermedios son grandes
  • Tienes lógica de iteración o filtrado que puedes expresar en Python (bucles, agregados, sort)
  • Los resultados de las herramientas son grandes pero solo necesitas un subconjunto o resumen
  • El orden de procesamiento no requiere que Claude razone sobre los datos en cada paso

Casos donde el tool calling tradicional es mejor:

  • Una sola herramienta con respuesta pequeña — el overhead del sandbox no se amortiza
  • El flujo requiere que Claude evalúe un resultado y decida dinámicamente el siguiente paso según su contenido
  • Necesitas confirmación humana entre pasos intermedios
  • Tus herramientas tienen strict: true en el schema (incompatible con PTC)

La línea divisoria es esta: si puedes escribir el código Python que orquesta las llamadas sin necesidad de que Claude razone entre ellas, PTC es el patrón correcto. Si la lógica de “qué herramienta llamar a continuación” depende de evaluar el resultado anterior con el modelo, necesitas tool calling directo o un patrón híbrido.

Errores comunes al implementar PTC

1. Mezclar texto con tool_result en la respuesta

Este es el error más frecuente y el que más desconcierta porque el mensaje de error no siempre es obvio. Cuando hay tool calls programáticas pendientes, la API exige que tu mensaje de respuesta contenga únicamente bloques tool_result. Ni texto antes ni texto después.

// ❌ Provoca error de API
messages.push({
  role: "user",
  content: [
    { type: "tool_result", tool_use_id: "toolu_01", content: "..." },
    { type: "text", text: "¿Debería continuar?" }, // inválido aquí
  ],
});

// ✓ Solo tool_results cuando hay llamadas programáticas pendientes
messages.push({
  role: "user",
  content: [
    { type: "tool_result", tool_use_id: "toolu_01", content: "..." },
  ],
});

Esta restricción solo aplica cuando respondes a llamadas programáticas. Para tool calling directo, puedes incluir texto después de los tool_result sin problema.

2. Descripción vaga del formato de salida

Claude escribe código Python que deserializa y procesa los resultados de tus herramientas. Si tu descripción dice solo “devuelve datos del empleado”, Claude no sabe si esperar JSON, una cadena plana, o un número. Cuanto más precisa sea la descripción del formato de salida —tipos, campos, estructura JSON— mejor puede Claude escribir el código de procesamiento.

Mala descripción: "Devuelve información del empleado"

Buena descripción: "Devuelve JSON con campos: empleado_id (string), gasto (number), limite (number), excedido (boolean)"

3. Habilitar allowed_callers: ["direct", "code_execution_20260120"] sin razón

Los docs de Anthropic son claros: elige uno u otro para cada herramienta. Habilitar ambos confunde al modelo sobre cómo debe usar la herramienta — no sabe si llamarla directamente o a través del sandbox. Si tu herramienta es para PTC, usa solo ["code_execution_20260120"] y Claude sabrá qué hacer.

4. No validar los datos que devuelven tus herramientas

El sandbox ejecuta el código que Claude escribe, y ese código procesa lo que tus herramientas devuelven. Si los resultados de una herramienta vienen de fuentes externas o contienen input del usuario, hay riesgo de inyección: datos que contienen fragmentos de código podrían ser interpretados por el entorno de ejecución [2]. Valida y sanitiza los resultados antes de devolverlos.

5. Olvidar que los containers expiran

Un container dura ~4,5 minutos de inactividad. Si implementas un flujo donde hay pasos humanos entre las llamadas al loop, el container puede expirar antes de que devuelvas el resultado. El sandbox recibe un TimeoutError. Monitorea container.expires_at en la respuesta y diseña tu flujo para responder dentro del tiempo disponible.

Checklist de implementación

  • code_execution_20260120 incluido en el array de tools
  • Todas las herramientas que usan PTC tienen allowed_callers: ["code_execution_20260120"]
  • Las descripciones de herramientas incluyen el formato exacto de salida (tipos, estructura JSON)
  • El loop responde con SOLO bloques tool_result cuando hay llamadas programáticas pendientes
  • El container.id se captura y se pasa en cada request para mantener el estado del sandbox
  • Los resultados de herramientas que vienen de fuentes externas están validados
  • Hay manejo de expires_at o timeouts para herramientas con latencia variable

Fuentes

  1. Advanced Tool Use — Anthropic Engineering — datos de reducción de tokens (43.588 → 27.297) en benchmarks de investigación multi-step.
  2. Programmatic Tool Calling — Anthropic Docs — referencia completa: allowed_callers, campo caller, ciclo de vida del container, restricciones y compatibilidad de plataformas.

Preguntas Frecuentes

¿PTC funciona con cualquier modelo de Claude?

No con todos. Al momento de publicar este post, Programmatic Tool Calling está disponible en Claude Opus 4.6, Claude Sonnet 4.6, Claude Sonnet 4.5 y Claude Opus 4.5 — todos usando el tipo de herramienta code_execution_20260120. Los modelos anteriores no soportan esta feature.

¿Los tool results de llamadas programáticas cuentan como tokens de entrada?

No en términos de tokens del modelo. El protocolo de la API exige enviar los tool_result de vuelta en el array messages — eso es parte del payload HTTP —, pero Anthropic no los contabiliza como input tokens del modelo. Solo cuenta el output final que el código Python genera. Cuanto más grandes sean tus resultados intermedios y cuanto menos necesites que Claude los vea directamente, mayor el ahorro.

¿Puedo mezclar herramientas programáticas y directas en el mismo request?

Sí, pero complica el loop. Necesitarías leer el campo caller de cada tool_use para saber si responder con el formato de PTC (solo tool_result) o de tool calling tradicional (puedes añadir texto). Por simplicidad, empieza con todo programático o todo directo, y mezcla solo cuando tengas una razón concreta para ello.

¿Qué pasa si necesito que Claude razone sobre un resultado antes de hacer la siguiente llamada?

En ese caso PTC no es la opción correcta. PTC funciona cuando el código Python puede tomar todas las decisiones de orquestación sin necesidad de inferencia del modelo entre pasos. Si el flujo requiere que Claude evalúe un resultado y decida dinámicamente qué herramienta llamar a continuación según su contenido semántico, necesitas tool calling tradicional. PTC y tool calling directo no se excluyen mutuamente — puedes usarlos en diferentes partes del mismo sistema según qué patrón encaje mejor.

¿Hay features que complementan PTC en el ecosistema de Anthropic?

Dos en particular. Tool Search Tool permite cargar herramientas bajo demanda en lugar de definir todas en el request, lo que reduce significativamente los tokens de entrada cuando tienes muchas herramientas disponibles. Tool Use Examples enseña a Claude con ejemplos de inputs y outputs reales, mejorando la precisión cuando el schema de la herramienta es complejo. Las dos aparecen documentadas en el artículo de Anthropic Engineering sobre advanced tool use [1].