* 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.
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.7 →
claude-opus-4-7: razonamiento profundo, ventana de un millón de tokens. - Claude Sonnet 4.6 →
claude-sonnet-4-6: el caballo de batalla, equilibrio entre coste y capacidad. - Claude Haiku 4.5 →
claude-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
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:
- 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.
- Cualquier cambio de byte invalida el bloque. Una coma fuera de sitio en
SYSTEM_PROMPTreescribe 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é
- Olvidar
max_tokens: no tiene valor por defecto sensato; sin él, la API rechaza la petición. - Reordenar
systemytoolsentre llamadas rompe la caché. Mantengo un builder puro que produce el mismo árbol byte a byte. - No cerrar el stream: tras un
error, convienestream.controller.abort()para liberar la conexión HTTP/2. - Confundir
tool_use_idconiddel mensaje: cada bloque de herramienta tiene su propio identificador y debe casar con eltool_resultcorrespondiente.