Skip to content

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.

On this page
bash
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.

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

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

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

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

bash
npm install @aparte/plugin-marked @aparte/plugin-streaming-markdown
ts
import { setupMarkedProvider } from '@aparte/plugin-marked';
import { setupStreamingMarkdownProvider } from '@aparte/plugin-streaming-markdown';

setupMarkedProvider();
setupStreamingMarkdownProvider();
Open
The chat these steps build, with Shiki colouring its code. Ask it for a haiku, a table or some code.

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.

Replace the scripted lines with these:

ts
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-compat talks 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. keyResolver says where the key comes from — here, one the person saved from a field of yours, with localStorage.setItem('openrouter-key', key). byok: true says the browser sends it on purpose: the person’s own key, or none for a local model. To keep a key on your server, use AparteBackendTransport.

For a model on your own machine — Ollama, LM Studio, llama.cpp — see Local models.

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

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

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