··
Bucle while, herramienta, parada y presupuesto. Cómo escribir un agente mínimo en TypeScript con tool calling sin framework, para entender qué hay debajo antes de elegir abstracciones.

Antes de tocar LangGraph, Claude Agent SDK o cualquier otro framework, quiero montar un agente sin framework. No porque vaya a usar esto en producción, sino porque entender esto me da el criterio para elegir framework después. Toda abstracción cuesta algo, y para saber qué te ahorra hay que conocer lo que oculta.
El ejercicio que describo es lo más reducido que puedo hacer y todavía llamarlo o considerarlo agente, no wrapper. Lo escribo en TypeScript con el SDK oficial de Anthropic, pero la mecánica es portable a cualquier modelo con tool calling.
Un agente es, esencialmente, un while con tres salidas. Sigue iterando mientras el modelo decida que necesita más herramientas para responder. Se detiene cuando el modelo da una respuesta final, cuando se agota el presupuesto de turns o cuando una herramienta lanza un error que el bucle considera fatal.
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const MAX_TURNS = 8;
async function runAgent(userPrompt: string) {
const messages: Anthropic.MessageParam[] = [
{ role: "user", content: userPrompt },
];
for (let turn = 0; turn < MAX_TURNS; turn++) {
const response = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 2048,
tools,
messages,
});
messages.push({ role: "assistant", content: response.content });
if (response.stop_reason === "end_turn") {
return extractText(response);
}
if (response.stop_reason === "tool_use") {
const toolResults = await runTools(response.content);
messages.push({ role: "user", content: toolResults });
continue;
}
throw new Error(`Unexpected stop_reason: ${response.stop_reason}`);
}
throw new Error("Agent exceeded MAX_TURNS without converging");
}
Esto es todo. Diez minutos de código y ya tienes el esqueleto. El detalle interesante no está en el while, sino en cómo defines herramientas y cómo manejas su ejecución.
Una herramienta es una función con un esquema JSON que el modelo entiende. El modelo no ejecuta la función, solo decide cuándo llamarla y con qué argumentos. Tu código la ejecuta y le devuelve el resultado.
const tools: Anthropic.Tool[] = [
{
name: "buscar_post_blog",
description: "Busca un post del blog por término en el título o contenido. Devuelve los 5 más relevantes con slug, título y excerpt.",
input_schema: {
type: "object",
properties: {
query: { type: "string", description: "Término de búsqueda en español" },
},
required: ["query"],
},
},
{
name: "leer_post",
description: "Devuelve el contenido completo de un post dado su slug.",
input_schema: {
type: "object",
properties: { slug: { type: "string" } },
required: ["slug"],
},
},
];
La descripción de la herramienta es crítica. Es lo que el modelo lee para decidir si la llama, cuándo y con qué argumentos. Una descripción ambigua produce un agente confundido. Una descripción precisa con un ejemplo de cuándo conviene usarla cambia radicalmente el comportamiento.
La ejecución vive en tu código y debe ser robusta. Catchea excepciones y devuelve el error como contenido de la respuesta de tool, no propagues una excepción al bucle. El modelo es sorprendentemente bueno reaccionando a un mensaje del tipo "esta herramienta falló porque el slug no existe, prueba a buscarlo primero".
async function runTools(content: Anthropic.ContentBlock[]) {
const results: Anthropic.ToolResultBlockParam[] = [];
for (const block of content) {
if (block.type !== "tool_use") continue;
try {
const output = await dispatchTool(block.name, block.input);
results.push({
type: "tool_result",
tool_use_id: block.id,
content: JSON.stringify(output),
});
} catch (err) {
results.push({
type: "tool_result",
tool_use_id: block.id,
is_error: true,
content: err instanceof Error ? err.message : "tool failure",
});
}
}
return results;
}
En el bucle de arriba la memoria es trivialmente el array messages. Cada turn añade el mensaje del modelo y, si hubo herramientas, los resultados. El modelo recibe la conversación entera en cada llamada y reconstruye el contexto.
Esto funciona hasta que no funciona. Conforme el agente va trabajando, los messages crecen y con ellos el coste por turn y la latencia. A continuación, enumero tres patrones que aparecen rápido en cuanto el agente se pone serio.
El primero, recortar el contexto. A partir de cierto número de turns conviene resumir las primeras herramientas con un mensaje sintético y descartar los crudos. El modelo aguanta bien la pérdida de detalle si el resumen está bien hecho.
El segundo, separar memoria volátil de persistente. La conversación dura una sesión. Los hechos extraídos durante esa sesión, si son útiles a futuro, se guardan en una capa aparte que el modelo consulta como herramienta, no como contexto bruto.
El tercero, prompt caching. La parte fija del system prompt y de los esquemas de herramientas se cachea entre turns para abaratar las llamadas siguientes en una misma sesión. Anthropic cobra menos por contenido cacheado y la primera llamada que crea el caché es algo más cara, pero el ahorro a partir del turn dos compensa con creces.
Esta es la parte que distingue un agente de juguete de uno que aguanta producción. Hay decisiones que no se delegan al modelo bajo ningún concepto.
El número máximo de turns. Si el agente no ha convergido en, pongamos, ocho iteraciones, abortas. No lo dejas seguir hasta que se le ocurra parar.
El presupuesto en tokens por sesión. Si el acumulado de input + output supera el límite, abortas con un mensaje claro al usuario. Sin esto, un caso patológico te puede gastar 5 USD en una petición.
La lista blanca de herramientas. El modelo solo ve las herramientas que le pasas. Si tu agente tiene una herramienta de "ejecutar comando" o "borrar fichero", la activas solo cuando te interesa y nunca para usuarios externos sin autorización explícita.
La validación de salida. Antes de devolver al usuario lo que el modelo dice, valida que la respuesta cumple un mínimo. Esquema JSON, longitud, ausencia de PII, lo que aplique.
// Antes de iniciar el bucle, presupuesto duro:
const budget = { tokensUsed: 0, max: 50_000 };
// Tras cada response:
budget.tokensUsed += response.usage.input_tokens + response.usage.output_tokens;
if (budget.tokensUsed > budget.max) {
throw new BudgetExceededError(budget);
}
Una sesión real con este código, pidiéndole "encuéntrame los posts donde he hablado de pricing y resúmemelos", se ejecuta así.
Turn 1, el modelo decide llamar a buscar_post_blog con query: "pricing". Turn 2, recibe los cinco slugs, decide leer dos de ellos y llama a leer_post dos veces en paralelo. Turn 3, el modelo tiene los contenidos y devuelve un resumen final con stop_reason: "end_turn". Tres turns, dos herramientas distintas, en torno a 0,03 USD con Claude Sonnet 4.6 y prompt caching activo. Un wrapper no sabe hacer esto sin que tú escribas todo el orquestador a mano.
Cuando ya has montado este bucle a pelo, miras un framework con otros ojos. Las preguntas se vuelven concretas.
¿Cómo me deja definir herramientas? ¿Tipado fuerte, validación zod, o me obliga a un esquema crudo?
¿Qué hace con la memoria por defecto? ¿Acumula todo, recorta, resume? ¿Puedo cambiarlo?
¿Cómo emite trazas para mi observabilidad? ¿Hay integración nativa con Langfuse, OpenTelemetry, o me toca instrumentar a mano?
¿Qué grado de control me da sobre el bucle? ¿Puedo intercalar lógica entre turns, parar bajo condiciones que decida yo, encadenar agentes distintos?
¿Cuánto código tengo que escribir para sumar un salvavidas que no sea presupuesto y MAX_TURNS?
El próximo post compara LangGraph, Claude Agent SDK y la opción artesanal con código equivalente para los tres, y pongo la tabla de tradeoffs honesta. Spoiler suave, ninguno gana en todo y la elección depende de cuánto control quieras retener y cuánto ecosistema quieras heredar.
Otra entrega de la serie Del wrapper al agente. Vienes de Wrapper de IA vs agente de IA. Para volver al inicio, Wrapper de IA vs agente de IA.

Jose, autor del blog
QA Engineer. Escribo en voz alta sobre automatización, IA y arquitectura de software. Si algo te ha servido, escríbeme y cuéntamelo.
¿Qué te ha parecido? ¿Qué añadirías? Cada comentario afina la siguiente entrada.
Si esto te ha gustado

Cómo está montado por dentro el chat con agente de IA embebido en mi portfolio. Streaming SSE con eventos tipados, tool calling contra la API pública del blog, prompt como código y sesiones con cookie HttpOnly.

Cada post del blog puede leerse en voz alta con un reproductor integrado. El audio se genera con IA la primera vez que alguien lo pide y se cachea para todos los demás.

Los tests E2E se rompen con cada cambio de interfaz. En JMO Labs construimos un pipeline de 5 fases con IA que planifica, ejecuta, repara selectores, diagnostica fallos y verifica resultados de forma autónoma. La caché de selectores hace que cada ejecución sea más rápida que la anterior.