Skip to content

AI chat in Web Components — getting started

@aparte/core is a set of framework-agnostic web components for AI chat. You drop <aparte-*> elements onto a page, stream tokens into them, and style everything with CSS variables — no framework, and zero third-party dependencies.

By the end of this page you’ll have a working chat that streams a reply, running entirely in the browser — any browser from 2023 on, or Node 18+ on the server side; the exact floors are in the support matrix.

Terminal window
npm install @aparte/core

Import the package once (it registers the <aparte-*> custom elements), pull in the stylesheet, and call registerDefaultRenderers().

import '@aparte/core'; // registers the <aparte-*> custom elements
import '@aparte/core/styles.css'; // theme variables + component styles
import { registerDefaultRenderers } from '@aparte/core';
registerDefaultRenderers(); // turns raw text/markdown into rendered bubbles

<aparte-chat> is the container. It lays out a message viewport and a composer as a flex column, and — with center-empty — keeps the composer centered as a welcome state until the first message, then slides to the normal layout. Size it (a height, or let it fill a parent) and you’re done.

Left empty, it fills in a default composition — a viewport, and a composer with an input and a send button. Add attachments for the file picker too (opt-in, see Attachments):

<aparte-chat center-empty placeholder="Say something…" style="height: 600px"></aparte-chat>

Need full control — a custom composer, extra buttons? Put your own primitives inside the same element. It still lays them out and runs center-empty, so you keep the behaviour without a hand-written container:

<aparte-chat center-empty style="height: 600px">
<aparte-chat-viewport></aparte-chat-viewport>
<aparte-composer>
<div class="aparte-composer-shell">
<div class="aparte-composer-row">
<aparte-composer-input placeholder="Say something…" style="flex:1"></aparte-composer-input>
<aparte-composer-send></aparte-composer-send>
</div>
</div>
</aparte-composer>
</aparte-chat>

center-empty is opt-in — drop it and the composer sits at the bottom from the start. The .aparte-composer-shell / .aparte-composer-row helpers give the bordered input-with-a-send-button look; the composer itself is headless.

Add an <aparte-composer-toolbar> inside that shell and you get the bottom strip a model selector or a mode picker belongs in — the wrappers expose it as one toolbar slot. See The composer toolbar.

The fastest way to see a reply stream is a model that needs no key and no network. @aparte/provider-scenario is a real AparteAIProvider that replays turns you wrote — text at a typing pace, thinking, tool calls, errors. Install it, register it, start the client:

Terminal window
npm install @aparte/provider-scenario
import { aparteGlobalConfig, AparteClient, AparteDirectTransport } from '@aparte/core';
import { createScenarioProvider } from '@aparte/provider-scenario';
import { showcase } from '@aparte/provider-scenario/showcase';
// `showcase` is a ready-made set of turns: a haiku, a table, some code, the weather
// (a real tool round-trip), an artifact.
aparteGlobalConfig.registerAIProvider(createScenarioProvider({ scenarios: showcase }));
aparteGlobalConfig.setTransport(new AparteDirectTransport({ byok: true }));
new AparteClient().start(); // .start() attaches the listeners — without it nothing streams
Width Open in a tab
A real reply, streaming, with no key and no backend: the scripted model. Ask it for a haiku, a table, some code or the weather.
<!-- The model here is scripted: no key, no network, the same replies every time.
Everything else is the real thing - the loop, the transcript, the tool call. -->
<aparte-chat center-empty placeholder="Ask for a haiku, a table, some code or the weather..." style="height: 22rem"></aparte-chat>

Swap createScenarioProvider for a provider that talks to a real model and the rest of this page is unchanged — that is Wire a real model, below.

If the reply comes from something aparté does not talk to — your own SSE endpoint, a worker, a websocket — drive the viewport by hand. The composer fires an aparte-send event when the user submits. It bubbles, so you can listen on <aparte-chat>, and chat.viewport is the message list. Three methods are all you need:

  • appendMessage({ id, role, content, timestamp }) — create a bubble
  • appendToken(id, chunk) — stream text into it, token by token
  • completeMessage(id) — mark it done (stops the streaming caret)
// `!` because the import above has already upgraded the element, so both lookups
// succeed — but TypeScript cannot know that, and neither can a reader who moves
// this code above the markup. If in doubt, use `?.` instead of asserting.
const chat = document.querySelector('aparte-chat')!;
const viewport = chat.viewport!;
let n = 0;
// Stream a string into a fresh assistant bubble, a few characters at a time.
function streamReply(text: string) {
const id = 'a' + ++n;
viewport.appendMessage({ id, role: 'assistant', content: '', timestamp: Date.now() });
const tokens = text.split(/(\s+)/); // keep the whitespace as its own tokens
let i = 0;
const timer = setInterval(() => {
if (i >= tokens.length) {
clearInterval(timer);
viewport.completeMessage(id);
return;
}
// `?? ''` because indexing an array yields `string | undefined` under
// `noUncheckedIndexedAccess`, which this project — and this snippet — compile
// with. The loop above already guarantees the index is in range; the compiler
// cannot see that, and neither can a reader who moves the code.
viewport.appendToken(id, tokens[i++] ?? '');
}, 40);
}
chat.addEventListener('aparte-send', (e) => {
const text = e.detail.content;
// 1. Echo the user's message into the conversation.
viewport.appendMessage({ id: 'u' + ++n, role: 'user', content: text, timestamp: Date.now() });
// 2. Reply. Here we fake it; next you'll wire a real model.
streamReply(`You said: “${text}”. Now wire a transport to get a real answer.`);
});

(Using the primitives instead? Same code — just document.querySelector('aparte-chat-viewport') for the viewport and listen on the composer.)

That is the shape Bring your own loop develops. The rest of this page assumes the client above.

Faking the reply is fine for a first look. To talk to a real LLM, you configure core with two things and let AparteClient drive the streaming loop for you:

  1. A provider — the wire-format adapter for your model (an opt-in @aparte/provider-* package), registered with aparteGlobalConfig.registerAIProvider(…).
  2. A transport — where the request goes and how the key is handled:
    • AparteDirectTransport — the browser talks to the provider directly (bring-your-own-key or a local model): aparteGlobalConfig.setTransport(new AparteDirectTransport({ byok: true })).
    • AparteBackendTransport — the browser calls your endpoint, and the key stays server-side.

Once a provider and transport are set, construct an AparteClient and call .start() — it then listens for aparte-send (and aparte-retry, aparte-edit) globally and streams the assistant’s typed segments into your bubbles for you (no manual appendToken):

new AparteClient().start(); // .start() attaches the listeners — without it nothing streams

The client owns the whole exchange: it echoes your user message too, files included. If you wrote the aparte-send handler from Streaming it yourself, drop it — the two would both answer the same send. A host that keeps its own transcript passes echoUserMessage: false.

Now that something can honour them, switch the retry and edit buttons on — core ships them off precisely because without a client they would do nothing:

aparteGlobalConfig.setBubbleActions({ retry: true, edit: true });

The full list of what ships enabled and why is in Customization.

Provider adapters ship as opt-in @aparte/provider-* packages — see the Providers section for the OpenAI-compatible adapter (OpenAI, Mistral, OpenRouter, Groq, LM Studio, Ollama…), the Vercel AI SDK bridge (Anthropic, Google, 25+ vendors), and the in-browser Transformers.js provider. You can also register any object implementing the AparteAIProvider interface yourself.

  • Theming — restyle everything through CSS variables, no forking.
  • Customization — icons, render hooks, action registries.
  • Conversations & branching — retry / edit, branches, persistence.
  • Layout and the app shell — the five shapes a chat takes, and the sidebar, header and grid of a whole page.
  • Providers — connect a real model: OpenAI-compatible, the AI SDK bridge, or in-browser Transformers.js.
  • Local models — the chat on a model running on your machine: Ollama, LM Studio, llama.cpp.
  • The agent engine — the headless runStreamAgent loop and the streamRunner seam.