Installation and first chat
Install @aparte/core, put an AI chat on a page and stream a reply — first from a scripted model that needs no key, then from a real one.
Install
Section titled “Install”npm install @aparte/core @aparte/provider-scenario@aparte/provider-scenario is a scripted model for the first reply; drop it once a real model
answers. In TypeScript, set moduleResolution to bundler, node16 or nodenext: those read
the package’s exports map. No build step at all? See From a CDN.
Register the components
Section titled “Register the components”import '@aparte/core';
import '@aparte/core/styles.css';The first import defines the <aparte-*> elements, the second brings their styles. When your
page’s markup is static HTML, import the styles from a stylesheet the <head> links instead —
@import '@aparte/core/styles.css'; — so the page never paints without them.
Add the markup
Section titled “Add the markup”<aparte-chat placeholder="Ask anything" style="height: 32rem"></aparte-chat>Left empty, <aparte-chat> builds the default chat: a message list, and a
composer with an input and a send button. Give it a height, or let it fill its parent.
center-empty holds the composer in the middle until the first message, and attachments adds
a file picker (Attachments).
To choose the parts yourself, write them inside it:
<aparte-chat style="height: 32rem">
<aparte-chat-viewport></aparte-chat-viewport>
<aparte-composer placeholder="Ask anything">
<div class="aparte-composer-shell">
<div class="aparte-composer-row">
<aparte-composer-input></aparte-composer-input>
<aparte-composer-send></aparte-composer-send>
</div>
</div>
</aparte-composer>
</aparte-chat>Keep an <aparte-chat-viewport> somewhere inside: a chat that holds none replaces its content
with the default one. The Composer page has the other parts — a
toolbar, attachments, a stop button.
Stream a reply
Section titled “Stream a reply”import { aparteGlobalConfig, AparteClient } from '@aparte/core';
import { createScenarioProvider } from '@aparte/provider-scenario';
import { showcase } from '@aparte/provider-scenario/showcase';
aparteGlobalConfig.registerAIProvider(createScenarioProvider({ scenarios: showcase }));
new AparteClient().start();A provider talks to a model. This one replays turns written in advance, with no key and no
network, and since it offers one model, that model is selected. The client answers the
composer: it sends the conversation to the model and streams the reply into the chat. Nothing
streams until start() is called.
Render Markdown
Section titled “Render Markdown”npm install @aparte/plugin-marked @aparte/plugin-streaming-markdownimport { setupMarkedProvider } from '@aparte/plugin-marked';
import { setupStreamingMarkdownProvider } from '@aparte/plugin-streaming-markdown';
setupMarkedProvider();
setupStreamingMarkdownProvider();<!-- The model here is scripted: no key, no network, the same replies every time.
Everything else is the real thing - the client, the loop, the transcript. -->
<aparte-chat center-empty placeholder="Ask for a haiku, a table or some code..." style="height: 22rem"></aparte-chat>Models answer in Markdown, and core shows it as plain text until a renderer is set: the first plugin formats a finished message, the second formats it while it streams. Set them before the chat renders. Shiki colours the code blocks.
Wire a real model
Section titled “Wire a real model”Replace the scripted lines with these:
import { aparteGlobalConfig, AparteClient, AparteDirectTransport } from '@aparte/core';
import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat';
aparteGlobalConfig.registerAIProvider(createOpenAICompatProvider(presets.OPENROUTER));
aparteGlobalConfig.setModelConfig({ defaultProvider: 'openrouter', defaultModel: 'openai/gpt-4o-mini' });
aparteGlobalConfig.setTransport(new AparteDirectTransport({ byok: true }));
new AparteClient({ keyResolver: () => localStorage.getItem('openrouter-key') }).start();Three things change from the scripted chat:
- The provider.
@aparte/provider-openai-compattalks to any OpenAI-compatible API, with presets for OpenAI, Mistral, OpenRouter, Z.ai, LM Studio and Ollama. Models and agents has the others: the Vercel AI SDK, a model running in the browser, an agent over AG-UI. - The model. A provider that lists its models over the network selects none, and a message
sent with no model selected goes nowhere: name one with
setModelConfig, or let the person pick with the model selector. - The key.
keyResolversays where the key comes from — here, one the person saved from a field of yours, withlocalStorage.setItem('openrouter-key', key).byok: truesays the browser sends it on purpose: the person’s own key, or none for a local model. To keep a key on your server, useAparteBackendTransport.
For a model on your own machine — Ollama, LM Studio, llama.cpp — see Local models.
Turn on retry and edit
Section titled “Turn on retry and edit”aparteGlobalConfig.setBubbleActions({ retry: true, edit: true });A message shows a copy button by default. Retry and edit need a client to send the conversation again, so they are yours to turn on; Conversations and branches shows the versions of a reply that retry keeps.
From a CDN
Section titled “From a CDN”<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@aparte/core@0.24.0/dist/index.css">
<aparte-chat style="height: 32rem"></aparte-chat>
<script type="module">
import { aparteGlobalConfig, AparteClient }
from 'https://cdn.jsdelivr.net/npm/@aparte/core@0.24.0/+esm';
import { createScenarioProvider }
from 'https://cdn.jsdelivr.net/npm/@aparte/provider-scenario@0.24.0/+esm';
aparteGlobalConfig.registerAIProvider(createScenarioProvider({ turns: ['Hello from a page with no build step.'] }));
new AparteClient().start();
</script>One HTML file, no npm: jsDelivr serves each package as one module, and the chat answers with
nothing else installed. A real provider and the Markdown plugins load the same way, from their
own URLs. Give every @aparte/* URL the same version, so the page loads a single copy of core.
Stream from your own code
Section titled “Stream from your own code”const chat = document.querySelector('aparte-chat');
let n = 0;
chat?.addEventListener('aparte-send', async (event) => {
const viewport = chat?.viewport;
if (!viewport) return;
viewport.appendMessage({ id: `u${++n}`, role: 'user', content: event.detail.content, timestamp: Date.now() });
const id = `a${++n}`;
viewport.appendMessage({ id, role: 'assistant', content: '', timestamp: Date.now() });
for await (const token of yourStream(event.detail.content)) viewport.appendToken(id, token);
viewport.completeMessage(id);
});When the reply comes from something aparté does not talk to — your endpoint, a worker, a
websocket — leave the client out and drive the chat yourself: aparte-send fires when the person
sends, you add their message, and the viewport takes the reply token by token.
Use your own loop goes further: reasoning, tool calls, stopping.
- Theme Colours, shapes and fonts, through CSS variables.
- How it fits The pieces between your page and your model, and the package that holds each.
- Tools and approval Let the model call your functions, with the person approving what matters.
- Troubleshooting When nothing streams, or the console warns about a key.