* notes / anthropic

Anthropic SDK

Recorrido práctico por el SDK de Anthropic en TypeScript: Messages API, streaming, tool use y prompt caching como ciudadano de primera clase.

February 10, 20267 min read#anthropic#claude#typescript#sdk

Contexto: la Messages API y por qué importa el SDK oficial

La Messages API es la superficie principal para interactuar con los modelos de Claude. Acepta una lista ordenada de turnos (user / assistant), un opcional system con instrucciones, definiciones de herramientas, y devuelve un mensaje del asistente que puede contener texto, llamadas a herramientas, o ambos. El SDK oficial (@anthropic-ai/sdk para Node/TypeScript) añade tipos, manejo de reintentos, streaming asincrónico y, sobre todo, un objeto MessageStream que encapsula el procesado evento a evento.

Los identificadores de modelo que utilizo en producción a fecha de hoy son:

  • Claude Opus 4.7claude-opus-4-7: razonamiento profundo, ventana de un millón de tokens.
  • Claude Sonnet 4.6claude-sonnet-4-6: el caballo de batalla, equilibrio entre coste y capacidad.
  • Claude Haiku 4.5claude-haiku-4-5-20251001: para latencia ínfima y tareas masivas.

Cabe destacar que la elección del modelo no es solo cuestión de capacidad: el precio por token de entrada/salida y la latencia media son criterios igualmente decisivos. Haiku encaja para clasificadores y enrutadores; Sonnet, para la conversación principal; Opus, cuando el problema lo justifica.

Diagrama del ciclo de petición

Clientemessages.stream()Prompt cachecache_controlModeloclaude-sonnet-4-6Stream SSEdeltas de texto

Bootstrap: un chat mínimo con streaming

Empiezo por la pieza más simple: enviar un turno y emitir el texto a medida que llega.

import Anthropic from "@anthropic-ai/sdk";
 
const client = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY!,
});
 
const stream = client.messages.stream({
  model: "claude-sonnet-4-6",
  max_tokens: 1024,
  system: "Eres un asistente conciso. Responde en español neutro.",
  messages: [{ role: "user", content: "Resume el patrón Saga en tres frases." }],
});
 
stream.on("text", (delta) => process.stdout.write(delta));
 
const final = await stream.finalMessage();
console.error("\n---\nusage:", final.usage);

El objeto stream emite varios eventos: text (deltas listos para imprimir), message (mensaje completo), error y end. Para una CLI basta con suscribirse a text; para una UI conviene además observar messageStart para mostrar un estado de «pensando» y contentBlockStop para señalar finales de párrafo.

Caché de prompt: la decisión más rentable

Si hay una sola optimización que aprendí a aplicar siempre, es cache_control: { type: "ephemeral" } sobre los bloques estables del prompt: el system, las definiciones de herramientas y, cuando aplica, documentos extensos al comienzo de la conversación. Anthropic almacena temporalmente esos segmentos y, en peticiones siguientes, los reusa internamente con un coste de lectura reducido y latencia menor.

El truco fundamental es que el orden importa: solo se cachean los prefijos. Si el bloque inmutable va al final, la caché no servirá.

const SYSTEM_PROMPT = `Actúas como tutor de matemáticas universitarias.
Reglas: paso a paso, ejemplos resueltos, evita atajos.`;
 
const REFERENCE = await fs.readFile("./manual.md", "utf8");
 
const resp = await client.messages.create({
  model: "claude-sonnet-4-6",
  max_tokens: 2048,
  system: [
    {
      type: "text",
      text: SYSTEM_PROMPT,
      cache_control: { type: "ephemeral" },
    },
    {
      type: "text",
      text: REFERENCE,
      cache_control: { type: "ephemeral" },
    },
  ],
  messages: [
    { role: "user", content: "Explícame la diagonalización de matrices." },
  ],
});
 
console.log(resp.usage);
// usage: {
//   input_tokens: 124,
//   cache_creation_input_tokens: 8830,   // primera vez
//   cache_read_input_tokens: 0,
//   output_tokens: 612
// }

A la segunda petición, los mismos bloques arrojarán cache_read_input_tokens: 8830 y cache_creation_input_tokens: 0. La diferencia de coste es significativa, sobre todo en agentes que reutilizan un contexto pesado a lo largo de una sesión.

No obstante, dos sutilezas merecen recordatorio:

  1. El TTL es del orden de minutos. Para sesiones largas conviene marcar también los últimos turnos de usuario estables (por ejemplo, un fichero adjunto), aunque solo permanezcan en caché unos minutos.
  2. Cualquier cambio de byte invalida el bloque. Una coma fuera de sitio en SYSTEM_PROMPT reescribe la caché entera. Vale la pena versionar el system como artefacto inmutable.

Conversación multi-turno

El estado de la conversación lo mantiene el cliente: cada llamada nueva debe contener la historia completa. Mi acumulador es un array de mensajes que crece tras cada respuesta.

type Turn = { role: "user" | "assistant"; content: string };
const history: Turn[] = [];
 
async function ask(user: string): Promise<string> {
  history.push({ role: "user", content: user });
  const resp = await client.messages.create({
    model: "claude-sonnet-4-6",
    max_tokens: 1024,
    system: [{ type: "text", text: SYSTEM_PROMPT, cache_control: { type: "ephemeral" } }],
    messages: history,
  });
  const text = resp.content
    .filter((b) => b.type === "text")
    .map((b) => (b as { text: string }).text)
    .join("");
  history.push({ role: "assistant", content: text });
  return text;
}

Asimismo, conviene poner un tope al historial (truncado por turnos viejos o por resumen automático con una llamada a Haiku) si la sesión va a ser larga.

Tool use: el bucle agente

Las herramientas se declaran como un array de descriptores con su input_schema (un JSON Schema). Claude decide si llamarlas; nosotros las ejecutamos y le devolvemos el resultado en un tool_result. El patrón es un bucle hasta stop_reason !== "tool_use".

const tools = [
  {
    name: "get_weather",
    description: "Devuelve la temperatura actual de una ciudad en grados Celsius.",
    input_schema: {
      type: "object",
      properties: { city: { type: "string" } },
      required: ["city"],
    },
    cache_control: { type: "ephemeral" }, // las definiciones también son cacheables
  },
];
 
async function runAgent(userPrompt: string) {
  const messages: Anthropic.MessageParam[] = [
    { role: "user", content: userPrompt },
  ];
 
  while (true) {
    const resp = await client.messages.create({
      model: "claude-sonnet-4-6",
      max_tokens: 1024,
      tools,
      messages,
    });
 
    messages.push({ role: "assistant", content: resp.content });
 
    if (resp.stop_reason !== "tool_use") {
      return resp.content
        .filter((b) => b.type === "text")
        .map((b) => (b as { text: string }).text)
        .join("");
    }
 
    const toolUses = resp.content.filter((b) => b.type === "tool_use") as Array<{
      type: "tool_use";
      id: string;
      name: string;
      input: Record<string, unknown>;
    }>;
 
    const toolResults = await Promise.all(
      toolUses.map(async (t) => ({
        type: "tool_result" as const,
        tool_use_id: t.id,
        content: JSON.stringify(await dispatch(t.name, t.input)),
      })),
    );
 
    messages.push({ role: "user", content: toolResults });
  }
}
 
async function dispatch(name: string, input: Record<string, unknown>) {
  if (name === "get_weather") {
    return { city: input.city, tempC: 21.4 };
  }
  throw new Error(`Herramienta desconocida: ${name}`);
}

Por consiguiente, lo que en apariencia es una sola respuesta del modelo se convierte en una pequeña máquina de estados: pensar, llamar, observar, pensar de nuevo. Para hacerlo robusto añadí un límite de iteraciones (8 en mi caso) y logging del usage por vuelta, lo cual me permitió detectar herramientas mal descritas que provocaban iteraciones repetidas.

Trampas que pagué

  1. Olvidar max_tokens: no tiene valor por defecto sensato; sin él, la API rechaza la petición.
  2. Reordenar system y tools entre llamadas rompe la caché. Mantengo un builder puro que produce el mismo árbol byte a byte.
  3. No cerrar el stream: tras un error, conviene stream.controller.abort() para liberar la conexión HTTP/2.
  4. Confundir tool_use_id con id del mensaje: cada bloque de herramienta tiene su propio identificador y debe casar con el tool_result correspondiente.