La maggior parte delle "integrazioni Claude" collega un input di chatbot all'API e chiude lì. L'AI in produzione ha un'altra faccia. La funzione qui sotto è quella che rilasciamo: una chiamata tipizzata a messages.create, system prompt e documento di riferimento in cache, un tool lookupCustomer che il modello può chiamare, la risposta che torna al client via Server-Sent Events.
1. Il client tipizzato
L'Anthropic SDK è incapsulato in un client sottile che aggiunge retry, mappatura degli errori e un'union tipizzata per il modello. La selezione del modello avviene al punto di chiamata; il client non sceglie.
// 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 definizione del tool
Un tool è una funzione tipizzata con uno schema JSON. Zod produce lo schema; l'handler gira nel normale flusso transazionale dell'applicazione. Il modello non tocca mai il database direttamente.
// 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 chiamata in streaming con prompt caching
System prompt e documento di riferimento portano entrambi cache_control. Dopo la prima richiesta, le chiamate successive pagano la tariffa cache-read (circa il dieci percento del prezzo standard di input) su quei blocchi. I messaggi della conversazione invece non vengono messi in cache perché cambiano a ogni 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
// Quando il modello decide di chiamare un tool, lo stream emette un
// content_block_stop con il payload tool_use. Eseguiamo il tool e
// continuiamo la conversazione con il risultato.
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
Il generator dello streaming va in pipe in un trasporto Server-Sent Events così il browser riceve i token parziali man mano che arrivano. La route handler scrive anche una riga di log per chiamata, così cost accounting e audit non sono opzionali.
// 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. Cosa ti compra questo
Il prompt caching trasforma una chiamata da cinque centesimi in una da mezzo centesimo. Il tool use trasforma il modello da un'interfaccia di chat a un operatore che legge il tuo database, chiama le tue API, fa partire i tuoi workflow. L'audit log dà al team finanza un numero su cui pianificare e alla security un record da firmare. Tutto sta in quattro file perché l'SDK è progettato bene e l'applicazione smette di combatterlo.
Questo è cosa significa "AI è il team" nel codice, non nella slide di marketing.
Approfondimenti