# 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.

- Autore / Author: Diego Betto (https://diegobetto.com/about/)
- Pubblicato / Published: 2026-11-02
- URL: https://diegobetto.com/agentic-ai-componenti-ai-frontend/
- Tag: ai, agenti, react, typescript, vercel ai sdk, generative ui

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](https://diegobetto.com/agenti-ai-web-autonomo/) 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:

1. **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.
2. **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.
3. **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](https://diegobetto.com/fastify-rest-api-typescript/).

> 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`.

```ts
// 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:

```ts
// 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.

> 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:

```ts
// 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:

```tsx
// 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.

> 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.

```tsx
// 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](https://diegobetto.com/shadcn-ui-guida/) che già hai), non un HTML inventato al volo.
- **Stati di caricamento veri.** Lo stato `input-streaming` arriva _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:

```tsx
// 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.

> `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`):

```vue
<!-- 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](https://diegobetto.com/mcp-server-typescript/) 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](https://diegobetto.com/react-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ì.

> 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](https://diegobetto.com/sicurezza-server-actions-react-server-components/): 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-live` che si aggiorna a ogni token legge frammenti di frase all'infinito. Meglio `aria-busy="true"` sul log mentre lo stream è attivo e un annuncio unico a risposta completata (es. una regione `aria-live="polite"` separata con "Risposta pronta").
- **Il pulsante "Interrompi" è obbligatorio**, non un extra. `stop()` chiude lo stream e, con `abortSignal: req.signal` sul server, ferma anche la generazione. Lo stesso principio dell'[AbortController](https://diegobetto.com/abortcontroller-fetch-javascript/).
- **Troppi render.** Ogni chunk aggiorna lo stato. Con messaggi lunghi e Markdown complesso l'INP ne risente: `useChat` accetta `experimental_throttle` (es. `50` ms) 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](https://diegobetto.com/vitest-guida-react-19/) 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
- [ ] `stopWhen` e `maxOutputTokens` impostati
- [ ] Rate limit per utente e `abortSignal` collegato 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-busy` durante lo stream e annuncio a fine risposta

**Sicurezza**

- [ ] Autorizzazione dentro ogni `execute`
- [ ] `needsApproval` sui 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](https://diegobetto.com/webgpu-ai-nel-browser/). 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:**

- [Vercel AI SDK — documentazione](https://ai-sdk.dev/docs)
- [AI SDK UI: chatbot con tool, client-side tools e approvazioni](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-tool-usage)
- [AI SDK UI: Generative User Interfaces](https://ai-sdk.dev/docs/ai-sdk-ui/generative-user-interfaces)
- [Model Context Protocol](https://modelcontextprotocol.io/)
- [OWASP Top 10 for LLM Applications](https://owasp.org/www-project-top-10-for-large-language-model-applications/)
