Skip to content

Add an AI chat to a Svelte app

Install @aparte/svelte, render <AparteChat>, stream a model with createAparteClient, and put your own UI in its slots — on Svelte 4 or 5, SvelteKit included.

On this page
bash
npm install @aparte/svelte @aparte/core
npm install @aparte/provider-scenario   # a scripted model, to stream with no key

@aparte/core and svelte are peer dependencies: Svelte ^4.0.0 || ^5.0.0. The package ships its .svelte sources, which your build compiles, so one package serves both majors.

svelte
<script lang="ts">
  import { AparteChat, createAparteChat, type AparteChatImperativeApi } from '@aparte/svelte';
  import '@aparte/core/styles.css';

  const chat = createAparteChat();
  const { messages } = chat;
  let comp: AparteChatImperativeApi | null = null;
  $: chat.connect(comp);
</script>

<AparteChat bind:this={comp} messages={$messages} centerWhenEmpty onmessagesChange={(m) => chat.onMessagesChange(m)}>
  <p slot="empty-state">Ask me anything…</p>
</AparteChat>

createAparteChat holds the messages in a store and mirrors the chat’s imperative API; connect ties it to the component you bind. In a Svelte 5 component in runes mode, write the binding as let comp = $state<AparteChatImperativeApi | null>(null); and the connection as $effect(() => { chat.connect(comp); });. The person’s message is added when they send, so do not add it yourself; onmessageSent fires after, for a side effect.

ts
// main.ts — registered once, before the app mounts
import { aparteGlobalConfig } from '@aparte/core';
import { createScenarioProvider } from '@aparte/provider-scenario';
import { showcase } from '@aparte/provider-scenario/showcase';

aparteGlobalConfig.registerAIProvider(createScenarioProvider({ scenarios: showcase }));
// …then mount your App.svelte
svelte
<script lang="ts">
  import { AparteChat, createAparteChat, createAparteClient, type AparteChatImperativeApi } from '@aparte/svelte';

  const chat = createAparteChat();
  createAparteClient();
  const { messages } = chat;
  let comp: AparteChatImperativeApi | null = null;
  $: chat.connect(comp);
</script>

<AparteChat bind:this={comp} messages={$messages} onmessagesChange={(m) => chat.onMessagesChange(m)} />

createAparteClient starts a client when the component mounts, stops it when it is destroyed, and returns { client, abort }; it takes the AparteClientOptions. For a real model, register its provider, name the model and give the key — aparteGlobalConfig.setModelConfig({ defaultProvider: 'openrouter', defaultModel: 'openai/gpt-4o-mini' }) and createAparteClient({ keyResolver }) (Connect a model). A config prop gives one <AparteChat> providers and a transport of its own; give its client the same one, createAparteClient({ config }). With the client running, turn on retry and edit with aparteGlobalConfig.setBubbleActions({ retry: true, edit: true }) (Buttons).

Each callback — onmessageSent, onaction, onmessagesChange, onmessageAppended, ontypingChange, onconversationCreated — is a prop that gets the payload itself, and an event as well: on:messagesChange={(e) => chat.onMessagesChange(e.detail)}, the payload under event.detail. Both fire for the same occurrence, the callback first; write the callbacks on Svelte 5.

svelte
<AparteChat bind:this={comp} messages={$messages} onmessagesChange={(m) => chat.onMessagesChange(m)}>
  <p slot="empty-state">Ask me anything…</p>
  <svelte:fragment slot="toolbar"><span class="hint">Shift + Enter for a new line</span></svelte:fragment>
  <svelte:fragment slot="bubble" let:message><MyBubble {message} /></svelte:fragment>
</AparteChat>
SlotWhat it fills
empty-statethe chat before the first message
above-composerthe space above the composer
toolbarthe composer’s bottom row, not drawn when you fill composer; <svelte:fragment slot="toolbar"> for several nodes (The composer toolbar)
composerthe composer, replaced by yours
bubble, with let:messageeach message, drawn by you (Bubble)
svelte
<AparteChat id="support" class="flex-1 min-h-0" loading={isFetchingHistory} attachments />

The ones you reach for: placeholder, disabled and submitOnEnter; centerWhenEmpty; loading, for a wait of your own; isTyping and typingText, a waiting line you drive yourself; overlayComposer; attachments, off by default (Attachments); elicitation={false} when you draw the questions yourself; conversationId, config and layoutTransitionMs, how long the list holds its layout after a conversation swap. The root takes class and a style string. id is the chat’s id, which every event carries: for two chats on one page, each answered by its own client, give each an id and its client scopeToTargetId with it (Wrapper surface).

The chat store mirrors the chat’s imperative API — appendMessage, addSegment, injectTokenStream, scrollToBottom, focusInput and the rest — to drive the chat from a loop of yours (Your own loop). createConversationManager() lists, creates, archives and pins saved conversations in stores once you call init(adapter) with your storage adapter — and the chat’s config as second argument, if it has one — and the chat switches between them through conversationId (Save conversations).

svelte
<aparte-select searchable="" placeholder="Pick a model" on:aparte-select-change={(e) => (model = e.detail.value)}>
  <aparte-option value="gpt-4o-mini">GPT-4o mini</aparte-option>
</aparte-select>

Importing from @aparte/svelte declares every element of core in SvelteHTMLElements, so svelte-check covers its attributes and its on: handlers: a presence attribute takes '' to be on and null to be off (Elements, typed). In Svelte 5’s runes mode, write the event as an attribute, typed the same way: onaparte-select-change={(e) => (model = e.detail.value)}. A plugin’s element is typed by the plugin, and one of yours by you (your own element). <AparteUi> mounts any tag and sets its props; it forwards core’s events, so name your element’s own in events:

svelte
<AparteUi bind:this={ui} name="my-token-counter" props={{ 'data-budget': '8000' }} events={['token-count']} onelementEvent={(e) => (count = e.detail)} />

onelementEvent gets the element’s own event, and on:elementEvent fires too, with it under event.detail. Bound, it gives ui.getElement() and ui.callMethod(name, ...args).

The wrapper has no server guard and is not tested under a server render: under SvelteKit, render the chat on the client only — export const ssr = false in the route’s +page.ts, or a component imported in onMount. @aparte/core itself imports on a server through its entry without elements (On the server). Vitest runs on Node, so alias @aparte/core to @aparte/core/browser for the elements to upgrade (Testing your components).

A whole chat site in Svelte — the conversation sidebar, the header, settings, tools and the scripted model — is the Svelte 5 example, and the Svelte 4 one.