The Vercel AI SDK gives a Svelte app the conversation: the messages, the stream, the tool calls. The chat itself (bubbles, Markdown that renders as it streams, a composer, what a tool call looks like, how a person approves one) is yours to build: the SDK's ready-made chat components are React.
In this tutorial we put a whole chat on a SvelteKit page, in about 150 lines:
- the model is called from a SvelteKit route, so the API key never reaches the browser;
- the model can call one tool,
add_task, and it runs only after the user clicks Approve; - the call is drawn by a Svelte component of yours.
The chat UI is aparté, an MIT library I maintain: Web Components with a thin Svelte wrapper (there are React, Vue and Angular ones too). The code below is for SvelteKit 3.1, Svelte 5.57, AI SDK 7 and aparté 1.0.2.
The finished app is in apartejs/examples, and you can try it on StackBlitz before reading on, without a key: with none, a scripted model answers in place of Claude. It understands nothing, but the approval, the card and the streaming Markdown are the real thing.
Open the finished app in StackBlitz
1. Create the project
npx sv create task-chat --template minimal --types ts cd task-chat npm install @aparte/svelte @aparte/core @aparte/provider-ai-sdk @aparte/plugin-marked marked ai @ai-sdk/anthropic 2. Give the server your key
SvelteKit 3 declares its environment variables in src/env.ts:
// src/env.ts import { defineEnvVars } from '@sveltejs/kit/env'; export const variables = defineEnvVars({ ANTHROPIC_API_KEY: {}, }); and reads them from .env, which the template already keeps out of git:
ANTHROPIC_API_KEY=sk-ant-... On SvelteKit 2, skip src/env.ts and import the key from $env/static/private in the next step.
3. The route that calls the model
// src/routes/api/chat/+server.ts import { createAnthropic } from '@ai-sdk/anthropic'; import { streamText } from 'ai'; import type { AparteChatRequest, AparteStreamEvent } from '@aparte/core'; import { fullStreamToAparteEvents, toModelMessages, toToolChoice, toToolSet } from '@aparte/provider-ai-sdk'; import { ANTHROPIC_API_KEY } from '$app/env/private'; import type { RequestHandler } from './$types'; const anthropic = createAnthropic({ apiKey: ANTHROPIC_API_KEY }); export const POST: RequestHandler = async ({ request: req }) => { // In a real app, check the user's session here: this route spends your key. const { request } = (await req.json()) as { request: AparteChatRequest }; const result = streamText({ model: anthropic('claude-haiku-5-5'), // the server picks the model, not the page system: `You keep the user's task list. Today is ${new Date().toDateString()}.`, messages: toModelMessages(request.messages), abortSignal: req.signal, ...(request.tools?.length ? { tools: toToolSet(request.tools), toolChoice: toToolChoice(request.toolChoice) ?? 'auto' } : {}), }); const ndjson = fullStreamToAparteEvents(result.fullStream) .pipeThrough( new TransformStream<AparteStreamEvent, string>({ transform(event, controller) { if (event.type === 'error') { console.error('[api/chat]', event.message); // the vendor's words stay in your log event = { type: 'error', message: 'The model call failed.' }; } controller.enqueue(JSON.stringify(event) + '\n'); }, }), ) .pipeThrough(new TextEncoderStream()); return new Response(ndjson, { headers: { 'content-type': 'application/x-ndjson' } }); }; This is an ordinary streamText call. The four helpers are the glue:
-
toModelMessagesturns the conversation the page sends into the SDK's messages; -
toToolSetgives the model the page's tools as declarations only, with noexecute: when the model calls one, the call goes back to the page, which asks the user and runs it; -
fullStreamToAparteEventsturns the SDK's stream into the events the chat reads, sent as one JSON object per line.
The route ignores the model the page asks for and picks its own, so nobody can point your key at a more expensive model. The provider's own error text stays in your server log, because a provider's error can quote parts of the key.
4. Point the chat at the route, and give it a tool
// src/lib/tasks.svelte.ts export type Task = { title: string; due?: string }; export const tasks = $state<Task[]>([]); // src/lib/chat.ts import { aparteGlobalConfig, AparteBackendTransport } from '@aparte/core'; import { createAiSdkProvider } from '@aparte/provider-ai-sdk'; import { setupMarkedStreamingProvider } from '@aparte/plugin-marked/streaming'; import { tasks, type Task } from './tasks.svelte.ts'; // Models answer in Markdown: format it as it streams, not after. setupMarkedStreamingProvider(); // The page sends every request to /api/chat; the model call happens there. aparteGlobalConfig.registerAIProvider( createAiSdkProvider({ id: 'server', name: 'Server', languageModel: () => { throw new Error('The model runs on the server.'); }, }), ); aparteGlobalConfig.setTransport(new AparteBackendTransport({ endpoint: '/api/chat' })); aparteGlobalConfig.setModelConfig({ defaultProvider: 'server', defaultModel: 'claude-haiku-5-5' }); // A tool the model may call, but only after the user says yes. aparteGlobalConfig.registerTool( { name: 'add_task', description: "Add a task to the user's list.", inputSchema: { type: 'object', properties: { title: { type: 'string' }, due: { type: 'string', description: 'Due date, YYYY-MM-DD' }, }, required: ['title'], }, needsApproval: true, }, async (call) => { const task: Task = { title: String(call.input['title']), due: call.input['due'] ? String(call.input['due']) : undefined, }; tasks.push(task); return { toolCallId: call.id, content: `Added "${task.title}".`, structuredContent: task }; }, ); Models answer in Markdown, and without a renderer the chat would show the asterisks. setupMarkedStreamingProvider() formats the reply while it arrives: marked parses it, and Vercel's remend, the piece behind Streamdown, closes what the model has not closed yet, so **bold is bold before its closing ** arrives and a half-written table already has its rows.
The provider registered here never calls a model: its languageModel is only there because the page needs an id to send. The transport sends each request to the route instead.
needsApproval: true is the whole approval feature. When the model calls add_task, the turn stops before the handler runs and the chat asks the user. The handler runs in the browser, after a yes. Here it pushes to a list on the page; in a real app it would fetch your own API, with the user's session. It returns two things: content is what the model reads, and structuredContent is the same result as data, for the component in step 6.
5. The page
<!-- src/routes/+page.svelte --> <script lang="ts" module> import '#lib/chat.ts'; </script> <script lang="ts"> import { AparteChat, AparteToolUI, createAparteClient } from '@aparte/svelte/runes'; import '@aparte/core/styles.css'; import TaskCard from '#lib/TaskCard.svelte'; import { tasks } from '#lib/tasks.svelte.ts'; createAparteClient(); </script> <AparteToolUI name="add_task"> {#snippet children(tool)}<TaskCard {tool} />{/snippet} </AparteToolUI> <main> <section class="chat"> <AparteChat centerWhenEmpty placeholder="What do you need to do?"> {#snippet emptyState()}<p>Tell me what you need to do, I'll keep the list.</p>{/snippet} </AparteChat> </section> <aside> <h2>Tasks</h2> {#each tasks as task} <p>{task.title}{task.due ? ` · ${task.due}` : ''}</p> {:else} <p class="empty">Nothing yet.</p> {/each} </aside> </main> <style> :global(body) { margin: 0; font-family: system-ui, sans-serif; background: var(--aparte-bg); color: var(--aparte-text); } main { display: grid; grid-template-columns: 1fr 16rem; height: 100dvh; } .chat { display: flex; min-height: 0; } .chat :global(aparte-chat) { flex: 1; min-height: 0; } aside { padding: 1rem; border-inline-start: 1px solid var(--aparte-border); } h2 { margin: 0 0 0.75rem; font-size: 1rem; } .empty { color: var(--aparte-text-muted); } </style> The <script module> block runs once, however many times the page mounts. createAparteClient() starts the chat's loop in the browser when the component mounts and stops it when it is destroyed. The page still renders on the server and hydrates without a mismatch. (#lib is SvelteKit 3's alias; on SvelteKit 2 write $lib/chat and so on.)
6. Draw the tool call yourself
Without step 6 the call already shows, as a one-line row with its arguments behind a disclosure. <AparteToolUI> swaps that row for your component, for every call to that tool:
<!-- src/lib/TaskCard.svelte --> <script lang="ts"> import type { AparteToolCallView } from '@aparte/svelte/runes'; import type { Task } from './tasks.svelte.ts'; let { tool }: { tool: AparteToolCallView } = $props(); // The model's arguments, still arriving while the call streams. const task = $derived((tool.input ?? {}) as Partial<Task>); const labels: Record<string, string> = { 'awaiting-approval': 'Waiting for your OK', resolved: 'Added', rejected: 'Not added', }; </script> <div class="task-card" data-status={tool.status}> <strong>{task.title ?? '…'}</strong> {#if task.due}<span>due {task.due}</span>{/if} <em>{labels[tool.status] ?? 'Preparing…'}</em> </div> <style> .task-card { display: grid; gap: 0.25rem; max-width: 22rem; padding: 0.75rem 1rem; border: 1px solid var(--aparte-border); border-radius: 0.75rem; background: var(--aparte-surface-1); } span { color: var(--aparte-text-muted); font-size: 0.875rem; } em { font-style: normal; font-size: 0.8125rem; color: var(--aparte-accent); } [data-status='rejected'] em { color: var(--aparte-text-muted); } </style> The component is mounted once per call and updated in place, so tool.status moves from awaiting-approval to resolved or rejected under the user's eyes. The --aparte-* variables keep the card on the chat's colours, in light and dark.
7. Try it
npm run dev Ask "Remind me to book the dentist next Thursday". The model answers, calls add_task, and the question appears at the composer: which tool, which arguments, Approve or Reject. Approve, and the task lands in the list on the right before the model confirms. Reject, and the card says Not added and the list stays empty.
The user can also type an answer instead of clicking, such as "no, make it Friday", and press Enter. That refuses the call, and the words reach the model as the tool's result, so it can try again with Friday.
Where the tool runs
The loop runs in the page: the route makes one model call per step, and the page runs the tool between two calls. That suits tools that touch the page or call your API with the user's session. If your tools must run on the server (a database, a secret), the loop has to move there too: aparté's server loop does that, with approvals still asked in the page.
What this leaves out
- Auth. The route answers anyone who can reach it. Check the session first.
- Saving conversations. A reload starts over. aparté takes a storage adapter for that: Save conversations.
- Other models. Any
@ai-sdk/*provider works the same way: change themodel:line and the key.
The finished app is in apartejs/examples, the Svelte guide at apartejs.dev/frameworks/svelte, and the library at github.com/apartejs/aparte. If something is missing for your app, I'd like to know: an issue or a comment here both work.
I wrote this article with help from an AI assistant. I ran the code on the versions above with one change: a local Gemma model through @ai-sdk/openai-compatible in place of Claude, so the anthropic(...) line is the one part I did not run.
