Diego Betto
Photo by Creatvise on Unsplash

Diego Betto · ·19 min di lettura

Agentic AI in the front-end: AI components, generative UI and tools with Vercel AI SDK

Beyond the chat widget: how to connect an LLM to React, Vue or Svelte components with Vercel AI SDK, let the AI take actions, generate UI in real time and keep the user in control.

Condividi:XLinkedInFacebookWhatsApp

For a couple of years, “adding AI” to a web app meant one thing: a bubble in the bottom-right corner, a text field and streamed answers. Useful, but disconnected from the rest of the app: the model talks about your product, it doesn’t use it.

The next step, the one the most interesting SaaS products are moving towards, is different. The LLM becomes an agent that calls application functions (filter a table, create a task, open a panel), and the answer is no longer just text but interface components — a chart, a preview card, a prefilled form — generated and updated in real time.

In a previous article I covered external agents browsing your site. Here we look at the other side: how to bring an agent inside your front-end, with concrete code based on Vercel AI SDK and React (plus a look at Vue and Svelte), and with the guardrails you need so it doesn’t become a security or cost problem.

From chat widget to in-app agent

It’s worth pinning down a vocabulary, because “AI in the front-end” covers very different things:

Level What the model does What the user sees
Chat Generates text Streamed messages
Tool calling Decides which function to call and with which arguments The result of the action (often still as text)
Generative UI Chooses which component to show and with what data Cards, charts, forms inside the conversation
In-app agent Runs several steps in a row, including on the UI The app changing: filters applied, panels opened

The real jump happens between the first and the second level: once the model can call tools, the rest is a matter of how you represent those calls in the UI.

The architecture in short

An app with AI components almost always has three pieces:

  1. A server endpoint that talks to the model. This is where the API key, the system prompt and any tool touching data or permissions live. The browser never sees any of them.
  2. A streaming flow to the client, carrying not just text but structured parts: text chunks, tool calls with their state, results.
  3. Client components that turn each part into the right UI: text → paragraph, getProjectStats result → chart, approval request → “Confirm / Cancel” buttons.

Vercel AI SDK covers exactly these three pieces: streamText and tool on the server, the “UI messages” streaming protocol in between, and useChat (React) or the Chat class (Vue, Svelte) on the client. The examples below use Next.js, but the endpoint is a plain Request → Response function and works the same way in Astro, Remix, SvelteKit, Nuxt or a Fastify server.

⚠ Attenzione

The code follows the AI SDK 5/6 APIs (UIMessage with parts, sendMessage, inputSchema, stopWhen, needsApproval). The SDK moves fast and some names change between majors (e.g. addToolResult → addToolOutput): check the docs for the version you install.

The use case: an assistant inside a project dashboard

Take a project management SaaS. To the right of the task list there’s an assistant panel. The user can type things like:

  • “How are we doing on the current sprint?” → the AI reads the stats and shows a chart, not a paragraph full of numbers.
  • “Show me only high-priority bugs assigned to Mark” → the AI filters the table the user already has on screen.
  • “Create a task to update dependencies, assign it to me for Friday” → the AI drafts the task, the user confirms before it’s saved.

Three tools, three different behaviours: a read-only one executed on the server, one that acts on the interface and runs in the browser, and a write one that requires approval.

The server: model, tools and streaming

Let’s start with the tools. Each one has a description (which the model reads as an instruction), a Zod schema for its input and, if it runs on the server, an execute function.

// 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:
        "Returns the project's current sprint statistics: tasks by status, " +
        "tasks completed per day and remaining points. Use it for progress questions.",
      inputSchema: z.object({
        sprint: z.enum(["current", "previous"]).default("current"),
      }),
      execute: async ({ sprint }) => {
        // Authorization lives here, not in the prompt
        await db.assertProjectMember(userId, projectId);
        return db.getSprintStats(projectId, sprint);
      },
    }),

    // No execute: the client runs it, because it acts on the UI
    filterTasks: tool({
      description:
        "Filters the task table the user has on screen. Returns no data: " +
        "it only changes what is displayed.",
      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("Assignee name or email"),
      }),
    }),

    createTask: tool({
      description: "Creates a new task in the project. The user must approve before it's saved.",
      inputSchema: z.object({
        title: z.string().min(3).max(200),
        assignee: z.string().max(100).optional(),
        dueDate: z.iso.date().optional().describe("Format 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}` };
      },
    }),
  };
}

Two things to notice. First: the tools are created per request, with userId and projectId taken from the session — never from model-generated arguments. The model chooses what to do, the server decides what it’s allowed to do it on. Second: filterTasks has no execute. It’s a client-side tool: the server forwards the call to the browser, which runs it and sends the result back.

Now the 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:
      "You are the assistant of a project dashboard. Answer concisely. " +
      "When showing numeric data use getProjectStats instead of listing it in text. " +
      "To change what the user sees, use filterTasks.",
    messages: await convertToModelMessages(messages),
    tools: createProjectTools(session.user.id, projectId),
    stopWhen: stepCountIs(5), // at most 5 model → tool → model rounds
    maxOutputTokens: 1_000,
    abortSignal: req.signal, // if the user closes or hits "stop", we stop paying for tokens
  });

  return result.toUIMessageStreamResponse();
}

stopWhen: stepCountIs(5) is what turns a chat into a small agent: the model can call a tool, read the result, call another one and only then answer. Without an explicit limit, a confused model can loop calling tools for nothing — and every round costs money.

💡 Consiglio

If the same agent is used in several places (chat endpoint, background jobs, tests), AI SDK 6 provides the ToolLoopAgent class to define model, instructions, tools and stop condition once and reuse them. For a single endpoint, streamText is more than enough.

The client: messages made of parts

On the client, useChat manages conversation state and streaming. The key thing to understand is that each message isn’t a string but an array of parts: text, reasoning, and one part per tool call, with type tool-<toolName> and a state that evolves over time.

To get correct types for tool inputs and outputs, derive the message type from the tools themselves:

// 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>;

And the component:

// 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 },
      }),
      // Once every client tool has a result (or every approval a response),
      // send the conversation back to the server so the agent can continue
      sendAutomaticallyWhen: (options) =>
        lastAssistantMessageIsCompleteWithToolCalls(options) ||
        lastAssistantMessageIsCompleteWithApprovalResponses(options),

      onToolCall({ toolCall }) {
        if (toolCall.dynamic) return;

        if (toolCall.toolName === "filterTasks") {
          setFilters(toolCall.input);
          // No await here: avoids deadlocks while the stream is still open
          addToolOutput({
            tool: "filterTasks",
            toolCallId: toolCall.toolCallId,
            output: { applied: true, visibleTasks: visibleCount() },
          });
        }
      },
    });

  const busy = status === "submitted" || status === "streaming";

  return (
    <aside aria-label="Project assistant" 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">
          Ask the assistant
        </label>
        <input
          id="assistant-input"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="E.g. show me open high-priority bugs"
          disabled={status === "submitted"}
        />
        {busy ? (
          <button type="button" onClick={stop}>
            Stop
          </button>
        ) : (
          <button type="submit">Send</button>
        )}
      </form>
    </aside>
  );
}

The most interesting bit is onToolCall. When the model decides to call filterTasks, the browser receives the call in the stream, updates the filter store — the same function the table’s dropdowns use — and tells the model how many tasks are now visible. The agent can then answer “Here are the 4 high-priority bugs assigned to Mark” knowing the number is real.

⚠ Attenzione

The Zod schema on the server validates the input of tools executed there. For client tools, the input comes from the model through the stream: treat it like any other external data. If setFilters only accepts known values (enums, max lengths) you’re fine; if it builds queries or URLs, validate again.

Generative UI: every tool gets its own component

This is what sets an “AI component” apart from a chat. Instead of having the model write “The sprint has 12 out of 30 tasks done, with a peak on Tuesday…”, you let it call getProjectStats and you render the result with a real component.

// 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">
                I couldn't load the statistics: {part.errorText}
              </p>
            );
        }
        break;

      case "tool-filterTasks":
        return part.state === "output-available" ? (
          <p key={key} className="tool-note">
            Filters applied to the table ({part.output.visibleTasks} tasks visible)
          </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: "Cancelled by the user" })
                }
              />
            );
          case "output-available":
            return (
              <p key={key}>
                Task created: <a href={part.output.url}>open the task</a>
              </p>
            );
          case "output-denied":
            return <p key={key} className="tool-note">Creation cancelled.</p>;
          default:
            return null;
        }
    }
    return null;
  });
}

The model doesn’t generate markup: it generates typed data, and you pick the component. It’s a fundamental detail:

  • Visual consistency. The chart uses your design system (for example the shadcn/ui components you already have), not HTML invented on the fly.
  • Real loading states. The input-streaming state arrives before the tool runs: you can show the chart skeleton while the server queries the database.
  • Security. No model HTML ever reaches the DOM. The worst that can happen is a chart with wrong numbers — not code execution.

Human-in-the-loop: the agent proposes, the user decides

createTask has needsApproval: true. When the model calls it, the server does not run execute: the stream reaches the client with the part in the approval-requested state and the proposed task data. The TaskApproval component shows a preview:

// 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">Create this task?</h3>
      <dl>
        <dt>Title</dt>
        <dd>{draft.title}</dd>
        <dt>Assigned to</dt>
        <dd>{draft.assignee ?? "Nobody"}</dd>
        <dt>Due date</dt>
        <dd>{draft.dueDate ?? "None"}</dd>
        <dt>Priority</dt>
        <dd>{draft.priority}</dd>
      </dl>
      <div className="actions">
        <button type="button" onClick={onConfirm}>
          Create task
        </button>
        <button type="button" onClick={onCancel}>
          Cancel
        </button>
      </div>
    </section>
  );
}

When the user responds, addToolApprovalResponse updates the message, sendAutomaticallyWhen sends the conversation back to the server and, only if approved, the server runs execute. If the user cancels, the model knows and can ask what to change.

The rule I use: reading is free, UI changes are free, writes need confirmation, irreversible operations never. Deleting a project, emailing customers, changing permissions or billing are not tools: at most the assistant takes the user to the right page.

ℹ️ Nota

needsApproval also accepts an async function that receives the input: you can ask for confirmation only above a threshold (e.g. tasks assigned to someone else, amounts over €100) and let trivial cases through automatically.

Vue and Svelte

The SDK isn’t tied to React. The server stays identical; on the client you use the Chat class, which exposes the same concepts (messages with 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="Ask the assistant" />
  </form>
</template>

In Svelte 5 it’s virtually the same code with import { Chat } from "@ai-sdk/svelte" and runes instead of ref.

How far can “generative UI” go?

The pattern above — tool → predefined component — is the safest, but not the only one. There’s a spectrum, and it’s worth knowing where you sit on it:

Approach Who decides the layout Risk When to use it
Tool → catalog component You; the model picks the data Low Almost always: dashboards, SaaS, e-commerce
Declarative spec (JSON of components from a closed catalog) The model, within your catalog Medium Reports or panels composed on the fly, with variable structure
Generated code (HTML/JSX from the model) The model High Prototypes, artifacts, always in a sandboxed iframe

The second approach is where several standards are heading: Google’s A2UI and MCP Apps (the MCP extension for returning interactive UI from tools) go that way, as do protocols like AG-UI that standardise events between an agent and the front-end. The shared idea: the model describes what to show with a limited vocabulary, the client decides how to draw it.

What about React Server Components? Earlier SDK versions had streamUI (in ai/rsc), which returned RSC components straight from the stream. It’s an elegant idea — I touched on it in the Server Components article — but development of ai/rsc is paused and Vercel itself recommends the useChat + tool parts approach. For a new project I’d start there.

⛔ Pericolo

Never insert model output with dangerouslySetInnerHTML or v-html. Model text can contain anything: including instructions injected from a document or a comment the agent has read. Use a Markdown renderer that doesn’t accept raw HTML, and consider disabling remote images: an image whose URL is built by the model is a classic channel for data exfiltration.

Security and production: the checklist you can’t skip

An agent in the front-end has the same permissions as the user, but makes decisions based on text that can come from anyone: task descriptions written by colleagues, uploaded documents, external API responses. Here’s what really matters.

1. Authorization in the tools, not in the prompt. “Don’t show data from other projects” in the system prompt is not a security control. The control is db.assertProjectMember(userId, projectId) inside execute, with userId taken from the session. The same rules apply as for Server Actions: every tool is a public endpoint.

2. Indirect prompt injection. If a tool returns text written by other users (comments, descriptions), that text enters the model’s context. Return structured data and only what’s needed, clearly delimit untrusted content, and make sure no important action depends solely on what the model “read”.

3. Cost limits. stopWhen for steps, maxOutputTokens for answers, per-user rate limits, abortSignal tied to the request. An unbounded tool loop is the fastest way to get an unpleasant invoice.

4. No secrets on the client. The provider key lives only on the server. The client talks to your endpoint, never directly to the model API.

5. Idempotency. With streams and automatic retries, a write tool can be called twice. Use the toolCallId as an idempotency key.

6. Logging and observability. Record which tools are called, with which arguments and what outcome. It’s the only way to understand why the assistant “did something weird” and to spot abuse attempts.

Accessibility and performance

An AI panel is a dynamic component like any other, and it brings two typical problems with it:

  • Streaming and screen readers. An aria-live region updated on every token reads sentence fragments forever. Better: aria-busy="true" on the log while the stream is active, and a single announcement once the answer is complete (e.g. a separate aria-live="polite" region saying “Answer ready”).
  • The “Stop” button is mandatory, not an extra. stop() closes the stream and, with abortSignal: req.signal on the server, also stops generation. Same principle as AbortController.
  • Too many renders. Every chunk updates state. With long messages and complex Markdown, INP suffers: useChat accepts experimental_throttle (e.g. 50 ms) to batch updates, and it’s worth memoising the rendering of completed messages.
  • The rest of the app must work without AI. Filters, task creation, stats: the assistant is a shortcut, not the only path. If the provider is slow or down, the user must not be stuck.

Testing an AI component

You don’t need to call a real model in tests. AI SDK exposes mock models in ai/test to simulate deterministic responses and tool calls, so you can test that MessageParts shows the skeleton, then the chart, then the approval card. With Vitest and Testing Library the component is tested like any other: pass it a hand-built array of parts and assert on the DOM.

Tests on model behaviour (does it pick the right tool? does it respect limits?) are a different category — “evals” — and should run on a reference prompt set, not on every commit.

Checklist

Architecture

  • API key, system prompt and sensitive tools only on the server
  • Tools created per request, with identity taken from the session
  • Zod schemas with enums and max lengths on every input
  • stopWhen and maxOutputTokens set
  • Per-user rate limit and abortSignal tied to the request

UI

  • Every tool has a component for every state (loading, result, error)
  • Client tools call the same logic as the UI
  • No model HTML rendered directly
  • “Stop” button always available while streaming
  • aria-busy during streaming and an announcement at the end

Security

  • Authorization inside every execute
  • needsApproval on write tools
  • No tools for irreversible operations
  • Third-party content returned as minimal, structured data
  • Tool calls logged

Frequently asked questions

❓ Do I have to use Vercel AI SDK?

No. You can use the provider’s SDK directly and handle streaming yourself, or frameworks such as CopilotKit or assistant-ui built on similar protocols. AI SDK is a convenient choice because it’s provider-agnostic (switch models by changing one line), works with React, Vue and Svelte, and has already solved streaming, tool states and approvals.

❓ What's the difference between a client tool and a server tool?

A server tool has an execute function and runs on your backend: it’s suited to reading or writing data. A client tool has no execute: the call reaches the browser, which runs it in onToolCall and returns the result with addToolOutput. It’s suited to acting on the interface (filters, navigation, opening panels) or asking the user something.

❓ Is generative UI safe?

It depends on who generates the markup. If the model only chooses among components you wrote and provides data validated by a schema, the risk is low. If the model generates HTML or code, that content must be treated as untrusted and isolated in a sandboxed iframe with no access to the user’s session.

❓ Can I run the model in the browser instead of on the server?

For small tasks, yes: classification, short summaries, semantic search. I covered it in the article on WebGPU and client-side AI. For an agent calling tools over several steps, a server-side model is still far more reliable today.

Conclusion

AI in the front-end stops being a widget the moment the model can use the application: call the same functions as the buttons, show results with your components, ask for confirmation before changing anything. The tools to do it exist and are mature; the part that makes the difference is design. Small, well-described tools, a component for every state, authorization on the server, and a clear boundary between what the agent can do on its own and what stays with the user.

References:

Condividi:XLinkedInFacebookWhatsApp
Diego Betto

Written by

Diego Betto

Co-Founder & CTO at PAPION. Senior software engineer with AI skills: React, TypeScript, Node.js, LLM and AI agent integration, and application security.