La mayoría de las "integraciones Claude" conectan un input de chatbot a la API y se quedan ahí. La AI en producción tiene otra cara. La función de abajo es la que enviamos: una llamada tipada a messages.create, system prompt y documento de referencia en cache, un tool lookupCustomer que el modelo puede llamar, la respuesta que vuelve al client por Server-Sent Events.
1. El client tipado
El Anthropic SDK queda envuelto en un client fino que añade retries, mapeo de errores y una union tipada de modelo. La selección de modelo ocurre en el call site; el client no elige.
// src/lib/claude/client.ts
import Anthropic from '@anthropic-ai/sdk'
export const claude = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY!,
maxRetries: 3,
})
export type ClaudeModel =
| 'claude-opus-4-7'
| 'claude-sonnet-4-6'
| 'claude-haiku-4-5'
export const MODEL_BY_TASK: Record<'reasoning' | 'standard' | 'cheap', ClaudeModel> = {
reasoning: 'claude-opus-4-7',
standard: 'claude-sonnet-4-6',
cheap: 'claude-haiku-4-5',
}
2. La definición del tool
Un tool es una función tipada con un JSON schema. Zod produce el schema; el handler corre en el flujo transaccional normal de la aplicación. El modelo nunca toca la base de datos directamente.
// src/lib/claude/tools/lookup-customer.ts
import { z } from 'zod'
import { zodToJsonSchema } from 'zod-to-json-schema'
import { adminClient } from '@/lib/supabase/admin'
export const lookupCustomerInput = z.object({
email: z.string().email(),
})
export const lookupCustomerTool = {
name: 'lookupCustomer',
description:
'Find a customer by email. Returns the canonical record or null.',
input_schema: zodToJsonSchema(lookupCustomerInput, {
target: 'jsonSchema7',
}) as Record<string, unknown>,
} as const
export async function runLookupCustomer(
input: z.infer<typeof lookupCustomerInput>,
): Promise<unknown> {
const { data, error } = await adminClient
.from('customers')
.select('id, name, plan, status, created_at')
.eq('email', input.email)
.maybeSingle()
if (error) throw error
return data ?? null
}
3. La llamada en streaming con prompt caching
El system prompt y el documento de referencia llevan cache_control. Después de la primera petición, las siguientes pagan la tarifa cache-read (alrededor del diez por ciento del precio estándar de input) sobre esos bloques. Los mensajes de la conversación se quedan sin cache porque cambian en cada turno.
// src/lib/claude/run.ts
import { claude, MODEL_BY_TASK } from './client'
import { lookupCustomerTool, runLookupCustomer, lookupCustomerInput } from './tools/lookup-customer'
import { systemPrompt } from './prompts/system'
import { referenceDoc } from './prompts/reference'
interface RunInput {
conversation: { role: 'user' | 'assistant'; content: string }[]
tenantId: string
}
export async function* runClaude(input: RunInput) {
const stream = await claude.messages.create({
model: MODEL_BY_TASK.standard,
max_tokens: 4096,
system: [
{
type: 'text',
text: systemPrompt,
cache_control: { type: 'ephemeral' },
},
{
type: 'text',
text: referenceDoc,
cache_control: { type: 'ephemeral' },
},
],
messages: input.conversation,
tools: [lookupCustomerTool],
stream: true,
})
for await (const chunk of stream) {
yield chunk
// Cuando el modelo decide llamar a un tool, el stream emite un
// content_block_stop con el payload tool_use. Ejecutamos el tool y
// continuamos la conversación con el resultado.
if (
chunk.type === 'content_block_stop' &&
'content_block' in chunk &&
chunk.content_block?.type === 'tool_use' &&
chunk.content_block.name === 'lookupCustomer'
) {
const parsed = lookupCustomerInput.parse(chunk.content_block.input)
const result = await runLookupCustomer(parsed)
yield {
type: 'tool_result' as const,
tool_use_id: chunk.content_block.id,
content: JSON.stringify(result),
}
}
}
}
4. La route Server-Sent Events
El generator del streaming pasa por un transporte Server-Sent Events para que el navegador reciba los tokens parciales según llegan. El route handler también escribe una fila de log por llamada, así que cost accounting y audit no son opcionales.
// app/api/chat/route.ts
import { runClaude } from '@/lib/claude/run'
import { logClaudeRequest } from '@/lib/claude/logging'
import { getServerSession } from '@/lib/auth/server'
export const runtime = 'nodejs'
export async function POST(request: Request) {
const session = await getServerSession()
if (!session) return new Response('unauthorised', { status: 401 })
const { conversation } = await request.json()
const start = Date.now()
const stream = new ReadableStream({
async start(controller) {
let inputTokens = 0
let outputTokens = 0
let cacheHits = 0
try {
for await (const chunk of runClaude({
conversation,
tenantId: session.tenantId,
})) {
if (chunk.type === 'message_start') {
inputTokens = chunk.message.usage.input_tokens
cacheHits = chunk.message.usage.cache_read_input_tokens ?? 0
}
if (chunk.type === 'message_delta') {
outputTokens = chunk.usage.output_tokens
}
controller.enqueue(
new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`),
)
}
} finally {
controller.close()
await logClaudeRequest({
tenantId: session.tenantId,
userId: session.userId,
model: 'claude-sonnet-4-6',
inputTokens,
outputTokens,
cacheHits,
durationMs: Date.now() - start,
})
}
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
}
5. Qué te compra esto
El prompt caching convierte una llamada de cinco céntimos en una de medio céntimo. El tool use convierte al modelo de una superficie de chat en un operador que lee tu base de datos, llama a tus APIs, dispara tus workflows. El audit log le da al equipo de finanzas un número con el que planificar y a security un registro que firmar. Todo entra en cuatro archivos porque el SDK está bien diseñado y la aplicación deja de pelearse con él.
Esto es lo que significa "AI es el equipo" en código, no en la slide de marketing.
Lecturas relacionadas