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

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

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](https://diegobetto.com/en/ai-agents-agentic-web/) 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](https://diegobetto.com/en/fastify-rest-api-typescript/) server.

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

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

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

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

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

And the component:

```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 },
      }),
      // 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.

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

```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">
                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](https://diegobetto.com/en/shadcn-ui-guide/) 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:

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

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

```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="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](https://diegobetto.com/en/mcp-server-typescript/) 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](https://diegobetto.com/en/react-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.

> 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](https://diegobetto.com/en/react-server-actions-security/): 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](https://diegobetto.com/en/abortcontroller-cancel-fetch-javascript/).
- **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](https://diegobetto.com/en/vitest-guide-react-19/) 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](https://diegobetto.com/en/webgpu-client-side-ai-browser/). 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:**

- [Vercel AI SDK — documentation](https://ai-sdk.dev/docs)
- [AI SDK UI: chatbot tool usage, client-side tools and approvals](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/)
