
Agentic AI nel front-end: componenti AI, generative UI e tool con Vercel AI SDK
Oltre il widget di chat: come collegare un LLM a componenti React, Vue o Svelte con Vercel AI SDK, far eseguire azioni all'AI, generare UI in tempo reale e tenere l'utente al comando.
Per un paio d’anni “aggiungere l’AI” a un’applicazione web ha voluto dire una cosa sola: una bolla in basso a destra, un campo di testo e risposte in streaming. Utile, ma scollegata dal resto dell’app: il modello parla del tuo prodotto, non lo usa.
Il passo successivo, quello su cui si stanno muovendo i SaaS più interessanti, è diverso. L’LLM diventa un agente che chiama funzioni dell’applicazione (filtrare una tabella, creare un task, aprire un pannello) e la risposta non è più solo testo ma componenti dell’interfaccia — un grafico, una card di anteprima, un form precompilato — generati e aggiornati in tempo reale.
In un articolo precedente ho parlato degli agenti esterni che navigano il tuo sito. Qui guardiamo l’altro lato: come portare un agente dentro il tuo front-end, con codice concreto basato su Vercel AI SDK e React (con un cenno a Vue e Svelte), e con i paletti che servono per non trasformarlo in un problema di sicurezza o di costi.
Dal widget di chat all’agente nell’interfaccia
Conviene fissare un vocabolario, perché “AI nel front-end” copre cose molto diverse:
| Livello | Cosa fa il modello | Cosa vede l’utente |
|---|---|---|
| Chat | Genera testo | Messaggi in streaming |
| Tool calling | Decide quale funzione chiamare e con quali argomenti | Il risultato dell’azione (spesso ancora come testo) |
| Generative UI | Sceglie quale componente mostrare e con quali dati | Card, grafici, form dentro la conversazione |
| Agente nell’app | Esegue più passi di fila, anche sull’interfaccia | L’app che cambia: filtri applicati, pannelli aperti |
Il salto vero avviene tra il primo e il secondo livello: dal momento in cui il modello può chiamare tool, il resto è una questione di come rappresenti quelle chiamate nella UI.
L’architettura in breve
Un’app con componenti AI ha quasi sempre tre pezzi:
- Un endpoint sul server che parla con il modello. Qui stanno la chiave API, il system prompt e i tool che toccano dati o permessi. Il browser non vede mai nessuna di queste cose.
- Un flusso in streaming verso il client, che non trasporta solo testo ma parti strutturate: pezzi di testo, chiamate a tool con il loro stato, risultati.
- Componenti nel client che trasformano ogni parte nella UI giusta: testo → paragrafo, risultato di
getProjectStats→ grafico, richiesta di approvazione → bottoni “Conferma / Annulla”.
Vercel AI SDK copre esattamente questi tre pezzi: streamText e tool sul server, il protocollo di streaming dei “UI messages” nel mezzo, e useChat (React) o la classe Chat (Vue, Svelte) sul client. Gli esempi che seguono usano Next.js, ma l’endpoint è una normale funzione Request → Response e funziona allo stesso modo in Astro, Remix, SvelteKit, Nuxt o in un server Fastify.
⚠ Attenzione
Il codice segue le API di AI SDK 5/6 (UIMessage con parts, sendMessage, inputSchema,
stopWhen, needsApproval). L’SDK si muove in fretta e tra una major e l’altra alcuni nomi
cambiano (es. addToolResult → addToolOutput): controlla la documentazione della versione che
installi.
Il caso d’uso: un assistente dentro una dashboard di progetto
Prendiamo un SaaS di gestione progetti. A destra della lista dei task c’è un pannello assistente. L’utente può scrivere cose come:
- “Come stiamo andando con lo sprint corrente?” → l’AI legge le statistiche e mostra un grafico, non un paragrafo di numeri.
- “Fammi vedere solo i bug ad alta priorità assegnati a Marco” → l’AI filtra la tabella che l’utente ha già sullo schermo.
- “Crea un task per aggiornare le dipendenze, assegnalo a me per venerdì” → l’AI prepara il task, l’utente conferma prima che venga salvato.
Tre tool, tre comportamenti diversi: uno di lettura eseguito sul server, uno che agisce sull’interfaccia ed è eseguito nel browser, uno di scrittura che richiede approvazione.
Il server: modello, tool e streaming
Partiamo dai tool. Ognuno ha una descrizione (che il modello legge come istruzione), uno schema Zod per l’input e, se va eseguito sul server, una funzione execute.
// lib/ai/tools.ts
import { tool } from "ai";
import { z } from "zod";
import { db } from "@/lib/db";
export function createProjectTools(userId: string, projectId: string) {
return {
getProjectStats: tool({
description:
"Restituisce le statistiche dello sprint corrente del progetto: task per stato, " +
"task completati per giorno e punti residui. Usalo per domande sull'avanzamento.",
inputSchema: z.object({
sprint: z.enum(["current", "previous"]).default("current"),
}),
execute: async ({ sprint }) => {
// L'autorizzazione è qui, non nel prompt
await db.assertProjectMember(userId, projectId);
return db.getSprintStats(projectId, sprint);
},
}),
// Nessun execute: lo esegue il client, perché agisce sulla UI
filterTasks: tool({
description:
"Filtra la tabella dei task che l'utente ha sullo schermo. Non restituisce dati: " +
"cambia solo cosa viene mostrato.",
inputSchema: z.object({
status: z.array(z.enum(["todo", "in_progress", "review", "done"])).optional(),
priority: z.array(z.enum(["low", "medium", "high"])).optional(),
type: z.array(z.enum(["bug", "feature", "chore"])).optional(),
assignee: z.string().max(100).optional().describe("Nome o email dell'assegnatario"),
}),
}),
createTask: tool({
description:
"Crea un nuovo task nel progetto. L'utente deve approvare prima del salvataggio.",
inputSchema: z.object({
title: z.string().min(3).max(200),
assignee: z.string().max(100).optional(),
dueDate: z.iso.date().optional().describe("Formato YYYY-MM-DD"),
priority: z.enum(["low", "medium", "high"]).default("medium"),
}),
needsApproval: true,
execute: async (input) => {
await db.assertCanWrite(userId, projectId);
const task = await db.createTask(projectId, { ...input, createdBy: userId });
return { id: task.id, url: `/projects/${projectId}/tasks/${task.id}` };
},
}),
};
}
Due cose da notare. Primo: i tool sono creati per richiesta, con userId e projectId presi dalla sessione — mai dagli argomenti generati dal modello. Il modello sceglie cosa fare, il server decide su cosa ha il diritto di farlo. Secondo: filterTasks non ha execute. È un client-side tool: il server inoltra la chiamata al browser, che la esegue e rimanda indietro il risultato.
Ora l’endpoint:
// app/api/chat/route.ts
import { streamText, convertToModelMessages, stepCountIs, type UIMessage } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { auth } from "@/lib/auth";
import { rateLimit } from "@/lib/rate-limit";
import { createProjectTools } from "@/lib/ai/tools";
export const maxDuration = 30;
export async function POST(req: Request) {
const session = await auth();
if (!session) return new Response("Unauthorized", { status: 401 });
if (!(await rateLimit(`chat:${session.user.id}`, { limit: 20, window: "1m" }))) {
return new Response("Too many requests", { status: 429 });
}
const { messages, projectId }: { messages: UIMessage[]; projectId: string } = await req.json();
const result = streamText({
model: anthropic("claude-sonnet-5-5"),
system:
"Sei l'assistente di una dashboard di progetto. Rispondi in italiano, in modo conciso. " +
"Quando mostri dati numerici usa getProjectStats invece di elencarli nel testo. " +
"Per modificare cosa vede l'utente usa filterTasks.",
messages: await convertToModelMessages(messages),
tools: createProjectTools(session.user.id, projectId),
stopWhen: stepCountIs(5), // al massimo 5 giri modello → tool → modello
maxOutputTokens: 1_000,
abortSignal: req.signal, // se l'utente chiude o preme "stop", smettiamo di pagare token
});
return result.toUIMessageStreamResponse();
}
stopWhen: stepCountIs(5) è ciò che trasforma una chat in un piccolo agente: il modello può chiamare un tool, leggere il risultato, chiamarne un altro e solo alla fine rispondere. Senza un limite esplicito, un modello confuso può girare in loop chiamando tool a vuoto — e ogni giro costa.
💡 Consiglio
Se lo stesso agente serve in più punti (endpoint di chat, job in background, test), AI SDK 6
offre la classe ToolLoopAgent per definire modello, istruzioni, tool e condizione di stop una
volta sola e riusarli. Per un singolo endpoint streamText è più che sufficiente.
Il client: messaggi fatti di parti
Sul client, useChat gestisce lo stato della conversazione e lo streaming. La cosa importante da capire è che ogni messaggio non è una stringa, ma un array di parts: testo, ragionamento, e una parte per ogni chiamata a tool, con tipo tool-<nomeTool> e uno stato che evolve nel tempo.
Per avere i tipi corretti su input e output dei tool, si deriva il tipo dei messaggi dai tool stessi:
// lib/ai/types.ts
import type { InferUITools, UIMessage, UIDataTypes } from "ai";
import type { createProjectTools } from "./tools";
type ProjectTools = InferUITools<ReturnType<typeof createProjectTools>>;
export type ProjectUIMessage = UIMessage<never, UIDataTypes, ProjectTools>;
E il componente:
// components/assistant/assistant-panel.tsx
"use client";
import { useState } from "react";
import { useChat } from "@ai-sdk/react";
import {
DefaultChatTransport,
lastAssistantMessageIsCompleteWithToolCalls,
lastAssistantMessageIsCompleteWithApprovalResponses,
} from "ai";
import type { ProjectUIMessage } from "@/lib/ai/types";
import { useTaskFilters } from "@/components/tasks/filters-store";
import { MessageParts } from "./message-parts";
export function AssistantPanel({ projectId }: { projectId: string }) {
const [input, setInput] = useState("");
const { setFilters, visibleCount } = useTaskFilters();
const { messages, sendMessage, status, stop, addToolOutput, addToolApprovalResponse } =
useChat<ProjectUIMessage>({
transport: new DefaultChatTransport({
api: "/api/chat",
body: { projectId },
}),
// Quando tutti i tool client hanno un risultato (o tutte le approvazioni una risposta),
// rimanda la conversazione al server per far proseguire l'agente
sendAutomaticallyWhen: (options) =>
lastAssistantMessageIsCompleteWithToolCalls(options) ||
lastAssistantMessageIsCompleteWithApprovalResponses(options),
onToolCall({ toolCall }) {
if (toolCall.dynamic) return;
if (toolCall.toolName === "filterTasks") {
setFilters(toolCall.input);
// Niente await qui: evita deadlock con lo stream ancora aperto
addToolOutput({
tool: "filterTasks",
toolCallId: toolCall.toolCallId,
output: { applied: true, visibleTasks: visibleCount() },
});
}
},
});
const busy = status === "submitted" || status === "streaming";
return (
<aside aria-label="Assistente di progetto" className="assistant">
<div className="assistant-log" aria-busy={busy}>
{messages.map((message) => (
<div key={message.id} data-role={message.role}>
<MessageParts message={message} onApproval={addToolApprovalResponse} />
</div>
))}
</div>
<form
onSubmit={(e) => {
e.preventDefault();
if (!input.trim()) return;
sendMessage({ text: input });
setInput("");
}}
>
<label htmlFor="assistant-input" className="sr-only">
Chiedi all'assistente
</label>
<input
id="assistant-input"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Es. mostrami i bug aperti ad alta priorità"
disabled={status === "submitted"}
/>
{busy ? (
<button type="button" onClick={stop}>
Interrompi
</button>
) : (
<button type="submit">Invia</button>
)}
</form>
</aside>
);
}
Il punto più interessante è onToolCall. Quando il modello decide di chiamare filterTasks, il browser riceve la chiamata già validata nello stream, aggiorna lo store dei filtri — la stessa funzione che usano i menu a tendina della tabella — e restituisce al modello quanti task sono ora visibili. L’agente può così rispondere “Ecco i 4 bug ad alta priorità assegnati a Marco” sapendo che il numero è vero.
⚠ Attenzione
Lo schema Zod sul server valida l’input dei tool eseguiti lì. Per i tool client, l’input arriva
dal modello attraverso lo stream: trattalo come qualunque dato esterno. Se setFilters accetta
solo valori noti (enum, lunghezze massime) sei a posto; se costruisce query o URL, valida di nuovo.
Generative UI: ogni tool ha il suo componente
Qui sta la parte che distingue un “componente AI” da una chat. Invece di far scrivere al modello “Lo sprint ha 12 task completati su 30, con un picco martedì…”, lasci che chiami getProjectStats e mostri tu il risultato con un componente vero.
// components/assistant/message-parts.tsx
import type { ProjectUIMessage } from "@/lib/ai/types";
import { Markdown } from "./markdown";
import { SprintChart, SprintChartSkeleton } from "./sprint-chart";
import { TaskApproval } from "./task-approval";
type Props = {
message: ProjectUIMessage;
onApproval: (response: { id: string; approved: boolean; reason?: string }) => void;
};
export function MessageParts({ message, onApproval }: Props) {
return message.parts.map((part, i) => {
const key = `${message.id}-${i}`;
switch (part.type) {
case "text":
return <Markdown key={key}>{part.text}</Markdown>;
case "tool-getProjectStats":
switch (part.state) {
case "input-streaming":
case "input-available":
return <SprintChartSkeleton key={key} />;
case "output-available":
return <SprintChart key={key} stats={part.output} />;
case "output-error":
return (
<p key={key} role="alert">
Non sono riuscito a leggere le statistiche: {part.errorText}
</p>
);
}
break;
case "tool-filterTasks":
return part.state === "output-available" ? (
<p key={key} className="tool-note">
Filtri applicati alla tabella ({part.output.visibleTasks} task visibili)
</p>
) : null;
case "tool-createTask":
switch (part.state) {
case "approval-requested":
return (
<TaskApproval
key={key}
draft={part.input}
onConfirm={() => onApproval({ id: part.approval.id, approved: true })}
onCancel={() =>
onApproval({ id: part.approval.id, approved: false, reason: "Annullato dall'utente" })
}
/>
);
case "output-available":
return (
<p key={key}>
Task creato: <a href={part.output.url}>apri il task</a>
</p>
);
case "output-denied":
return <p key={key} className="tool-note">Creazione annullata.</p>;
default:
return null;
}
}
return null;
});
}
Il modello non genera markup: genera dati tipizzati, e il componente lo scegli tu. È un dettaglio fondamentale:
- Coerenza visiva. Il grafico usa il tuo design system (ad esempio i componenti shadcn/ui che già hai), non un HTML inventato al volo.
- Stati di caricamento veri. Lo stato
input-streamingarriva prima che il tool venga eseguito: puoi mostrare lo skeleton del grafico mentre il server interroga il database. - Sicurezza. Nessun HTML del modello finisce nel DOM. Il peggio che può succedere è che il grafico mostri numeri sbagliati — non che esegua codice.
Human-in-the-loop: l’agente propone, l’utente decide
createTask ha needsApproval: true. Quando il modello lo chiama, il server non esegue execute: lo stream arriva al client con la parte in stato approval-requested e i dati del task proposto. Il componente TaskApproval mostra un’anteprima modificabile:
// components/assistant/task-approval.tsx
type Draft = { title: string; assignee?: string; dueDate?: string; priority: "low" | "medium" | "high" };
export function TaskApproval({
draft,
onConfirm,
onCancel,
}: {
draft: Draft;
onConfirm: () => void;
onCancel: () => void;
}) {
return (
<section className="approval-card" aria-labelledby="approval-title">
<h3 id="approval-title">Creare questo task?</h3>
<dl>
<dt>Titolo</dt>
<dd>{draft.title}</dd>
<dt>Assegnato a</dt>
<dd>{draft.assignee ?? "Nessuno"}</dd>
<dt>Scadenza</dt>
<dd>{draft.dueDate ?? "Nessuna"}</dd>
<dt>Priorità</dt>
<dd>{draft.priority}</dd>
</dl>
<div className="actions">
<button type="button" onClick={onConfirm}>
Crea task
</button>
<button type="button" onClick={onCancel}>
Annulla
</button>
</div>
</section>
);
}
Quando l’utente risponde, addToolApprovalResponse aggiorna il messaggio, sendAutomaticallyWhen rimanda la conversazione al server e, solo se l’approvazione è positiva, il server esegue execute. Se l’utente annulla, il modello lo sa e può chiedere cosa cambiare.
La regola che uso: lettura libera, interfaccia libera, scrittura con conferma, operazioni irreversibili mai. Cancellare un progetto, inviare email a clienti, modificare permessi o fatturazione non sono tool: al massimo l’assistente porta l’utente alla pagina giusta.
ℹ️ Nota
needsApproval accetta anche una funzione asincrona che riceve l’input: puoi chiedere conferma
solo sopra una certa soglia (es. task assegnati ad altri, importi oltre 100 €) e lasciare passare
in automatico i casi banali.
Vue e Svelte
L’SDK non è legato a React. Il server resta identico; sul client si usa la classe Chat, che espone gli stessi concetti (messages con parts, sendMessage, onToolCall, addToolOutput):
<!-- AssistantPanel.vue -->
<script setup lang="ts">
import { ref } from "vue";
import { Chat } from "@ai-sdk/vue";
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
const input = ref("");
const chat = new Chat({
transport: new DefaultChatTransport({ api: "/api/chat" }),
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
});
function submit() {
chat.sendMessage({ text: input.value });
input.value = "";
}
</script>
<template>
<div v-for="message in chat.messages" :key="message.id">
<template v-for="(part, i) in message.parts" :key="i">
<p v-if="part.type === 'text'">{{ part.text }}</p>
<SprintChart
v-else-if="part.type === 'tool-getProjectStats' && part.state === 'output-available'"
:stats="part.output"
/>
</template>
</div>
<form @submit.prevent="submit">
<input v-model="input" aria-label="Chiedi all'assistente" />
</form>
</template>
In Svelte 5 è praticamente lo stesso codice con import { Chat } from "@ai-sdk/svelte" e i rune al posto dei ref.
Fino a dove può arrivare la “generative UI”?
Il pattern visto sopra — tool → componente predefinito — è il più sicuro, ma non è l’unico. Esiste uno spettro, e conviene sapere dove ti stai posizionando:
| Approccio | Chi decide il layout | Rischio | Quando usarlo |
|---|---|---|---|
| Tool → componente del catalogo | Tu, il modello sceglie i dati | Basso | Quasi sempre: dashboard, SaaS, e-commerce |
| Specifica dichiarativa (JSON di componenti da un catalogo chiuso) | Il modello, entro il tuo catalogo | Medio | Report o pannelli composti al volo, con struttura variabile |
| Codice generato (HTML/JSX dal modello) | Il modello | Alto | Prototipi, artefatti, sempre in un iframe sandboxato |
Il secondo approccio è quello su cui si stanno muovendo diversi standard: A2UI di Google e le MCP Apps (l’estensione di MCP per restituire UI interattive dai tool) vanno in quella direzione, così come protocolli come AG-UI che standardizzano gli eventi tra un agente e il front-end. L’idea comune: il modello descrive cosa mostrare con un vocabolario limitato, il client decide come disegnarlo.
E i React Server Components? Le prime versioni dell’SDK avevano streamUI (in ai/rsc), che restituiva direttamente componenti RSC dallo stream. È un’idea elegante — ne avevo parlato nell’articolo sui Server Components — ma lo sviluppo di ai/rsc è sospeso e Vercel stessa consiglia di usare l’approccio basato su useChat e tool parts. Per un progetto nuovo partirei da lì.
⛔ Pericolo
Non inserire mai l’output del modello con dangerouslySetInnerHTML o v-html. Il testo del
modello può contenere qualunque cosa: anche istruzioni iniettate da un documento o da un commento
che l’agente ha letto. Usa un renderer Markdown che non accetta HTML grezzo, e valuta di
disattivare le immagini remote: un’immagine con un URL costruito dal modello è un canale classico
per esfiltrare dati.
Sicurezza e produzione: la checklist che non puoi saltare
Un agente nel front-end ha gli stessi permessi dell’utente, ma prende decisioni basandosi su testo che può arrivare da chiunque: descrizioni di task scritte da colleghi, documenti caricati, risposte di API esterne. Ecco cosa conta davvero.
1. Autorizzazione nei tool, non nel prompt. “Non mostrare i dati degli altri progetti” nel system prompt non è un controllo di sicurezza. Il controllo è db.assertProjectMember(userId, projectId) dentro execute, con userId preso dalla sessione. Le stesse regole valgono per le Server Actions: ogni tool è un endpoint pubblico.
2. Prompt injection indiretta. Se un tool restituisce testo scritto da altri utenti (commenti, descrizioni), quel testo entra nel contesto del modello. Restituisci dati strutturati e il minimo indispensabile, delimita chiaramente i contenuti non fidati, e fai in modo che nessuna azione importante dipenda solo da quello che il modello “ha letto”.
3. Limiti di costo. stopWhen per i passi, maxOutputTokens per le risposte, rate limit per utente, abortSignal collegato alla richiesta. Un loop di tool senza limiti è il modo più veloce per ricevere una fattura spiacevole.
4. Niente segreti nel client. La chiave del provider sta solo sul server. Il client parla con il tuo endpoint, mai direttamente con l’API del modello.
5. Idempotenza. Con gli stream e i retry automatici, un tool di scrittura può essere chiamato due volte. Usa il toolCallId come chiave di idempotenza.
6. Log e osservabilità. Registra quali tool vengono chiamati, con quali argomenti e con quale esito. È l’unico modo per capire perché l’assistente “ha fatto una cosa strana” e per accorgerti di tentativi di abuso.
Accessibilità e performance
Un pannello AI è un componente dinamico come un altro, e si porta dietro due problemi tipici:
- Lo streaming e gli screen reader. Una regione
aria-liveche si aggiorna a ogni token legge frammenti di frase all’infinito. Meglioaria-busy="true"sul log mentre lo stream è attivo e un annuncio unico a risposta completata (es. una regionearia-live="polite"separata con “Risposta pronta”). - Il pulsante “Interrompi” è obbligatorio, non un extra.
stop()chiude lo stream e, conabortSignal: req.signalsul server, ferma anche la generazione. Lo stesso principio dell’AbortController. - Troppi render. Ogni chunk aggiorna lo stato. Con messaggi lunghi e Markdown complesso l’INP ne risente:
useChataccettaexperimental_throttle(es.50ms) per raggruppare gli aggiornamenti, e conviene memoizzare il rendering dei messaggi già completati. - Tutto il resto dell’app deve funzionare senza AI. Filtri, creazione task, statistiche: l’assistente è una scorciatoia, non l’unico percorso. Se il provider è lento o giù, l’utente non deve restare bloccato.
Testare un componente AI
Non serve chiamare un modello vero nei test. AI SDK espone modelli mock in ai/test con cui puoi simulare risposte e chiamate a tool deterministiche, e testare che il tuo MessageParts mostri lo skeleton, poi il grafico, poi la card di approvazione. Con Vitest e Testing Library il componente si testa come qualsiasi altro: gli passi un array di parts costruito a mano e verifichi il DOM.
I test sul comportamento del modello (sceglie il tool giusto? rispetta i limiti?) sono un’altra categoria — le “eval” — e vanno eseguiti su un set di prompt di riferimento, non a ogni commit.
Checklist
Architettura
- Chiave API, system prompt e tool sensibili solo sul server
- Tool creati per richiesta, con identità presa dalla sessione
- Schemi Zod con enum e lunghezze massime su ogni input
-
stopWhenemaxOutputTokensimpostati - Rate limit per utente e
abortSignalcollegato alla richiesta
UI
- Ogni tool ha un componente per ogni stato (caricamento, risultato, errore)
- I tool client chiamano la stessa logica della UI
- Nessun HTML del modello renderizzato direttamente
- Bottone “Interrompi” sempre disponibile durante lo stream
-
aria-busydurante lo stream e annuncio a fine risposta
Sicurezza
- Autorizzazione dentro ogni
execute -
needsApprovalsui tool di scrittura - Nessun tool per operazioni irreversibili
- Contenuti di terzi restituiti come dati strutturati e minimi
- Log delle chiamate ai tool
Domande frequenti
❓ Serve per forza Vercel AI SDK?
No. Puoi usare direttamente l’SDK del provider e gestire tu lo streaming, oppure framework come CopilotKit o assistant-ui che si appoggiano a protocolli simili. AI SDK è una scelta comoda perché è indipendente dal provider (cambi modello cambiando una riga), funziona con React, Vue e Svelte e ha già risolto streaming, stati dei tool e approvazioni.
❓ Qual è la differenza tra un tool client e un tool server?
Un tool server ha una funzione execute e gira sul tuo backend: è adatto per leggere o scrivere
dati. Un tool client non ha execute: la chiamata arriva al browser, che la esegue in
onToolCall e restituisce il risultato con addToolOutput. È adatto per agire sull’interfaccia
(filtri, navigazione, apertura di pannelli) o per chiedere qualcosa all’utente.
❓ La generative UI è sicura?
Dipende da chi genera il markup. Se il modello sceglie solo tra componenti che hai scritto tu e fornisce dati validati da uno schema, il rischio è basso. Se il modello genera HTML o codice, quel contenuto va trattato come non fidato e isolato in un iframe sandboxato, senza accesso alla sessione dell’utente.
❓ Posso far girare il modello nel browser invece che sul server?
Per compiti piccoli sì: classificazione, riassunti brevi, ricerca semantica. Ne ho parlato nell’articolo su WebGPU e AI lato client. Per un agente che chiama tool su più passi, oggi un modello lato server resta molto più affidabile.
Conclusione
L’AI nel front-end smette di essere un widget nel momento in cui il modello può usare l’applicazione: chiamare le stesse funzioni dei bottoni, mostrare i risultati con i tuoi componenti, chiedere conferma prima di modificare qualcosa. Gli strumenti per farlo ci sono e sono maturi; la parte che fa la differenza è progettuale. Tool piccoli e ben descritti, componenti per ogni stato, autorizzazione sul server e un confine chiaro tra ciò che l’agente può fare da solo e ciò che resta all’utente.
Riferimenti:

Co-Fondatore & CTO presso PAPION. Senior software engineer con competenze AI: React, TypeScript, Node.js, integrazione di LLM e agenti AI, sicurezza applicativa.