Getting started, frameworks and guides: Install, first render in vanilla or a framework, and every guide (theming, tools, persistence, branching, localisation, accessibility, troubleshooting). # Best AI Chat UI Libraries in 2026, Compared > A fair, dated comparison of AI chat UI libraries — Loquix, assistant-ui, AI Elements, deep-chat, kitn, ChatKit — on lock-in, runtime, local models and theming. If you are choosing a chat UI library for an LLM product, the libraries below are the ones you will shortlist. This page says what each is good at and where aparté differs — including the cases where you should pick the other one. Claims about other projects were checked against their public docs and npm packages in **October 2026**, the month the weights below were measured; they move fast, so verify before deciding. The short version: **aparté is the one that is a complete runtime *and* framework-agnostic *and* free of third-party dependencies.** The React kits have the larger ecosystems. The other web-component kits are presentational — you bring the loop. If you need neither framework freedom nor a runtime, you have better options than aparté, and they are named below. ## What a working chat weighs [Section titled “What a working chat weighs”](#what-a-working-chat-weighs) Every library below set up the same way: the chat its docs’ quickstart builds, streaming a model’s reply, bundled and gzipped. The bar is what a page downloads before the chat can answer; the line under it says what that UI holds, and the method under the chart is measured by a script anyone can run again. | Library | At startup | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Loquix 0.6.0 | 27 KB 26 KB + 1 KB CSS Presentational components: the six its Quick Start imports and the token sheet, plus about 50 lines of ours to stream a reply, since it ships no model client; replies are plain text. The full import its README shows: 82 KB + 1 KB CSS. | | aparté 0.19.2 | 105 KB 86 KB + 20 KB CSS The chat with Markdown while it streams, its own client for any OpenAI-compatible API, and the agent loop with tools and approvals. | | Deep Chat 2.5.1 | 120 KB One element with its own clients for 24 AI services (OpenAI, Claude, Gemini, Ollama…), Markdown and tool calls; its styles are inside its JavaScript. The CDN script its docs also list, served as it ships: 105 KB. | | assistant-ui 0.15.29 | 444 KB 377 KB + 67 KB React The AI SDK runtime and the Thread its docs copy into your app: Markdown, tool calls, attachments; the model is called from your server, and its CSS is your Tailwind build’s output. Its LocalRuntime with an adapter you write, in place of the AI SDK: 299 KB + 67 KB React. | | CopilotKit 1.79.1 | 768 KB 682 KB + 18 KB CSS + 67 KB React Its documented React chat: Markdown, math, code highlighting, tool calls and generative UI; its runtime runs on a server and is not counted. | the library’s JavaScript its CSS React, which it needs Measured on 16 October 2026 with esbuild 0.27.7: minified, code splitting on, what loads at startup, gzip level 9; every library at its latest version published before 17 October 2026. Loquix and Deep Chat keep their component styles inside their JavaScript; assistant-ui's CSS comes out of your own Tailwind build and is not counted. CopilotKit's build also splits off 2.9 MB of optional chunks (a grammar per highlighted language, diagrams, a developer inspector), fetched only when a reply needs one. A server is never counted. [The script that measures it ↗](https://github.com/apartejs/aparte/blob/main/scripts/measure-bundles.mjs) ## The matrix [Section titled “The matrix”](#the-matrix) | Criterion | aparté | assistant-ui | AI Elements | deep-chat | Loquix | kitn | | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | --------------------------------------- | ----------------------------------------------------- | ------------------------------- | ----------------------------------- | --------------------------------------------------- | | Frameworks | Web Components + React, Vue, Svelte, Angular wrappers | React (Vue bindings in early preview) | React | Web Component (+ React wrapper) | Web Components (Lit) | Web Components (+ React wrappers), built in SolidJS | | Runtime included (streaming, agent loop, tool calls) | **Yes** — [core](/why/) + [headless engine](/guides/engine/) | Runtime adapters (AI SDK, LangGraph, …) | Via the Vercel AI SDK | Built in, config-driven | No — presentational | No — reads a stream you open (OpenAI, Anthropic) | | Weight of a working chat (JavaScript and CSS, gzipped) | **105 KB** | 444 KB, React included | not measured: its components are copied into your app | 120 KB | 27 KB, no model client | not measured | | Framework you must adopt | **None** — and no third-party dependency in `@aparte/core` | React | React + the AI SDK | None | None (Lit is bundled) | None | | Human-in-the-loop: tool approval, typed questions | **Approval at the composer, [typed elicitation panel](/guides/elicitation/)** | Generic tool UI | Confirmation component | — | Rendered, not run | Rendered, not run | | Edit / retry / branch a conversation | **[Yes, with a version picker](/guides/conversations-branching/)** | Yes | Partial | — | Partial | — | | Persistence | [A storage interface you implement](/guides/conversation-persistence/) | A hosted cloud service (paid) | Your own | — | — | — | | Theming | [CSS variables, light DOM](/guides/theming/), no fork | Tailwind / shadcn — fork the markup | shadcn — the code lives in your app | Config object | CSS variables | CSS variables (Shadow DOM) | | Run a model in the browser | Yes — the [transformers provider](/providers/ai/transformers/) | — | — | Yes (Web LLM) | — | — | | Scripted demo/test provider | **Yes — [the scenario provider](/providers/ai/scenario/)** (deterministic turns: thinking, tools, artifacts) | — | — | Demo mode (canned) | — | — | | Markdown, code, reasoning | Yes ([opt-in plugins](/guides/how-it-fits/#which-package-does-what)) | Yes | Yes | Yes | Yes | Yes | | Mermaid, KaTeX | — | Yes | Yes | — | — | — | | Voice | — | Yes (realtime) | Yes | Yes | — | — | | Citations / sources | — | Partial | Yes | — | Yes | — | | Floating / modal chat | — | Yes | — | Yes (embed) | Partial | — | | Best fit for | Multi-framework teams that want the runtime included | React teams, all-in on React | Apps already on the Vercel AI SDK | A config-driven embed, fast | A polished presentational kit (Lit) | A minimal presentational kit | | Licence | MIT | MIT | Apache-2.0 | MIT | MIT | MIT | The rows read left to right as *what aparté is for*: the top half is the runtime, the bottom half is the catalogue. aparté wins the top half and loses the bottom half, and both halves are true. ## assistant-ui [Section titled “assistant-ui”](#assistant-ui) **The React reference**, with Vue bindings in early preview since October 2026 (`@assistant-ui/vue`, 0.0.x). A composable primitives kit (thread, composer, message, action bar, branch picker, chain of thought, attachments) with the widest set of runtime integrations in the category — the Vercel AI SDK, LangGraph, LangChain, Mastra, AG-UI, A2A and more — plus realtime voice, Mermaid and KaTeX rendering, and a hosted cloud for thread persistence. **Pick assistant-ui when** you are on React for good, want its idioms end to end, and want to plug into one of those runtimes rather than run a loop of your own. Its ecosystem is larger than aparté’s and will stay so. **Pick aparté when** the same chat has to ship in more than one framework (or none), when you do not want a UI kit to dictate your styling system, or when you want a runtime in the library rather than an adapter to someone else’s. Theming is the other line: assistant-ui is Tailwind/shadcn, so a visual change is a fork of markup; aparté is CSS variables on light DOM, so it is a stylesheet. ## Vercel AI Elements [Section titled “Vercel AI Elements”](#vercel-ai-elements) **The largest catalogue.** Fifty-odd shadcn components — chatbot, code (artifact, file tree, terminal, sandbox, web preview), voice and workflow-graph families — installed by CLI, so the source lands in your app. Requires React and the Vercel AI SDK. **Pick AI Elements when** you build on Next.js and the AI SDK and want a finished part for every screen an agent product has. Nobody else has that breadth. **Pick aparté when** you are not on React, or when you want the parts to update as a dependency rather than as copied source. One thing to know: AI Elements’ file tree, terminal and test results are **app-fed views**, not message types — the same conclusion aparté reached when it removed those segment kinds. A tool result rendered richly — generative UI, in the term the field settled on — is the seam both libraries converge on. ## deep-chat [Section titled “deep-chat”](#deep-chat) **The fastest time-to-chat.** One `` element, configured by attributes and objects, connects directly to twenty-odd vendor APIs, does files, camera, microphone, speech in and out, and can run a model in the browser. **Pick deep-chat when** you want one tag and a config object and you are done — it is the best of the category at that, and the only other one that runs a model client-side. **Pick aparté when** you need composable parts rather than one element (aparté is a viewport, a composer, bubbles and a status line you place yourself), token-level theming, an agent loop with [tool approval](/guides/tools/), or the same chat in four frameworks with [typed wrappers](/frameworks/). deep-chat’s customisation is a large config surface; aparté’s is CSS and render hooks. ## Loquix [Section titled “Loquix”](#loquix) **A presentational web-component kit.** Fifty-odd Lit components, in Shadow DOM, across reasoning, trust, search and template categories, with accessibility as its headline. By design it ships no provider, no loop and no transport: it is a type-only interface you feed. **Pick Loquix when** you already have a chat runtime you like and want a broad, accessible set of views to render its output. **Pick aparté when** you want the runtime too. aparté’s core has no third-party dependency (Loquix carries Lit), and its [engine](/guides/engine/) runs the agent loop, the tool calls, the approval gate and the conversation compactor — the parts a presentational kit leaves to you. Loquix’s catalogue is about as large as aparté’s, with search and parameter panels aparté does not draw; its roadmap points at provider integrations, which is the ground aparté already covers. ## kitn [Section titled “kitn”](#kitn) **The presentational twin.** A set of web components written in SolidJS — a thread, a prompt input, a conversation list, layouts that compose them — with typed React wrappers, rendering tool calls, reasoning traces and typed cards (confirmations, choices, forms). It reads an OpenAI or Anthropic stream your app opened, and ships no HTTP client and no loop: “the kit parses, your app fetches”. Theming is a token set (`--kai-color-*`), and its MCP server can brand it from a colour. **Pick kitn when** you want a drop-in view over a loop you run elsewhere, and a theme in five minutes. **Pick aparté when** the typed cards have to be *honoured*, not just drawn: aparté’s [elicitation](/guides/elicitation/) pauses the run, collects the answer and resumes it, and the approval step gates a real tool call. kitn draws the same vocabulary without the runtime under it. ## OpenAI ChatKit [Section titled “OpenAI ChatKit”](#openai-chatkit) **The platform drop-in.** A web component (with a React wrapper) served by the OpenAI platform through a client token — streaming, tools, attachments and chain-of-thought visualisation without writing the front end. Self-hosting goes through their Python SDK. **Pick ChatKit when** you build on the OpenAI platform and want their hosted chat experience with the least code. **Pick aparté when** the model is not OpenAI’s, when the key must stay yours (browser-direct BYOK or [your own endpoint](/guides/backend-transport/)), when a model runs locally, or when you need theming beyond what a hosted surface exposes. The lock-in there is the vendor; aparté’s [transport](/why/) is the seam that keeps it out. ## Also in the field [Section titled “Also in the field”](#also-in-the-field) * **CopilotKit** — a category above: app-state sync with agents, generative UI, LangGraph co-agents, with chat components of its own in React (CopilotChat, CopilotSidebar, CopilotPopup) and now in Vue and Angular. If that framework is what you need, aparté is not it; aparté can still drive an agent that speaks its [AG-UI protocol](/providers/agent/ag-ui/). * **NLUX** — React and vanilla, under the MPL-2.0 licence; no release since August 2024. * **TanStack AI** — a type-safe SDK that runs the agent loop on your server, with provider adapters, MCP, durable streams and sandboxes, bindings for React, Vue, Svelte, Angular and Solid, and headless chat components you style yourself. Closer to aparté’s engine than to its components — and aparté can [draw a TanStack AI route](/providers/agent/tanstack-ai/) as its UI. * **Chainlit** — Python full-stack; a different stack entirely. ## How to read this page [Section titled “How to read this page”](#how-to-read-this-page) aparté is in beta (`0.x`, every package released in lockstep, its public surface [frozen](/reference/stability/)). The comparison is written against that version; the catalogue rows will change as components land, and the runtime rows are the ones that define the project. If a claim above is wrong or out of date, [open an issue](https://github.com/apartejs/aparte/issues) — a comparison that flatters the author is worth nothing to the reader. # Use aparté in React, Vue, Svelte or Angular > Pick the package for your framework — a chat component, typed tags for every element, and a way to mount any custom element — over the same web components. ## Pick the package [Section titled “Pick the package”](#pick-the-package) | Framework | Package | Versions | The chat | Its state | The client | | ------------------------------- | ----------------- | ---------------------- | --------------------- | ----------------------------- | ------------------------------------ | | [React](/frameworks/react/) | `@aparte/react` | `^18.0.0 \|\| ^19.0.0` | `` | `useAparteChat()` | `useAparteClient()` | | [Vue](/frameworks/vue/) | `@aparte/vue` | `^3.5.0` | `` | `useAparteChat()` | `useAparteClient()` | | [Svelte](/frameworks/svelte/) | `@aparte/svelte` | `^4.0.0 \|\| ^5.0.0` | `` | `createAparteChat()` | `createAparteClient()` | | [Angular](/frameworks/angular/) | `@aparte/angular` | `^19.2.0` | `AparteChatComponent` | the component, by `viewChild` | `provideAparte()`, `AparteAiService` | `@aparte/core` is web components, so it works in any framework as it is; a wrapper adds the framework’s idiom — refs, events and lifecycle wired for you. Each one gives you: * **a chat component**, the whole chat with its slots and callbacks; * **typed tags for every element of core**, real attributes and real events checked by your compiler, to place a select, a conversation list or a composer of your own ([Elements, typed](/frameworks/elements/)); a plugin’s element, like the model selector, is typed by the plugin; * **`AparteUi`** — `AparteUiComponent` in Angular — which mounts any custom element as a component, for an element of yours or a third party’s. A wrapper depends on `@aparte/core` alone, never on a provider: register the one you want ([Connect a model](/providers/)). ## The same in all four [Section titled “The same in all four”](#the-same-in-all-four) The same slots — `empty-state`, `composer`, `above-composer`, `toolbar` and a bubble of your own — and the same callbacks — `messageSent`, `action`, `messagesChange`, `messageAppended`, `typingChange` and `conversationCreated` — each in its framework’s idiom. [Wrapper surface](/reference/wrappers/) has the four syntaxes side by side, generated from the wrappers’ source. Without a framework, the elements are the API: [Installation and first chat](/guides/getting-started/). ## Next [Section titled “Next”](#next) * [React A streaming chat in a React or Next app.](/frameworks/react/) * [Vue A streaming chat in a Vue or Nuxt app.](/frameworks/vue/) * [Svelte A streaming chat in a Svelte or SvelteKit app.](/frameworks/svelte/) * [Angular A streaming chat in a standalone Angular app.](/frameworks/angular/) # Add an AI chat to an Angular app > Install @aparte/angular, render the standalone chat component, configure and stream a model with provideAparte at bootstrap, and put your own UI in its slots. ## Install [Section titled “Install”](#install) ```bash npm install @aparte/angular @aparte/core npm install @aparte/provider-scenario # a scripted model, to stream with no key ``` ```css /* src/styles.css — the global stylesheet angular.json lists under "styles" */ @import '@aparte/core/styles.css'; ``` `@aparte/core`, `@angular/core`, `@angular/common` and `rxjs` are peer dependencies: Angular `^19.2.0` and RxJS `^7.8.0`. ## Render the chat [Section titled “Render the chat”](#render-the-chat) ```ts import { Component } from '@angular/core'; import { AparteChatComponent, type AparteMessage } from '@aparte/angular'; @Component({ selector: 'app-chat', standalone: true, imports: [AparteChatComponent], template: `

Ask me anything…

`, }) export class ChatComponent { messages: AparteMessage[] = []; } ``` `AparteChatComponent` is standalone: import it where you use it, with no NgModule. The chat keeps its thread and reports it through `(messagesChange)`. The person’s message is added when they send, so do not add it again in `(messageSent)`, which fires after, for a side effect. ## Stream a model [Section titled “Stream a model”](#stream-a-model) ```ts // main.ts import { bootstrapApplication } from '@angular/platform-browser'; import { provideAparte } from '@aparte/angular'; import { createScenarioProvider } from '@aparte/provider-scenario'; import { showcase } from '@aparte/provider-scenario/showcase'; import { App } from './app'; bootstrapApplication(App, { providers: [provideAparte({ providers: [createScenarioProvider({ scenarios: showcase })] })], }); ``` `provideAparte` registers the providers at bootstrap and starts the client — `autoConnect`, on by default — so a send streams with no wiring in your components. With `autoConnect: false`, the client is yours to start: ```ts import { Component, inject, type OnDestroy, type OnInit } from '@angular/core'; import { AparteAiService } from '@aparte/angular'; @Component({ selector: 'app-shell', standalone: true, template: '' }) export class ShellComponent implements OnInit, OnDestroy { private ai = inject(AparteAiService); ngOnInit() { this.ai.connect(); } // safe to call twice ngOnDestroy() { this.ai.disconnect(); } } ``` `AparteAiService` also gives you `client`, the running `AparteClient`, and `abort()`, which stops the reply. With the client running, turn on retry and edit with `aparteGlobalConfig.setBubbleActions({ retry: true, edit: true })` ([Buttons](/guides/customization/actions/)). ## Configure it at bootstrap [Section titled “Configure it at bootstrap”](#configure-it-at-bootstrap) ```bash npm install @aparte/provider-openai-compat @aparte/plugin-marked @aparte/plugin-model-selector @aparte/locale-fr ``` ```ts import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat'; import { provideAparte } from '@aparte/angular'; import { fr } from '@aparte/locale-fr'; export const aparte = provideAparte({ providers: [createOpenAICompatProvider(presets.OPENROUTER)], plugins: { actions: [async () => { await import('@aparte/plugin-model-selector'); }], markdown: () => import('@aparte/plugin-marked').then((m) => m.setupMarkedProvider()), }, modelConfig: { defaultProvider: 'openrouter', defaultModel: 'openai/gpt-4o-mini' }, locale: fr, themeMode: 'auto', clientOptions: { keyResolver: () => localStorage.getItem('openrouter.key') ?? undefined }, }); ``` Pass `aparte` to `bootstrapApplication(App, { providers: [aparte] })`. | Option | What it takes | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | | `providers` | the providers to register | | `plugins.actions` | loaders, `() => void \| Promise`, awaited before the client starts: a dynamic `import()` gives the plugin a chunk of its own | | `plugins.icons` | an icon provider, or a loader | | `plugins.markdown` | a Markdown provider `(raw) => html`, which declares its `raw` parameter, or a loader | | `plugins.theme` | a loader | | `modelConfig` | the model to send to, as `setModelConfig` takes it — a provider that lists its models over the network selects none | | `locale` | an `AparteLocale` object, such as `fr` from `@aparte/locale-fr` | | `themeMode` | `'light'`, `'dark'` or `'auto'`, which follows the system | | `clientOptions` | the `AparteClientOptions` — a `keyResolver` gives a cloud provider its key | | `autoConnect` | whether the client starts at bootstrap: `true` by default | A loader that fails is reported in the console and skipped. A loader defines its plugin’s element, and you place it: ``, with `AparteModelSelectorDirective` from `@aparte/plugin-model-selector/angular` in the component’s `imports`. The options are typed as `ProvideAparteOptions` and `ApartePluginLoader`, and injectable as `APARTE_CONFIG_TOKEN`, the client’s own as `APARTE_CLIENT_OPTIONS`. `provideAparte` is optional: the same calls on `aparteGlobalConfig` configure the same things, and `AparteAiService.connect()` then starts the client. ## Put your own UI in it [Section titled “Put your own UI in it”](#put-your-own-ui-in-it) ```html

Ask me anything…

Shift + Enter for a new line
{{ message.content }}
``` | `slot` | What it fills | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `empty-state` | the chat before the first message | | `above-composer` | the space above the composer | | `toolbar` | the composer’s bottom row, not drawn when you fill `composer` ([The composer toolbar](/guides/customization/composer-toolbar/)) | | `composer` | the composer, replaced by yours | `[bubbleTemplate]` draws each message from a template of yours, the message as its `$implicit` ([Bubble](/guides/customization/bubble/#your-own-element-in-a-framework)). Content in no slot is not drawn. ## Inputs and outputs [Section titled “Inputs and outputs”](#inputs-and-outputs) ```html ``` 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](/guides/attachments/)); `[elicitation]="false"` when you draw the questions yourself; `conversationId`, `config` and `layoutTransitionMs`, how long the list holds its layout after a conversation swap. An `id` on the tag 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 `clientOptions.scopeToTargetId`. `class` and `style` on the tag reach the component’s host, the sized box (`display: block; height: 100%`); `[containerClass]` and `[containerStyle]`, a string or a map, reach the chat’s container inside it. The outputs are `messageSent`, `messagesChange`, `messageAppended`, `action`, `typingChange` and `conversationCreated` ([Wrapper surface](/reference/wrappers/#callbacks)). ## Control it from code [Section titled “Control it from code”](#control-it-from-code) ```ts import { Component, viewChild } from '@angular/core'; import { AparteChatComponent } from '@aparte/angular'; @Component({ selector: 'app-agent-chat', standalone: true, imports: [AparteChatComponent], template: `` }) export class AgentChatComponent { private readonly chat = viewChild.required(AparteChatComponent); reply(id: string, tokens: AsyncIterable) { this.chat().appendMessage({ id, role: 'assistant', content: '', timestamp: Date.now() }); void this.chat().injectTokenStream(id, tokens); } } ``` The component carries the chat’s imperative API — `appendMessage`, `addSegment`, `injectTokenStream`, `scrollToBottom`, `focusInput` and the rest — once its view is initialised, to drive the chat from a loop of yours ([Your own loop](/guides/bring-your-own-loop/)); `injectTokenStream` also takes an RxJS `Observable`. `ConversationManagerService` lists, creates, archives and pins saved conversations as signals once `init(adapter)` has run, and the chat switches between them through `[conversationId]` ([Save conversations](/guides/conversation-persistence/)). ## Any element, typed [Section titled “Any element, typed”](#any-element-typed) ```ts import { Component } from '@angular/core'; import { AparteOptionDirective, AparteSelectDirective } from '@aparte/angular'; @Component({ selector: 'app-picker', standalone: true, imports: [AparteSelectDirective, AparteOptionDirective], template: ` Small Large `, }) export class PickerComponent { use(value: string) { console.info(value); } } ``` Each element of core has a standalone directive whose selector is its tag — typed inputs, one output per event — so the template is checked without `CUSTOM_ELEMENTS_SCHEMA`; import the ones you use, or `APARTE_ELEMENT_DIRECTIVES` for all of them ([Elements, typed](/frameworks/elements/)). A plugin’s element is typed by the plugin, and one of yours by you ([your own element](/frameworks/elements/#your-own-element-or-a-plugins)). ``, the `AparteUiComponent` with its inputs typed as `AparteUiProps`, mounts any tag and sets its props; it forwards core’s events, so name your element’s own in `[events]`: ```html ``` ## Server rendering and tests [Section titled “Server rendering and tests”](#server-rendering-and-tests) `provideAparte` starts the client only in a browser. Render the chat itself on the client, in a `@defer` block — `@defer { }` — so the server draws only its placeholder; `@aparte/core` imports on a server through its entry without elements ([On the server](/frameworks/elements/#on-the-server)). A test runner that runs on Node — Vitest, Jest — takes that entry too, so alias `@aparte/core` to `@aparte/core/browser` for the elements to upgrade ([Testing your components](/frameworks/elements/#testing-your-components)); Karma runs in a browser and needs nothing. A whole chat site in Angular — the conversation sidebar, the header, settings, tools and the scripted model — is [the Angular example](https://github.com/apartejs/aparte/tree/main/apps/examples/angular). ## Next [Section titled “Next”](#next) * [Elements, typed Every element as a standalone directive, and one of your own.](/frameworks/elements/) * [Wrapper surface Every slot and output, beside the other three frameworks.](/reference/wrappers/) # Place any aparté element, typed, in your framework > Put a single element — a select, the conversation list, a composer of your own — in React, Vue, Svelte or Angular, its attributes and events checked by your compiler, and type an element of your own the same way. ## Place an element [Section titled “Place an element”](#place-an-element) * React ```tsx GPT-4o mini // events are read by ref, typed through the DOM useEffect(() => { const el = select.current; const onChange = (e: HTMLElementEventMap['aparte-select-change']) => pick(e.detail.value); el?.addEventListener('aparte-select-change', onChange); return () => el?.removeEventListener('aparte-select-change', onChange); }, []); ``` * Vue ```vue GPT-4o mini ``` Tell Vue’s compiler these tags are custom elements: `vue({ template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith('aparte-') } } })` in `vite.config.ts`, or `vue.compilerOptions.isCustomElement` in `nuxt.config.ts`. * Svelte ```svelte pick(e.detail.value)}> GPT-4o mini ``` In Svelte 5’s runes mode, write the event as an attribute and type it yourself: `onaparte-select-change={(e: CustomEvent) => pick(e.detail.value)}`. * Angular ```ts import { Component } from '@angular/core'; import { APARTE_ELEMENT_DIRECTIVES } from '@aparte/angular'; @Component({ selector: 'app-picker', standalone: true, imports: [...APARTE_ELEMENT_DIRECTIVES], template: ` GPT-4o mini `, }) export class PickerComponent { pick(value: string) { console.info(value); } } ``` Importing a wrapper types every element of core in that framework: JSX intrinsics in React, `GlobalComponents` in Vue, `SvelteHTMLElements` in Svelte. The attribute names are the HTML ones and a wrong value is a compile error; the events are typed through the DOM, since `@aparte/core` augments `HTMLElementEventMap`, so a handler’s `e.detail` has the event’s own type where the framework reads that map. In Angular, each element has a standalone directive whose selector is its tag — `AparteDirective`, all of them in `APARTE_ELEMENT_DIRECTIVES` — so the template is checked without `CUSTOM_ELEMENTS_SCHEMA`, inputs take a `boolean`, and outputs emit the event’s detail. `` has none: `AparteChatComponent` is that tag. Each [component page](/components/) shows its element in the four frameworks. ## Presence attributes [Section titled “Presence attributes”](#presence-attributes) ```ts import type { AparteAttrValue, AparteSelectAttributes, AparteTemplateAttrs } from '@aparte/core'; type InATemplate = AparteTemplateAttrs; // searchable?: '' | null | undefined // placeholder?: string type Presence = AparteAttrValue; // '' | null | undefined ``` An aparté element reacts to an attribute being there, not to a property’s value. In React, Vue and Svelte a presence attribute is typed `'' | null | undefined`: write `''` to set it and `null` or `undefined` to remove it, never `false`. A few are on unless told otherwise — `submit-on-enter`, `multiple` — and take `'false'` as well, the one value that turns them off. Angular’s directives take a `boolean`, and write or remove the attribute for you. `AparteTemplateAttrs` turns any attribute interface into that template spelling, for an integration of your own. ## The types you can import [Section titled “The types you can import”](#the-types-you-can-import) ```ts import type { AparteElementAttributes, AparteElementTagName } from '@aparte/core'; type SidebarAttrs = AparteElementAttributes['aparte-sidebar']; type EveryTag = AparteElementTagName; // 'aparte-chat' | 'aparte-select' | … ``` `AparteElementAttributes` maps each of core’s tags to its attributes, keyed by `AparteElementTagName`: index it for any element. Every interface is exported by name too — `AparteChatAttributes`, `AparteChatViewportAttributes`, `AparteChatBubbleAttributes`, `AparteChatStatusAttributes`, `AparteComposerAttributes`, `AparteComposerInputAttributes`, `AparteComposerActionAttributes`, `AparteComposerAddAttachmentAttributes`, `AparteComposerToolbarAttributes`, `AparteConversationListAttributes`, `AparteSelectAttributes`, `AparteOptionAttributes`, `AparteOptgroupAttributes`, `AparteProgressSpinnerAttributes`, `AparteContextAttributes`, `AparteIconAttributes`, `AparteScrollRailAttributes`, `AparteSidebarAttributes`, `AparteSplitAttributes`, `AparteSuggestionsAttributes` — and `AparteNoAttributes` types the elements that observe none. ## Your own element, or a plugin’s [Section titled “Your own element, or a plugin’s”](#your-own-element-or-a-plugins) Core types its own elements only: an element is typed by whoever owns it. A plugin ships its bindings — `import '@aparte/plugin-model-selector/react'`, or `/vue`, `/svelte`, `/angular`, types the [model selector](/components/model-selector/) — and an element of yours is typed through one of two mechanisms. ### React, Vue and Svelte: type the tag from your own package [Section titled “React, Vue and Svelte: type the tag from your own package”](#react-vue-and-svelte-type-the-tag-from-your-own-package) * React ```ts import type * as React from 'react'; import type { AparteTemplateAttrs } from '@aparte/core'; interface MyWidgetAttributes { label?: string; compact?: boolean } declare module 'react' { namespace JSX { interface IntrinsicElements { 'my-widget': React.DetailedHTMLProps, HTMLElement> & AparteTemplateAttrs; } } } ``` * Vue ```ts import type { AparteTemplateAttrs } from '@aparte/core'; interface MyWidgetAttributes { label?: string; compact?: boolean } declare module 'vue' { interface GlobalComponents { 'my-widget': import('vue').DefineComponent>; } } ``` * Svelte ```ts import type { HTMLAttributes } from 'svelte/elements'; import type { AparteTemplateAttrs } from '@aparte/core'; interface MyWidgetAttributes { label?: string; compact?: boolean } declare module 'svelte/elements' { interface SvelteHTMLElements { 'my-widget': HTMLAttributes & AparteTemplateAttrs & { 'on:my-widget-change'?: (e: CustomEvent<{ value: string }>) => void; }; } } ``` The three learn a tag through module augmentation, from a `.d.ts` of your own package, the framework’s own HTML attributes beside yours. For an event, augment `HTMLElementEventMap` with its detail type: React reads it by ref and Angular through your directive’s output, both typed from that map, and Svelte from the `on:` entry above. ### Angular: a directive of your own [Section titled “Angular: a directive of your own”](#angular-a-directive-of-your-own) ```ts import { Directive, ElementRef, Input, booleanAttribute, inject } from '@angular/core'; import { applyElementProps } from '@aparte/core'; @Directive({ selector: 'my-widget', standalone: true }) export class MyWidget { private readonly host = inject(ElementRef); @Input() set label(v: string | undefined) { this.write('label', v); } @Input({ transform: booleanAttribute }) set compact(v: boolean) { this.write('compact', v); } private write(name: string, value: unknown): void { applyElementProps(this.host.nativeElement, { [name]: value }); } } ``` `applyElementProps` is core’s rule for writing a value to an element — an attribute, a presence attribute or a property — which is what makes `compact` land as `compact=""` and `false` remove it. A hyphenated tag nothing defines mounts empty and upgrades when its definition arrives, so the types promise a shape, not a definition: import the package that defines the element. For a one-off with no types, `AparteUi` — ``, `AparteUiComponent`, in Angular — mounts any element by name and sets its `props`. It forwards core’s events only, so name your element’s own in `events`: ``. A ref to it is an `AparteUiHandle`: `getElement()` returns the element, and `callMethod(name, ...args)` calls one of its methods. ## On the server [Section titled “On the server”](#on-the-server) ```ts // Same specifier: under the `node` condition, the entry without elements. const { AparteClient, createAparteChatHandler, contentToText } = await import('@aparte/core'); ``` A custom element needs a DOM, and importing `@aparte/core` on a server is fine all the same: the `node` export condition resolves the same specifier to an entry without elements, in Node, an Electron main process or a server render — Next, Nuxt, SvelteKit, Angular. It keeps the client, the chat host, the transports and the server handlers, the conversations and messages, the config, the parsers and every type; it has no elements, and `registerAllComponents()` does nothing there. A Worker or an edge runtime takes the browser entry instead, which needs a DOM, and fails to import it: run code that imports `@aparte/core` on a Node runtime. | Wrapper | On a server | | ------- | --------------------------------------------------------------------------------------------------------------------------------------- | | React | `'use client'` lets a Server Component render it; its server render imports the entry without elements, and they upgrade in the browser | | Angular | `provideAparte` starts the client only in a browser; render the chat in a `@defer` block | | Vue | nothing: under Nuxt, render the chat on the client only | | Svelte | nothing: under SvelteKit, render the chat on the client only | ## Testing your components [Section titled “Testing your components”](#testing-your-components) ```ts // vitest.config.ts import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'jsdom', alias: [{ find: /^@aparte\/core$/, replacement: '@aparte/core/browser' }], }, }); ``` A test runner on Node takes the `node` condition, so no element upgrades: under jsdom, `document.createElement('aparte-chat')` is a plain `HTMLElement`. `@aparte/core/browser` is the entry with the elements in it. Alias the bare specifier to it with a regular expression, as above, so `@aparte/core/icons` and `@aparte/core/styles.css` keep their own paths. Call `registerAllComponents()` in your setup file to be told when the alias is missing: it warns. ## Next [Section titled “Next”](#next) * [Components Every element, with its attributes and events in the four frameworks.](/components/) * [Frameworks The chat component in React, Vue, Svelte and Angular.](/frameworks/) # Add an AI chat to a React app > Install @aparte/react, render , stream a model with useAparteClient, and put your own UI in its slots — in a React app, Next.js included. ## Install [Section titled “Install”](#install) ```bash npm install @aparte/react @aparte/core npm install @aparte/provider-scenario # a scripted model, to stream with no key ``` `@aparte/core`, `react` and `react-dom` are peer dependencies: React `^18.0.0 || ^19.0.0`. ## Render the chat [Section titled “Render the chat”](#render-the-chat) ```tsx import { AparteChat, useAparteChat } from '@aparte/react'; import '@aparte/core/styles.css'; export function Chat() { const chat = useAparteChat(); return ( Ask me anything…

} centerWhenEmpty /> ); } ``` `useAparteChat` holds the messages and the component’s ref: hand both to ``. The person’s message is added when they send, so do not add it yourself; `onMessageSent` fires after, for a side effect. In the Next App Router, put `'use client'` at the top of the file that calls `useAparteChat` and registers the provider: hooks run in a client component. Next still renders it to HTML on the server, where `@aparte/core` resolves to its entry without elements ([On the server](/frameworks/elements/#on-the-server)). ## Stream a model [Section titled “Stream a model”](#stream-a-model) ```tsx import { aparteGlobalConfig } from '@aparte/core'; import { createScenarioProvider } from '@aparte/provider-scenario'; import { showcase } from '@aparte/provider-scenario/showcase'; import { AparteChat, useAparteChat, useAparteClient } from '@aparte/react'; aparteGlobalConfig.registerAIProvider(createScenarioProvider({ scenarios: showcase })); export function Chat() { const chat = useAparteChat(); useAparteClient(); return ; } ``` `useAparteClient` starts a client when the component mounts, stops it when it unmounts, and returns `{ client, abort }`. It takes the `AparteClientOptions` and reads them once, on the first render: a function option keeps the values it closed over then, so read live state from a ref inside it, and remount the component with a new `key` to change the options. For a real model, register its provider, name the model and give the key — `aparteGlobalConfig.setModelConfig({ defaultProvider: 'openrouter', defaultModel: 'openai/gpt-4o-mini' })` and `useAparteClient({ keyResolver })` ([Connect a model](/providers/)). A `config` prop gives one `` providers and a transport of its own; give its client the same one, `useAparteClient({ config })`. With the client running, turn on retry and edit with `aparteGlobalConfig.setBubbleActions({ retry: true, edit: true })` ([Buttons](/guides/customization/actions/)). ## Put your own UI in it [Section titled “Put your own UI in it”](#put-your-own-ui-in-it) ```tsx Ask me anything…

} toolbar={Shift + Enter for a new line} /> ``` | Prop | What it fills | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `emptyState` | the chat before the first message | | `aboveComposer` | the space above the composer | | `toolbar` | the composer’s bottom row, not drawn when you pass `composer` ([The composer toolbar](/guides/customization/composer-toolbar/)) | | `composer` | the composer, replaced by yours | | `renderBubble(message)` | each message, drawn by you ([Bubble](/guides/customization/bubble/#your-own-element-in-a-framework)) | The component takes these and no children. ## Props and callbacks [Section titled “Props and callbacks”](#props-and-callbacks) ```tsx ``` 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](/guides/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 `className` and `style`. `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. The callbacks — `onMessageSent`, `onAction`, `onMessagesChange`, `onMessageAppended`, `onTypingChange` and `onConversationCreated` — are beside the other frameworks’ in [Wrapper surface](/reference/wrappers/#callbacks). ## Control it from code [Section titled “Control it from code”](#control-it-from-code) `chat.ref`, and the helpers `useAparteChat` returns beside it, are 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](/guides/bring-your-own-loop/)). `useConversationManager()` lists, creates, archives and pins saved conversations 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](/guides/conversation-persistence/)). ## Any element, typed [Section titled “Any element, typed”](#any-element-typed) ```tsx GPT-4o mini ``` Importing from `@aparte/react` types every element of core as JSX: the attribute names are the HTML ones, a presence attribute takes `''` to be on and `null` or `undefined` to be off, and events are read by ref, typed through the DOM ([Elements, typed](/frameworks/elements/)). A plugin’s element is typed by the plugin, and one of yours by you ([your own element](/frameworks/elements/#your-own-element-or-a-plugins)). `` mounts any tag and sets its props; it forwards core’s events, so name your element’s own in `events`. A ref to it gives `getElement()` and `callMethod(name, ...args)`: ```tsx setCount(e.detail)} /> ``` ## Tests [Section titled “Tests”](#tests) Vitest runs on Node, where `@aparte/core` resolves to its entry without elements, so a tag never upgrades under jsdom: alias it to `@aparte/core/browser` ([Testing your components](/frameworks/elements/#testing-your-components)). A whole chat site in React — the conversation sidebar, the header, settings, tools and the scripted model — is [the React example](https://github.com/apartejs/aparte/tree/main/apps/examples/react). ## Next [Section titled “Next”](#next) * [Elements, typed Any element in React, with its attributes and events checked.](/frameworks/elements/) * [Wrapper surface Every slot and callback, beside the other three frameworks.](/reference/wrappers/) # Add an AI chat to a Svelte app > Install @aparte/svelte, render , stream a model with createAparteClient, and put your own UI in its slots — on Svelte 4 or 5, SvelteKit included. ## Install [Section titled “Install”](#install) ```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. ## Render the chat [Section titled “Render the chat”](#render-the-chat) ```svelte chat.onMessagesChange(m)}>

Ask me anything…

``` `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(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. ## Stream a model [Section titled “Stream a model”](#stream-a-model) ```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 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](/providers/)). A `config` prop gives one `` 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](/guides/customization/actions/)). ## Callbacks and events [Section titled “Callbacks and events”](#callbacks-and-events) 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. ## Put your own UI in it [Section titled “Put your own UI in it”](#put-your-own-ui-in-it) ```svelte chat.onMessagesChange(m)}>

Ask me anything…

Shift + Enter for a new line
``` | Slot | What it fills | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `empty-state` | the chat before the first message | | `above-composer` | the space above the composer | | `toolbar` | the composer’s bottom row, not drawn when you fill `composer`; `` for several nodes ([The composer toolbar](/guides/customization/composer-toolbar/)) | | `composer` | the composer, replaced by yours | | `bubble`, with `let:message` | each message, drawn by you ([Bubble](/guides/customization/bubble/#your-own-element-in-a-framework)) | ## Props [Section titled “Props”](#props) ```svelte ``` 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](/guides/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](/reference/wrappers/)). ## Control it from code [Section titled “Control it from code”](#control-it-from-code) 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](/guides/bring-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](/guides/conversation-persistence/)). ## Any element, typed [Section titled “Any element, typed”](#any-element-typed) ```svelte (model = e.detail.value)}> GPT-4o mini ``` 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](/frameworks/elements/)). In Svelte 5’s runes mode, write the event as an attribute and type it yourself: `onaparte-select-change={(e: CustomEvent) => …}`, the type from `@aparte/core`. A plugin’s element is typed by the plugin, and one of yours by you ([your own element](/frameworks/elements/#your-own-element-or-a-plugins)). `` mounts any tag and sets its props; it forwards core’s events, so name your element’s own in `events`: ```svelte (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)`. ## SvelteKit and tests [Section titled “SvelteKit and tests”](#sveltekit-and-tests) 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](/frameworks/elements/#on-the-server)). Vitest runs on Node, so alias `@aparte/core` to `@aparte/core/browser` for the elements to upgrade ([Testing your components](/frameworks/elements/#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](https://github.com/apartejs/aparte/tree/main/apps/examples/svelte5), and [the Svelte 4 one](https://github.com/apartejs/aparte/tree/main/apps/examples/svelte4). ## Next [Section titled “Next”](#next) * [Elements, typed Any element in Svelte, with its attributes and events checked.](/frameworks/elements/) * [Wrapper surface Every slot and callback, beside the other three frameworks.](/reference/wrappers/) # Add an AI chat to a Vue app > Install @aparte/vue, render , stream a model with useAparteClient, and put your own UI in its slots — in a Vue app, Nuxt included. ## Install [Section titled “Install”](#install) ```bash npm install @aparte/vue @aparte/core npm install @aparte/provider-scenario # a scripted model, to stream with no key ``` ```ts // vite.config.ts — the aparte-* tags are custom elements, not Vue components import vue from '@vitejs/plugin-vue'; import { defineConfig } from 'vite'; export default defineConfig({ plugins: [vue({ template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith('aparte-') } } })], }); ``` `@aparte/core` and `vue` are peer dependencies: Vue `^3.5.0`. The `isCustomElement` setting keeps Vue from resolving an `aparte-*` tag as a component of yours; under Nuxt it goes in `nuxt.config.ts`, as `vue.compilerOptions.isCustomElement`. ## Render the chat [Section titled “Render the chat”](#render-the-chat) ```vue ``` `useAparteChat` holds the messages and the component’s ref: bind both, or bind `v-model:messages` to a ref of yours. The person’s message is added when they send, so do not add it yourself; `@message-sent` fires after, for a side effect. ## Stream a model [Section titled “Stream a model”](#stream-a-model) ```ts // main.ts — registered once, before the app mounts import { createApp } from 'vue'; import { aparteGlobalConfig } from '@aparte/core'; import { createScenarioProvider } from '@aparte/provider-scenario'; import { showcase } from '@aparte/provider-scenario/showcase'; import App from './App.vue'; aparteGlobalConfig.registerAIProvider(createScenarioProvider({ scenarios: showcase })); createApp(App).mount('#app'); ``` ```vue ``` `useAparteClient` starts a client when the component mounts, stops it before it unmounts, 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 `useAparteClient({ keyResolver })` ([Connect a model](/providers/)). A `config` prop gives one `` providers and a transport of its own; give its client the same one, `useAparteClient({ config })`. With the client running, turn on retry and edit with `aparteGlobalConfig.setBubbleActions({ retry: true, edit: true })` ([Buttons](/guides/customization/actions/)). ## Put your own UI in it [Section titled “Put your own UI in it”](#put-your-own-ui-in-it) ```vue ``` | Slot | What it fills | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `empty-state` | the chat before the first message | | `above-composer` | the space above the composer | | `toolbar` | the composer’s bottom row, not drawn when you fill `composer` ([The composer toolbar](/guides/customization/composer-toolbar/)) | | `composer` | the composer, replaced by yours | | `bubble`, with `{ message }` | each message, drawn by you ([Bubble](/guides/customization/bubble/#your-own-element-in-a-framework)) | Content outside these slots is not drawn. ## Props and events [Section titled “Props and events”](#props-and-events) ```vue ``` The ones you reach for: `placeholder`, `disabled` and `submit-on-enter`; `center-when-empty`; `loading`, for a wait of your own; `is-typing` and `typing-text`, a waiting line you drive yourself; `overlay-composer`; `attachments`, off by default ([Attachments](/guides/attachments/)); `:elicitation="false"` when you draw the questions yourself; `conversation-id`, `config` and `layout-transition-ms`, how long the list holds its layout after a conversation swap. `class` and `style` fall through to the root. `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. The events — `@message-sent`, `@action`, `@messages-change` (and `update:messages`, for `v-model`), `@message-appended`, `@typing-change` and `@conversation-created` — hand you the payload itself ([Wrapper surface](/reference/wrappers/#callbacks)). ## Control it from code [Section titled “Control it from code”](#control-it-from-code) `chat.chatRef`, and the methods `useAparteChat` returns beside it, are 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](/guides/bring-your-own-loop/)). `useConversationManager()` lists, creates, archives and pins saved conversations 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 `conversation-id` ([Save conversations](/guides/conversation-persistence/)). ## Any element, typed [Section titled “Any element, typed”](#any-element-typed) ```vue ``` Importing from `@aparte/vue` declares every element of core in `GlobalComponents`, so `vue-tsc` checks its attributes in any template: a presence attribute takes `''` to be on and `null` to be off ([Elements, typed](/frameworks/elements/)). A plugin’s element is typed by the plugin, and one of yours by you ([your own element](/frameworks/elements/#your-own-element-or-a-plugins)). `` mounts any tag and sets its props; it forwards core’s events, so name your element’s own in `events`: ```vue ``` ## Nuxt and tests [Section titled “Nuxt and tests”](#nuxt-and-tests) The wrapper has no server guard: under Nuxt, render the chat on the client only, in `` or a `.client.vue` component. `@aparte/core` itself imports on a server through its entry without elements ([On the server](/frameworks/elements/#on-the-server)). Vitest runs on Node, so alias `@aparte/core` to `@aparte/core/browser` for the elements to upgrade ([Testing your components](/frameworks/elements/#testing-your-components)). A whole chat site in Vue — the conversation sidebar, the header, settings, tools and the scripted model — is [the Vue example](https://github.com/apartejs/aparte/tree/main/apps/examples/vue). ## Next [Section titled “Next”](#next) * [Elements, typed Any element in Vue, with its attributes checked.](/frameworks/elements/) * [Wrapper surface Every slot and event, beside the other three frameworks.](/reference/wrappers/) # Accessibility > What the chat does for keyboard and screen-reader users, what it leaves to your page, and what is tested. ## What a screen reader hears [Section titled “What a screen reader hears”](#what-a-screen-reader-hears) | Part | Role | Name | | ----------------------------------------- | ------------------------------------------- | -------------------------------------- | | The message list | `log`, `aria-live="polite"`, and a tab stop | ”Transcript” | | A message | `article` | ”Your message” or “Assistant response” | | A message’s buttons | `toolbar` | ”Message actions” | | A reply still waiting for its first words | hidden text in its waiting dots | ”Typing” | | Your status line, `` | `status`, `aria-live="polite"` | its `text`, else “Typing” | | The composer’s input | `textbox`, `aria-multiline="true"` | its placeholder | While a reply streams, its message is marked busy, so a screen reader reads it once it is complete, and a reply still waiting says a turn is in progress rather than leaving a silence. Moving to another version of a reply is announced without taking the focus. Every name the chat writes itself comes from the locale, so it follows the language ([Translation](/guides/localization/)), and a key the locale leaves out reads in English; the status line’s name is the text you give it. ## Keyboard [Section titled “Keyboard”](#keyboard) | Where | Keys | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The composer | `Enter` sends, `Shift`+`Enter` starts a new line; `submit-on-enter="false"` (`submitOnEnter={false}` on the framework components) swaps them; while a reply streams, `Enter` keeps the draft | | A message’s buttons | one tab stop: `←` `→` move between them, mirrored right-to-left, `Home` `End` jump to the ends | | A select | `Enter`, `Space` or `↓` opens, `↓` `↑` move, `Home` `End` jump to the ends — in a searchable select, they move in its filter field — `Enter` chooses, `Escape` closes | | A conversation’s menu | `↓` `↑` move and wrap, `Home` `End`, `Escape` closes, `Tab` leaves for the button that opened it | | A split’s seam | arrows move it a step (up and down on a vertical split), `Shift` with an arrow a larger one, `Home` `End` to the ends, `Enter` folds the primary pane and a second `Enter` restores it | | The sidebar as a drawer | its toggle opens it with the focus inside, and `Tab` stays inside while it is open; `Escape` closes it and gives the focus back | A [select](/components/select/) is a combobox: `aria-expanded`, `aria-controls` and `aria-activedescendant` sit on the element that has the focus, which is its filter field when it is `searchable`. A [conversation](/components/conversation-list/)’s row is a button that opens it, beside a ⋯ button that opens its menu; asking before a delete turns that menu into a dialog named by the question. The [split](/components/split/)’s arrows follow the reading direction. ## Focus, motion, contrast and touch [Section titled “Focus, motion, contrast and touch”](#focus-motion-contrast-and-touch) * **Focus.** Every control shows a ring on keyboard focus (`:focus-visible`), flush against it, drawn in `--aparte-border-focus` — which follows `--aparte-neutral` — at `--aparte-focus-outline-width`. Widen it everywhere at once: ```css :root { --aparte-focus-outline-width: 2px; } ``` * **Motion.** Under `prefers-reduced-motion`, the duration tokens drop to near zero, so the chat’s transitions and animations stop, and so do yours where they read those tokens. What is happening is still announced. * **Contrast.** Under forced colours, as in Windows High Contrast mode, a part drawn only by its fill gets a border. * **Touch.** On a coarse pointer, the composer’s controls, the message buttons, the scroll button, a conversation’s ⋯ and a split’s grab zone grow to `--aparte-touch-target-size`; the message buttons stay shown instead of waiting for a hover, and the scroll rail is hidden. ## What you add [Section titled “What you add”](#what-you-add) * **A name for each chat**, when a page has more than one, so a reader knows which transcript they are in: wrap each in a landmark, `
`. The chat element has no role of its own, so a name set on it alone is not read. * **A translated label for each action you register.** `registerAction({ id, icon, label, zones })` writes `label` as the button’s name as given, without the locale: register the action again with the new label when the language changes. * **Roles for the kit’s neutral classes.** `.aparte-menu`, `.aparte-accordion`, `.aparte-popover` and `.aparte-tooltip` draw a surface; `role="menu"`, `menuitem`, `tooltip` and the keys those patterns need are yours. The conversation list’s row menu is a complete one to copy. * **Arrow keys across a long conversation list**, if you want them: every row is in the tab order, with no roving `tabindex`. * **A name in what a render hook returns.** An element with `textContent` carries it; a string of markup must carry it too ([Render hooks](/guides/customization/render-hooks/)). * **Your page around the chat**: headings, landmarks, a skip link. ## What is tested [Section titled “What is tested”](#what-is-tested) An axe suite runs in a real browser, in Chromium, WebKit, Firefox, and fails on any serious or critical violation, in each of these states: an idle chat, a streamed exchange, an open model menu, a turn in flight, and a failed turn. One more test reaches the composer, fills it and sends with the keyboard alone. axe sees what a machine can. Whether your labels say something useful, and whether your page reads in a sensible order, takes a person using your app. ## Next [Section titled “Next”](#next) * [Translation The locale every name on this page comes from.](/guides/localization/) * [Customization Your own parts of the chat, and the names they carry.](/guides/customization/) # Let people attach files and send them to the model > Show the paperclip, read the files when the message is sent, choose what reaches the model, and open an image full size. [A composer with files attached](/preview/demo/composer-attachments/) ```html
``` Files waiting in the composer, before the message is sent. ## Show the paperclip [Section titled “Show the paperclip”](#show-the-paperclip) * Vanilla ```html ``` * React ```tsx ``` * Vue ```vue ``` * Svelte ```svelte ``` * Angular ```html ``` The chat has no paperclip until you set `attachments`: set it when something reads the files — `AparteClient` does, and so does a loop of yours that reads them on send. It adds the button and the row of files waiting to go. In a composer you write yourself, place the two elements where you want them, and choose what the file dialog offers: ```html
``` `accept` and `multiple="false"` shape the file dialog only: a drop or a paste can still add other types and more files, and each pick adds to the files already waiting. To hold a limit, watch `aparte-composer-change` and call `removeAttachment`, below. ## Read the files on send [Section titled “Read the files on send”](#read-the-files-on-send) * Vanilla ```ts document.querySelector('aparte-chat')?.addEventListener('aparte-send', (event) => { const { content, files } = event.detail; // files: File[] | undefined }); ``` * React ```tsx { if (detail.files) upload(detail.files); }} /> ``` * Vue ```vue ``` * Svelte ```svelte { if (e.detail.files) upload(e.detail.files); }} /> ``` * Angular ```html ``` The files leave with the message, on `aparte-send`. React, Vue and Angular hand you its detail; Svelte’s `on:messageSent` hands you an event, as the DOM does, hence `e.detail`. To follow the files while they wait — to enable a button, say — listen for `aparte-composer-change`, whose `detail.state.attachments` is the current list. The composer element also takes them from code: `addAttachments(files)`, `removeAttachment(file)`, `clearAttachments()`, and `attachments` to read them. ## What the model receives [Section titled “What the model receives”](#what-the-model-receives) ```ts import { AparteClient } from '@aparte/core'; new AparteClient({ rawFileInject: 'all', // or 'images-only', or 'none' fileInjectFilter: (file) => !/(^|\.)env$|\.(pem|key)$/i.test(file.name), }).start(); ``` `AparteClient` puts the files into the request itself: * **an image** goes as an image part, for a model that reads images; * **a text file** — Markdown, JSON, CSV, source code, a log, an `.env` — is read in the browser and goes **in full**, under a `// File: name` line; * **anything else**, a PDF or a spreadsheet, does not go: the file stays on screen and on the send event for an upload of yours, and the console says so once per file name. `'images-only'` sends the images alone, and `'none'` sends no file, for an app that uploads them or retrieves what it needs from them itself. `fileInjectFilter` is called for each file the mode lets through — every file, in `'all'` — and `false` keeps that one out: the example keeps out `.env`, `.pem` and `.key` files, and yours can test whatever your users might attach by mistake. The files go with that one request: on the next turns, a retry included, the history carries the message’s text, not its files, so keep what the model needs in your app — an upload, a retrieval step. Whether the model reads an image depends on the [provider](/providers/) and the model. ## With your own loop [Section titled “With your own loop”](#with-your-own-loop) ```ts import { filesToAttachments } from '@aparte/core'; const chat = document.querySelector('aparte-chat'); chat?.addEventListener('aparte-send', (event) => { const { content, files } = event.detail; chat.viewport?.appendMessage({ id: crypto.randomUUID(), role: 'user', content, timestamp: Date.now(), ...(files?.length ? { attachments: filesToAttachments(files) } : {}), }); }); ``` This is for plain HTML without `AparteClient`: the client and the framework packages already add the person’s message with its files, so do not append it again there. `filesToAttachments` turns the sent files into the `attachments` a message shows. Call `appendMessage` on the message list, `chat.viewport`, not on the chat itself. ## Open an image full size [Section titled “Open an image full size”](#open-an-image-full-size) ```ts import { aparteGlobalConfig } from '@aparte/core'; declare function openLightbox(url: string, name: string): void; aparteGlobalConfig.setHostHandlers({ attachmentPreview: true }); document.addEventListener('aparte-attachment-preview', (event) => { openLightbox(event.detail.url, event.detail.name); }); ``` [A chat that accepts files](/preview/demo/attachments-chat/) ```html ``` Files on a sent message: an image tile and a file chip. Once you say you open them, an image becomes a button, by click, `Enter` or `Space`, that sends `aparte-attachment-preview` with its `url` and `name`; until then it is a picture, with no pointer and no tab stop. Core draws no lightbox: the view is yours. A chat with its own configuration takes `setHostHandlers` on that configuration. ## Drag, drop and paste [Section titled “Drag, drop and paste”](#drag-drop-and-paste) A file dropped anywhere on the composer attaches. While a file is dragged over it, the composer carries `aparte-is-dragover`, and its outline follows your theme. Drag and drop comes with the paperclip button, so a composer without the button takes no drop. A `disabled` composer takes no drop and greys the button; a composer streaming a reply still takes files. An image pasted into the input attaches too, on any composer, with or without the `attachments` flag, and leaves with the message. ## Release memory and save the files [Section titled “Release memory and save the files”](#release-memory-and-save-the-files) ```ts import { revokeAttachmentUrls, type AparteMessage } from '@aparte/core'; function forget(messages: AparteMessage[]): void { for (const message of messages) revokeAttachmentUrls(message.attachments); } ``` Each attachment’s `url` keeps its file in memory for as long as the page lives. When attachments leave the screen for good, `revokeAttachmentUrls` lets the memory go; calling it twice is harmless, and the message list’s `clearAll()` does it for the messages it drops. The file itself stays on the attachment as `blob`: your storage adapter saves it as an `AparteAttachmentRow`, with its conversation and message ids, and builds a new `url` when it loads the message ([Save conversations](/guides/conversation-persistence/#what-comes-back-after-a-reload)). ## Next [Section titled “Next”](#next) * [Attachments The tiles and chips, in the composer and on a message.](/components/attachments/) * [Composer Every part of the composer, the paperclip among them.](/components/composer/) * [Your own loop Send the files to a model yourself.](/guides/bring-your-own-loop/) # Keep the key on your server > Send each request through your own /api/chat — the page names a provider, your server holds the key, calls the model and streams the reply back — with the chat unchanged. ## Write the route [Section titled “Write the route”](#write-the-route) ```bash npm install @aparte/core @aparte/provider-openai-compat ``` ```ts // app/api/chat/route.ts (Next.js) import { createAparteChatHandler } from '@aparte/core'; import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat'; declare function getSession(req: Request): Promise<{ userId: string } | null>; // your session lookup export const POST = createAparteChatHandler({ providers: { openai: createOpenAICompatProvider(presets.OPENAI) }, resolveKey: (providerId) => process.env[`${providerId.toUpperCase()}_KEY`], authorize: async (req) => Boolean(await getSession(req)), // required: this route spends your key }); ``` `createAparteChatHandler` returns a Fetch handler, `Request` to `Response`, for a Next.js route in the Node runtime, Node, Deno or Bun. It reads `{ providerId, request }`, runs that provider on the server — the request, the key, the vendor call, the stream — and streams the reply back as NDJSON, one event per line; a request with `stream: false` gets a `{ text }` body. On those runtimes `@aparte/core` resolves to its `node` entry, which has no DOM ([On the server](/frameworks/elements/#on-the-server)); a Worker or an edge runtime does not ask for that entry. | Option | What it does | | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `providers` | the providers, keyed by the `providerId` the page sends: an unknown id is a 400, a provider that is not a [format adapter](/providers/#write-your-own-provider) a 500 | | `authorize(req)` | required, run before anything else: `false` answers 401, a `Response` answers itself, `true` goes on | | `resolveKey(providerId)` | the vendor key, from your environment or a secret store; `undefined` for a keyless server | | `fetchImpl` | the `fetch` that calls the vendor | These are the `AparteChatHandlerOptions`. authorize has to authenticate `Boolean(req.headers.get('cookie'))` passes any request that carries any cookie, a cross-site one included, and the route then spends your key for anyone who can load it. Call the session lookup that guards your other routes. ## What the route accepts [Section titled “What the route accepts”](#what-the-route-accepts) The page sends a `providerId`, never a URL: the vendor’s address comes from the provider in your map, so no request can point your server at another host. A vendor’s error keeps its status, and its body reaches the page as `{ error: { message, code?, type? } }`, without the vendor’s own text. The rest of the request is the page’s: `modelId`, the messages, the system prompt, the tools and `maxTokens` reach the provider as sent, so `authorize` decides who calls the route, not what they send. To limit the models or the size, check the body in a wrapper of yours, or in `authorize` through `req.clone().json()`: clone it, because the handler reads the body itself. ## Point the page at it [Section titled “Point the page at it”](#point-the-page-at-it) ```ts import { aparteGlobalConfig, AparteClient, AparteBackendTransport } from '@aparte/core'; import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat'; aparteGlobalConfig.registerAIProvider(createOpenAICompatProvider(presets.OPENAI)); // the id the route knows, no key aparteGlobalConfig.setTransport(new AparteBackendTransport({ endpoint: '/api/chat' })); aparteGlobalConfig.setModelConfig({ defaultProvider: 'openai', defaultModel: 'gpt-4.1-mini' }); new AparteClient().start(); ``` The page posts `{ providerId, request }` to your route and streams the reply into the chat; no key is in it. Register a provider under each id the route knows: the page reads what a model can do from it, and refuses to send to an id it does not have. `BackendTransportOptions` takes the `endpoint`, `headers` sent with every request, and `buildBody(request, providerId)` for a route that expects another body. A cookie goes along only to a route on the page’s own origin, so a route on another origin is authenticated through `headers`. The tools and the loop stay in the page: the route makes one model call at a time. When the tools live on your server, move the loop there too, with [the server loop](/guides/server-loop/). ## If your server already uses the AI SDK [Section titled “If your server already uses the AI SDK”](#if-your-server-already-uses-the-ai-sdk) ```bash npm install @aparte/core @aparte/provider-ai-sdk ai @ai-sdk/anthropic ``` ```ts // app/api/chat/route.ts (Next.js) — the AI SDK on your server, aparté in the page 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'; declare function getSession(req: Request): Promise<{ userId: string } | null>; // your session lookup const MODELS = ['claude-sonnet-4-5', 'claude-haiku-4-5']; export async function POST(req: Request): Promise { if (!(await getSession(req))) return new Response('Unauthorized', { status: 401 }); const { request } = (await req.json()) as { request: AparteChatRequest }; if (!MODELS.includes(request.modelId)) return new Response('Unknown model', { status: 400 }); const result = streamText({ model: createAnthropic({ apiKey: process.env['ANTHROPIC_API_KEY'] })(request.modelId), messages: toModelMessages(request.messages), temperature: request.temperature, maxOutputTokens: request.maxTokens, seed: request.seed, abortSignal: req.signal, ...(request.tools?.length ? { tools: toToolSet(request.tools), toolChoice: toToolChoice(request.toolChoice) ?? 'auto' } : {}), }); if (request.stream === false) { try { return Response.json({ text: await result.text }); } catch (err) { console.error('[api/chat] vendor request failed', err); return Response.json({ error: { message: 'Vendor request failed.' } }, { status: 502 }); } } const ndjson = fullStreamToAparteEvents(result.fullStream) .pipeThrough(new TransformStream({ transform: (event, controller) => { if (event.type === 'error') { console.error('[api/chat] vendor stream error', event.message); // the vendor's words stay in your log controller.enqueue({ type: 'error', message: 'Vendor request failed.' }); return; } controller.enqueue(event); }, })) .pipeThrough(new TransformStream({ transform: (event, controller) => controller.enqueue(`${JSON.stringify(event)}\n`), })) .pipeThrough(new TextEncoderStream()); return new Response(ndjson, { headers: { 'content-type': 'application/x-ndjson' } }); } ``` ```ts import { aparteGlobalConfig, AparteClient, AparteBackendTransport } from '@aparte/core'; import { createAiSdkProvider } from '@aparte/provider-ai-sdk'; // The id the page sends, and what the model can do; the call itself is the route's. aparteGlobalConfig.registerAIProvider(createAiSdkProvider({ id: 'anthropic', name: 'Anthropic', languageModel: () => { throw new Error('This provider runs on the server.'); }, })); aparteGlobalConfig.setTransport(new AparteBackendTransport({ endpoint: '/api/chat' })); aparteGlobalConfig.setModelConfig({ defaultProvider: 'anthropic', defaultModel: 'claude-sonnet-4-5' }); new AparteClient().start(); ``` The handler above runs format adapters, and the [AI SDK bridge](/providers/ai/ai-sdk/) is not one: it makes its own call through `streamText`. Its four helpers make the same route by hand — `toModelMessages` and `toToolChoice` translate the request, `toToolSet` gives the SDK the tools as declarations, which the page’s loop runs, and `fullStreamToAparteEvents` turns the SDK’s stream into the events the page reads. The page registers a provider under the id it sends and names one of the route’s models; its `languageModel` is never called, since the route makes the call. Two things in this route are what the handler does for you: the vendor’s error replaced before it leaves the server, and the `{ text }` answer to a request with `stream: false`. The check on the model is one the handler does not make; add it to either route. ## Write the stream yourself [Section titled “Write the stream yourself”](#write-the-stream-yourself) ```text {"type":"text","delta":"Hel"} {"type":"text","delta":"lo."} {"type":"done","usage":{"inputTokens":12,"outputTokens":3,"totalTokens":15}} ``` A route of your own answers `AparteBackendTransport` with NDJSON, one `AparteStreamEvent` per line — aparté’s events, not the AI SDK’s data stream protocol. `parseAparteEventStream(body)` reads that stream back into events. A transport of your own is set with `aparteGlobalConfig.setTransport`, like the two built in ([`AparteTransport`](/reference/core/transports/#apartetransport)). ## Next [Section titled “Next”](#next) * [Connect a model The providers your route's map takes.](/providers/) * [Run the loop on your server The tools on your server too, next to the loop.](/guides/server-loop/) # Show a loop of your own in the chat > Drive the chat from a loop that runs elsewhere — your backend, a worker, an Electron process — without AparteClient, streaming text, reasoning and tool calls into it. When the loop lives somewhere else, the chat only shows it: your code puts messages and tokens in, and the chat draws, streams and scrolls them as it does for `AparteClient`. No client, no provider, no transport. ## Stream tokens in [Section titled “Stream tokens in”](#stream-tokens-in) * Vanilla ```ts import { uuid } from '@aparte/core'; const chat = document.querySelector('aparte-chat')!; chat.addEventListener('aparte-send', async (event) => { const viewport = chat.viewport!; viewport.appendMessage({ id: uuid(), role: 'user', content: event.detail.content, timestamp: Date.now() }); const id = uuid(); viewport.appendMessage({ id, role: 'assistant', content: '', timestamp: Date.now() }); for await (const token of myAgentLoop(event.detail.content)) viewport.appendToken(id, token); viewport.completeMessage(id); }); ``` * React ```tsx import { useCallback } from 'react'; import { AparteChat, useAparteChat } from '@aparte/react'; import { uuid } from '@aparte/core'; import '@aparte/core/styles.css'; export function Chat() { const chat = useAparteChat(); const onMessageSent = useCallback(async (event: { content: string }) => { const id = uuid(); chat.ref.current?.appendMessage({ id, role: 'assistant', content: '', timestamp: Date.now() }); await chat.ref.current?.injectTokenStream(id, myAgentLoop(event.content)); }, [chat.ref]); return ( ); } ``` * Vue ```vue ``` * Svelte ```svelte chat.onMessagesChange(m)} onmessageSent={onMessageSent} /> ``` * Angular ```ts import { Component, viewChild } from '@angular/core'; import { AparteChatComponent } from '@aparte/angular'; import { uuid } from '@aparte/core'; import type { AparteMessage, AparteSendEventDetail } from '@aparte/core'; @Component({ selector: 'app-chat', standalone: true, imports: [AparteChatComponent], template: ``, }) export class ChatComponent { readonly chat = viewChild.required(AparteChatComponent); messages: AparteMessage[] = []; async onMessageSent(event: AparteSendEventDetail) { const id = uuid(); this.chat().appendMessage({ id, role: 'assistant', content: '', timestamp: Date.now() }); await this.chat().injectTokenStream(id, myAgentLoop(event.content)); } } ``` `myAgentLoop` is any `AsyncIterable`: an async generator over a fetch stream, a model’s SDK, whatever produces tokens. The framework packages add the person’s message when it is sent; in plain HTML, add it yourself, as above. Then add an empty assistant message, which shows the waiting dots until the first token, and stream into it: `injectTokenStream` in a framework, which also accepts an RxJS `Observable` in Angular, `appendToken` and `completeMessage` on the message list in plain HTML. Add the message before streaming into it: an id the framework does not know is kept by the message list alone, out of your `messages`. A new `injectTokenStream` cancels the one before, and `stopTokenStream()` cancels it; the source is closed, so a generator’s `finally` runs. `injectTokenStream` also sends the turn’s events: the send button is Stop while it streams, Stop cancels it, and the [conversation manager](/guides/conversation-persistence/) saves the reply when it ends. In plain HTML, [Make Stop work](#make-stop-work) shows the events to send yourself. ## From a source that pushes [Section titled “From a source that pushes”](#from-a-source-that-pushes) ```ts function createTokenQueue() { const buffer: string[] = []; let notify: (() => void) | null = null; let done = false; return { push(token: string) { buffer.push(token); notify?.(); }, end() { done = true; notify?.(); }, async *stream(): AsyncGenerator { for (;;) { while (buffer.length) yield buffer.shift()!; if (done) return; await new Promise((resolve) => { notify = resolve; }); notify = null; } }, }; } ``` A WebSocket, an Electron bridge or `postMessage` pushes its tokens rather than being pulled: push them into the queue, call `end()` when the reply is done, and stream `queue.stream()` into the message. ## Show reasoning and tool calls [Section titled “Show reasoning and tool calls”](#show-reasoning-and-tool-calls) ```ts const id = uuid(); chat.appendMessage({ id, role: 'assistant', content: '', timestamp: Date.now() }); // the reply the parts go into chat.addSegment({ id: 'think-1', type: 'thinking', content: '' }); for await (const chunk of reasoning) chat.appendToSegment('think-1', chunk); chat.updateSegment('think-1', { isStreaming: false }); chat.addSegment({ id: 'call-1', type: 'tool_call', status: 'pending', toolCall: { id: 'tc-1', name: 'search', input: { q: 'pricing' } } }); chat.updateSegment('call-1', { status: 'resolved', result: '3 pages found.' }); chat.updateMessage(id, { status: 'completed' }); // or 'error': the turn is over ``` A reply made of parts — reasoning, a tool call, an answer — is made of segments, each drawn by its renderer. On a framework chat, `addSegment`, `appendToSegment`, `updateSegment` and `removeSegment` act on the last message, so add the assistant message first: after a send, the last message is the person’s. On the message list, the message is named first: `addSegment(messageId, segment)`, `appendToSegment(messageId, segmentId, chunk)`, `updateSegment(messageId, segmentId, patch)`. Once a message has segments, its plain `content` is no longer drawn: give the answer a `text` segment of its own. A turn of segments does not end by itself: give it a final status with `updateMessage`, which also closes the segments still open. In plain HTML the transcript stays read-only until you do; on a framework chat it is read-only between `aparte-message-start` and the event that ends the turn. ## Make Stop work [Section titled “Make Stop work”](#make-stop-work) On a framework chat, `injectTokenStream` does it: nothing to add. A loop that writes segments rather than tokens, or a stream written with `appendToken` in plain HTML, is not a turn the composer can see: send the turn’s events yourself, on the chat’s element (the ``, or the framework component’s root, given an `id`), bubbling: `aparte-message-start` turns the send button to Stop, and `aparte-message-done`, `-error` or `-aborted` turns it back. Stop sends `aparte-abort` on `window`, with the chat’s `id` as `targetId`, for your loop to cancel, and the [conversation manager](/guides/conversation-persistence/) saves a reply when it ends. The [events reference](/reference/events/) types each detail. ```ts import { uuid } from '@aparte/core'; const chat = document.querySelector('aparte-chat')!; const lifecycle = (name: string, detail: object) => chat.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail: { targetId: chat.id, ...detail } })); let controller = new AbortController(); chat.addEventListener('aparte-send', async (event) => { const viewport = chat.viewport!; // …the person's message, added as in the first example const id = uuid(); controller = new AbortController(); viewport.appendMessage({ id, role: 'assistant', content: '', timestamp: Date.now() }); lifecycle('aparte-message-start', { messageId: id, role: 'assistant' }); try { for await (const token of myAgentLoop(event.detail.content, controller.signal)) viewport.appendToken(id, token); } finally { viewport.completeMessage(id); lifecycle(controller.signal.aborted ? 'aparte-message-aborted' : 'aparte-message-done', { messageId: id, role: 'assistant' }); } }); window.addEventListener('aparte-abort', (event) => { if (!event.detail?.targetId || event.detail.targetId === chat.id) controller.abort(); }); ``` ## Replay a stored conversation [Section titled “Replay a stored conversation”](#replay-a-stored-conversation) ```ts import type { AparteChatImperativeApi, AparteMessage } from '@aparte/core'; declare const chat: AparteChatImperativeApi; declare const stored: AparteMessage[]; // from your backend chat.clearMessages(); for (const message of stored) chat.appendMessage(message, { historical: true }); ``` A historical message is a record, not a turn: its segments are taken as they are and nothing on it streams, so a tool call saved while it ran does not come back spinning. In plain HTML, `viewport.setMessages(stored)` replaces the whole conversation the same way. ## What you give up [Section titled “What you give up”](#what-you-give-up) The parts `AparteClient` drives do not run: no tool calls or approvals of its own, no retry or edit sending again, no request to build. The retry and edit buttons are off, and when you turn them on with `aparteGlobalConfig.setBubbleActions({ retry: true, edit: true })` they only send `aparte-retry` and `aparte-edit`, which your loop answers as in [Conversations and branches](/guides/conversations-branching/#with-your-own-loop). Files the person attaches arrive on the send event, for your loop to read, as in [Attachments](/guides/attachments/#with-your-own-loop). `requestUserInput` still asks the person anything, from your code ([Ask the user a question](/guides/elicitation/)). ## The pieces core exports for this [Section titled “The pieces core exports for this”](#the-pieces-core-exports-for-this) ````ts import { aparteGlobalConfig, AparteStreamParser } from '@aparte/core'; const parser = new AparteStreamParser({ blocks: aparteGlobalConfig.getStreamBlocks(), thinkingDelimiters: [ { start: '', end: '' }, { start: '<|begin_of_thought|>', end: '<|end_of_thought|>' }, ], }); for (const delta of ['Here: ', '```', 'ts\n', 'const x = 1;\n', '```']) { const { segments } = parser.parse(delta); // the segments each delta completes } parser.finalize(); // and whatever is still buffered ```` `AparteStreamParser` turns a model’s text into segments as it streams — text, code fences, reasoning, and the tags registered with `aparteGlobalConfig.registerStreamBlock` when you pass them as its `blocks`, as above. Its reasoning delimiters replace the default `` and ``: list every pair you want. They are this parser’s own: `AparteClient` reads the defaults, so a model that marks its reasoning another way needs this parser in a loop of yours, or a provider that sends the reasoning as its own events. The rest, each one line: * `parseMarkdownToSegments(text, options)` — the same, for a whole reply at once, to store or inspect it. * `readableToAsyncIterable(events, signal)` — the event stream `parseAparteEventStream` returns, as something you can `for await`, stopped by a signal. * `parseAparteEventStream(body)` — the stream a [backend transport](/guides/backend-transport/) endpoint answers, read back into events. * `contentToText(content)` — the text of a message whose content has several parts. * `uuid()` and `copyText(text)` — an id and a copy that work on a plain `http://` address too, where `crypto.randomUUID` does not. * `registerAllComponents()` — keeps every element in a bundle whose bundler drops side-effect imports, and warns naming any tag that is not registered. * `populateBubbleFromMessage(bubble, message)` — a message element filled from a message. * `AparteChatHost` — what the framework packages bind to, for a framework of your own; the [client reference](/reference/core/client/) has its binding. ## aparté’s loop, elsewhere [Section titled “aparté’s loop, elsewhere”](#apartés-loop-elsewhere) If the loop is yours to write, `runStreamAgent` from [`@aparte/engine`](/guides/engine/) is the loop `AparteClient` runs — headless, with no DOM — in Node, a worker or an Electron process. On a server, [the server loop](/guides/server-loop/) runs it next to your tools and keeps the page’s tool rows, Stop and approvals. ## Next [Section titled “Next”](#next) * [The agent engine The loop on its own, in any JavaScript runtime.](/guides/engine/) * [Run the loop on your server The same loop, with the page drawing it.](/guides/server-loop/) * [Frameworks The chat in React, Vue, Svelte and Angular, and its ref.](/frameworks/) # Save conversations and list them in a sidebar > Keep conversations across reloads in any store — localStorage, IndexedDB, SQLite or your API — open one from a list or a URL, and get back what was saved. [Conversations grouped by date](/preview/demo/conversation-list/) ```html ``` The list of saved conversations, grouped by date, each with its menu. ## Write a storage adapter [Section titled “Write a storage adapter”](#write-a-storage-adapter) ```ts import type { AparteConversation, AparteStorageAdapter } from '@aparte/core'; const KEY = 'aparte:conversations'; function readAll(): AparteConversation[] { try { return JSON.parse(localStorage.getItem(KEY) ?? '[]'); } catch { return []; } } function writeAll(conversations: AparteConversation[]): void { localStorage.setItem(KEY, JSON.stringify(conversations)); } export class LocalStorageAdapter implements AparteStorageAdapter { async loadAll(): Promise { return readAll(); } async save(conversation: AparteConversation): Promise { const all = readAll(); const i = all.findIndex((c) => c.id === conversation.id); if (i >= 0) all[i] = conversation; else all.push(conversation); writeAll(all); } async delete(id: string): Promise { writeAll(readAll().filter((c) => c.id !== id)); } } ``` Your adapter does all the storage, against any store, and every method returns a promise. Three methods are required — `loadAll`, `save` (create or update) and `delete`. The optional ones let a store do a change in place rather than through `save`: `archive` and `unarchive`, `pin` and `unpin`, `rename`, and the pair `loadMeta` and `loadFull` for [large histories](#large-histories). An `AparteConversation` holds its `id`, `title`, `createdAt`, `updatedAt`, the `messages` on screen, the branch `tree`, and `archivedAt`, `pinnedAt` and `schemaVersion`, which an adapter compares with `APARTE_CONVERSATION_SCHEMA_VERSION` to migrate old records. Anything else your app keeps is a table of its own. ## Register a manager [Section titled “Register a manager”](#register-a-manager) ```ts import { aparteGlobalConfig, AparteConversationManager } from '@aparte/core'; import { LocalStorageAdapter } from './local-storage-adapter'; const manager = new AparteConversationManager(new LocalStorageAdapter(), { retention: { maxMessages: 200 }, // optional: what a save keeps }); await manager.init(); aparteGlobalConfig.setConversationManager(manager); ``` The manager holds the list and calls your adapter; your app talks to the manager. `init()` reads the store once. `retention` saves the last 200 messages of a conversation and the branches under them; the conversation on screen stays whole. A chat with its own configuration gets its own manager through that configuration’s `setConversationManager`. | Read | Gives | | --------------------------------------------------------------- | ------------------------------------------------------------- | | `conversations`, `activeConversations`, `archivedConversations` | the lists, the last two newest first | | `activeId`, `active` | the conversation open in the chat | | `subscribe(listener)` | a call after every change; returns the function that stops it | `createNew`, `delete`, `archive`, `unarchive`, `pin`, `unpin` and `updateTitle` change the list. A rename, a pin or an archive leaves `updatedAt` alone, so the conversation keeps its date; `updatedAt` moves when its messages change. The [conversations reference](/reference/core/conversations/) lists every method and type. In a framework, the package’s helper builds and registers the manager: call its `init(adapter)` once, or `init(adapter, config)` for a chat with its own `config`. The helpers build it without options; for `retention` or a title provider, build the manager as above and register it with `setConversationManager` — the chat saves to whichever manager is registered. | Framework | Helper | | --------- | ---------------------------------------------- | | React | `useConversationManager()`, a hook | | Vue | `useConversationManager()`, a composable | | Svelte | `createConversationManager()`, stores | | Angular | `ConversationManagerService`, provided in root | ## Open a conversation [Section titled “Open a conversation”](#open-a-conversation) * Vanilla ```ts import { AparteConversationController } from '@aparte/core'; const chat = document.querySelector('aparte-chat'); const viewport = chat?.viewport; if (chat && viewport) { chat.id ||= 'main-chat'; // the id the chat's events carry as targetId const controller = new AparteConversationController({ hostId: chat.id, host: chat, getMessages: () => viewport.getMessages(), setMessages: (messages) => viewport.setMessages(messages), appendMessage: (message) => viewport.appendMessage(message), clearMessages: (options) => viewport.clearAll(options), setLoading: (loading) => viewport.setLoading(loading), exportTree: () => viewport.exportTree(), importTree: (tree) => viewport.importTree(tree), }, { onConversationCreated: (id) => history.replaceState(null, '', `#${id}`) }); controller.bind(); void controller.setConversationId(location.hash.slice(1) || null); } ``` * React ```tsx const [conversationId, setConversationId] = useState(null); ``` * Vue ```vue ``` * Svelte ```svelte (conversationId = e.detail)} /> ``` * Angular ```html ``` A conversation id opens that conversation in the chat, and `null` closes it. The first message sent with none open creates one and hands you its id, so a URL or a router can follow. From then on, the person’s messages are saved as they are sent, and a reply when the chat hears it end — `aparte-message-done`, `-error` or `-aborted`, which `AparteClient` and `injectTokenStream` send, and a plain-HTML loop of your own sends as in [Your own loop](/guides/bring-your-own-loop/#make-stop-work). A new version is saved as it appears. In plain HTML the controller does this work, and `bind()` returns the function that stops it; `setLoading`, `exportTree` and `importTree` are optional, and without one it does without the loading state or the branches. `aparte-conversation-select` with `{ id }`, sent on `window`, opens a conversation in every chat bound to a manager — or in one chat, when it also carries that chat’s `targetId` — and `{ id: null }` starts a new one, so a New chat button needs nothing else: `window.dispatchEvent(new CustomEvent('aparte-conversation-select', { detail: { id: null } }))`. The chat sends `aparte-conversation-loaded` with `{ targetId, conversationId }` once the messages are on screen. With a framework, let your `conversationId` follow those selections too — `window.addEventListener('aparte-conversation-select', (e) => setConversationId(e.detail.id))` — so that a later `null` is a change the chat sees. ## Show the list [Section titled “Show the list”](#show-the-list) ```ts const list = document.querySelector('aparte-conversation-list'); function render(): void { if (!list) return; list.conversations = manager.activeConversations; list.setAttribute('active-id', manager.activeId ?? ''); } manager.subscribe(render); render(); list?.setAttribute('manage', ''); ``` [``](/components/conversation-list/) draws the rows, grouped by date — `flat` lists them without the groups — each with a menu to rename, pin, archive or delete it. With `manage`, the menu acts on the manager you registered. A click on a row sends `aparte-conversation-select`, which opens it in the chats bound to the manager. Without `manage`, the list only announces each gesture, and you carry it out: ```ts list?.addEventListener('aparte-conversation-rename', (e) => manager.updateTitle(e.detail.id, e.detail.title)); list?.addEventListener('aparte-conversation-delete', (e) => manager.delete(e.detail.id)); ``` The others are `-pin`, `-unpin`, `-archive` and `-unarchive`, each with `{ id }`. Under `manage` they still come first and can be cancelled, which takes back that one gesture. ## Large histories [Section titled “Large histories”](#large-histories) [The list while the conversations load](/preview/demo/conversation-list-loading/) ```html ``` The list while the store answers. ```ts import type { AparteConversation, AparteConversationMeta } from '@aparte/core'; interface SplitLoading { loadMeta(): Promise; // the rows, without their messages loadFull(id: string): Promise; // one conversation, or null if unknown } ``` Give the adapter these two methods, and the manager lists the conversations through the first and fetches a conversation’s messages through the second the first time it opens. Meanwhile the chat draws two placeholder turns, tells a screen reader it is loading, and disables the composer until the messages arrive. Set `loading` on the list while `init()` runs, and clear it when you set `conversations`. ## Save the branches and the title [Section titled “Save the branches and the title”](#save-the-branches-and-the-title) The versions of every answer are saved with the conversation, as its `tree`, when the binding has `exportTree` and `importTree` — the framework packages always do — and opening it brings every version back, picker included. To keep a single conversation yourself, without a manager: ```ts import type { AparteChatViewport } from '@aparte/core'; declare const viewport: AparteChatViewport; localStorage.setItem('chat', JSON.stringify(viewport.exportTree())); // every version, as data ``` and later `viewport.importTree(JSON.parse(localStorage.getItem('chat') ?? 'null'))`, once you have checked there is something to restore. A conversation’s title comes from its first message, and `autoTitle` says the manager chose it: editing that message then gives it a new one, while a title typed in later — `updateTitle` — is never replaced. [Conversation titles](/guides/titles/) makes them short and meaningful. ## What comes back after a reload [Section titled “What comes back after a reload”](#what-comes-back-after-a-reload) | On a saved message | After a reload | | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------- | | your fields: `id`, `content`, `segments` and their `meta` | as you saved them | | `messageId` and `index` on a segment | worked out again from the list | | timings | as saved, and never invented when absent | | a reply that was streaming | finished, `isStreaming: false` | | a tool call `awaiting-approval`, or `pending` in a finished reply | `aborted`, unless it carries a [server loop](/guides/server-loop/)’s `deferred.ticket` | | the parts core read from a Markdown reply | not saved: read again from its `content` | Attachments do not survive the adapter above: `JSON.stringify` drops each attachment’s `blob`, and its `url` dies with the page. Save each `blob` where binary data fits, such as IndexedDB, as an `AparteAttachmentRow` with its conversation and message ids, and when you load a conversation give each attachment a new `URL.createObjectURL(row.blob)` ([Attachments](/guides/attachments/#release-memory-and-save-the-files)). ## Next [Section titled “Next”](#next) * [Conversation titles A short title for each conversation, in the browser or from your model.](/guides/titles/) * [Conversation list The list, its menu, its states and its options.](/components/conversation-list/) * [Sidebar The column the list goes in, and its drawer on a phone.](/components/sidebar/) # Retry, edit and switch between versions of an answer > Let people retry an answer without losing the first one, edit what they sent, and move between the versions with the built-in picker. [Retry keeps every version; edit asks again](/preview/demo/branches-retry/?play=My%20dashboard%20query%20is%20slow.%20What%20should%20I%20do%3F) ```html ``` Retry the reply, then walk the versions with the arrows. The model here is scripted: the first two retries each get a new answer. ## Turn on retry and edit [Section titled “Turn on retry and edit”](#turn-on-retry-and-edit) ```ts import { aparteGlobalConfig } from '@aparte/core'; aparteGlobalConfig.setBubbleActions({ retry: true, edit: true }); ``` Both buttons are off until you turn them on: retry shows on replies, edit on the person’s own messages. With `AparteClient` running — `useAparteClient` in React and Vue, `createAparteClient` in Svelte and `provideAparte()` in Angular start one — that line is all. [Buttons](/guides/customization/actions/) lists the others and their order. ## Retry keeps the old answer [Section titled “Retry keeps the old answer”](#retry-keeps-the-old-answer) 1. YouMy dashboard query is slow. What should I do? 1. Assistant Cache it: the query is the same on every load… version 1, kept 2. Assistant Batch it: send the ten lookups as one request… ‹ 2 / 2 ›, on screen A conversation is a tree. Retry adds a sibling under the same message; the chat shows one path through the tree. A retry asks the model again from the same point and puts the new answer beside the old one, not over it. As soon as a message has two versions, its footer shows the `‹ 2 / 2 ›` picker, and its arrows switch the version on screen with nothing for you to wire. `AparteClient` asks again at a sampling temperature of 0.4, whatever temperature the app set, so the new answer usually differs. Offered on the person’s own messages too — `setBubbleActions({ user: ['edit', 'retry', 'copy'] })` — a retry answers that message afresh. ## Edit a sent message [Section titled “Edit a sent message”](#edit-a-sent-message) The edit button opens the message in an editor: `Enter` saves, `Shift`+`Enter` starts a new line and `Escape` cancels, whatever the composer’s `submit-on-enter`. Saving a changed text sends `aparte-edit` with `{ messageId, content, targetId }` — an unchanged or empty one just closes the editor — and `AparteClient` changes the message in place, drops the replies under it and asks again. An edit does not branch: the old text and its answers are gone. To keep them, handle `aparte-edit` yourself and keep the old text in your own data; the built-in picker shows only on replies, so a switch between versions of a question is yours to draw. ## With your own loop [Section titled “With your own loop”](#with-your-own-loop) ```ts import type { AparteChatViewport, AparteMessage } from '@aparte/core'; declare const viewport: AparteChatViewport; declare function streamAnswer(history: AparteMessage[]): AsyncIterable; // your model async function answerInto(id: string): Promise { // The path on screen, ending in the new empty reply: everything before it is the history. const history = viewport.getMessages().slice(0, -1); for await (const chunk of streamAnswer(history)) viewport.appendToken(id, chunk); viewport.completeMessage(id); } document.addEventListener('aparte-retry', (event) => { const id = viewport.addSiblingOf(event.detail.messageId, { id: crypto.randomUUID(), role: 'assistant', content: '', timestamp: Date.now(), }); if (id) void answerInto(id); }); document.addEventListener('aparte-edit', (event) => { const { messageId, content } = event.detail; viewport.updateMessage(messageId, { content }); viewport.truncateResponsesAfter(messageId); const id = viewport.addSiblingOf(messageId, { id: crypto.randomUUID(), role: 'assistant', content: '', timestamp: Date.now(), }); if (id) void answerInto(id); }); ``` The buttons only send their event; without a handler, an edit puts the original text back. `addSiblingOf` reads the role of the message you give it: beside a reply it adds a version of that reply, under one of the person’s messages it adds an answer to it — which is how the edit handler gets its fresh reply. `truncateResponsesAfter` drops every version of the replies under the edited message, so the new one starts alone. In React, Vue, Svelte or Angular, the framework package owns the messages: call `addSiblingOf`, `updateMessage` and `truncateResponsesAfter` on the chat’s ref, and stream with its `injectTokenStream(id, tokens)` in place of `answerInto`. ## While a reply streams [Section titled “While a reply streams”](#while-a-reply-streams) The version arrows, and retry and edit on every message, are disabled until the reply is done; Stop stays live. `` carries `data-busy` meanwhile, for controls of your own: `aparte-chat:has(aparte-chat-viewport[data-busy]) .my-button { opacity: 0.5; }`. ## Restyle it or take it back [Section titled “Restyle it or take it back”](#restyle-it-or-take-it-back) ```ts import { aparteGlobalConfig } from '@aparte/core'; aparteGlobalConfig.setBubbleActions({ retry: false }); // keep edit, drop retry aparteGlobalConfig.setBubbleActions({ user: ['copy'] }); // your messages: copy only ``` A `user` or `assistant` list replaces the flags for that role: to drop retry there, leave it out of the list. The picker needs no switch of its own: it shows only on a message that has versions. Its counter can be drawn your way, dots for instance, with [`setSiblingNavRenderer`](/guides/customization/render-hooks/). ## Next [Section titled “Next”](#next) * [Save conversations Keep every version across a reload, with the rest of the conversation.](/guides/conversation-persistence/#save-the-branches-and-the-title) * [Branches The picker, its states and its options.](/components/branches/) * [Conversation The message list, and every method used above.](/components/conversation/) # Customize the chat > Change more than colours and sizes — which part of the chat each seam changes, when a change shows on the chats already on screen, and how two chats get different setups. [The same message, in two shells](/preview/demo/customization-bubble-shell/) ```html
``` The same message twice: core's message, then a frame of your own, given to the second one by its own configuration. ## Pick the seam [Section titled “Pick the seam”](#pick-the-seam) | You want to | Use | Page | | -------------------------------------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------- | | change colours, sizes and shapes | CSS variables | [Theme](/guides/theming/) | | replace the error card, an attachment chip, the status line | a render hook | [Render hooks](/guides/customization/render-hooks/) | | redraw the whole message | a frame of your own, or an element of your own | [The message](/guides/customization/bubble/) | | turn on retry and edit, add a button to a message or to the composer | the actions | [Buttons](/guides/customization/actions/) | | put a mode picker or a model selector under the composer | the composer’s toolbar | [The composer toolbar](/guides/customization/composer-toolbar/) | | show a chart, a map or a form in a reply | a segment renderer | [Your own content](/guides/customization/segments/) | | show “Thought for 8s” | the timing a segment carries | [Durations](/guides/customization/segment-timing/) | | swap the icons, add avatars | the icon and avatar providers | [Icons and avatars](/guides/customization/icons-and-avatars/) | | render Markdown and colour code | the Markdown and highlight plugins | [Markdown and code](/guides/markdown/) | ## Everything goes through the configuration [Section titled “Everything goes through the configuration”](#everything-goes-through-the-configuration) ```ts import { aparteGlobalConfig } from '@aparte/core'; aparteGlobalConfig.setErrorRenderer(({ message }) => { const el = document.createElement('div'); el.className = 'my-error'; el.textContent = message; return el; }); aparteGlobalConfig.setErrorRenderer(null); // the built-in one again ``` The render hooks, the providers and the button settings are methods on `aparteGlobalConfig`, the configuration every chat on the page reads; a render hook or the avatar provider takes `null` to put the built-in back. A change to the buttons, the icons, the avatars or the language reaches the messages already on screen, and a new status-line renderer reaches the line already shown. The message frame, the error card, the attachment chips, a segment renderer and the Markdown and highlight providers apply to what is drawn after them, so set those before the chat renders. ## Custom segment types [Section titled “Custom segment types”](#custom-segment-types) ```ts import { registerSegmentRenderer } from '@aparte/core'; registerSegmentRenderer({ type: 'chart', render(segment) { const el = document.createElement('div'); el.className = 'my-chart'; return el; }, }); ``` A reply is made of typed parts — text, reasoning, code, a tool call — called segments, and a renderer registered for a type of your own draws it: a chart, a map, a form, generative UI. A segment whose type no renderer claims shows its `fallback` text, or `[Unknown segment type: …]` with a warning in the console when it has none: register a renderer for the type, or give the segment a `fallback`. [Your own content](/guides/customization/segments/) shows how such a segment reaches a message, and the rest of a renderer’s life. ## Per-chat configuration [Section titled “Per-chat configuration”](#per-chat-configuration) ```ts import { AparteConfig, attachConfig } from '@aparte/core'; const support = new AparteConfig(); support.setBubbleActions({ retry: true, edit: true }); const chat = document.querySelector('#support-chat'); if (chat) attachConfig(chat, support); ``` Attach a configuration of its own to a chat, or to an element around it, and every part inside reads that one; `detachConfig` takes it off. The framework packages take it as their `config` prop. A new configuration starts from the built-in defaults: set up on it what that chat needs — its Markdown and highlighting (`setupMarkedProvider(undefined, support)`), its icons, its language. It inherits one thing from the page-wide one, the segment renderers registered there, and cannot remove one of those; `registerSegmentRenderer(renderer, support)` registers one for that chat alone, which wins over the page-wide one. ## Next [Section titled “Next”](#next) * [Render hooks Your own markup for one piece: the error card, an attachment chip, the status line.](/guides/customization/render-hooks/) * [Your own content A chart, a map or a form in a reply, and a tag the model writes turned into one.](/guides/customization/segments/) * [Buttons Retry, edit and feedback, and buttons of your own on a message or in the composer.](/guides/customization/actions/) * [Icons and avatars Your icon set, and an avatar beside each message.](/guides/customization/icons-and-avatars/) # Buttons on a message and in the composer > Turn on retry, edit, feedback and details, choose their order, answer them, and add buttons of your own to a message or to the composer. [Messages with their actions](/preview/demo/message-actions/) ```html ``` A message's buttons: copy is on by default, the rest are yours to turn on. ## Turn on the built-in buttons [Section titled “Turn on the built-in buttons”](#turn-on-the-built-in-buttons) ```ts import { aparteGlobalConfig } from '@aparte/core'; aparteGlobalConfig.setBubbleActions({ retry: true, edit: true, feedback: true, info: true }); ``` | Button | Shown on | Who answers it | On by default | | ------------------------------- | -------------------------- | --------------------------------------------------- | ------------- | | `copy` | every message | core | yes | | `retry` | replies | `AparteClient`, or your listener for `aparte-retry` | no | | `edit` | your messages | `AparteClient`, or your listener for `aparte-edit` | no | | `feedback`, a thumb up and down | replies | your listener (`aparte-feedback`) | no | | `info`, the details of a reply | replies that carry `usage` | your listener (`aparte-message-info`) | no | Only `copy` is on by default; turn on the buttons your app answers. Calls merge: each one changes the flags it names. `APARTE_DEFAULT_BUBBLE_ACTIONS` holds the defaults, and [Conversations and branches](/guides/conversations-branching/) shows what retry and edit do. ## Choose them and their order [Section titled “Choose them and their order”](#choose-them-and-their-order) ```ts import { aparteGlobalConfig, type AparteBubbleActionName } from '@aparte/core'; aparteGlobalConfig.setBubbleActions({ user: ['edit', 'copy'] satisfies AparteBubbleActionName[], assistant: ['copy', 'thumbUp', 'thumbDown', 'retry', 'info'] satisfies AparteBubbleActionName[], }); ``` A list for a role draws exactly those buttons, in that order, whatever the flags say: naming a button in a list turns it on. In a list, feedback is two buttons, `thumbUp` and `thumbDown`. ## Answer feedback and details [Section titled “Answer feedback and details”](#answer-feedback-and-details) ```ts document.addEventListener('aparte-feedback', (event) => { const { messageId, value } = event.detail; // value: 'positive' or 'negative' void fetch('/api/feedback', { method: 'POST', body: JSON.stringify({ messageId, value }) }); }); document.addEventListener('aparte-message-info', (event) => { openDetails(event.detail.messageId, event.detail.usage); }); ``` The events bubble out of the chat, so one listener on `document` answers every chat on the page. ## Let image tiles open a preview [Section titled “Let image tiles open a preview”](#let-image-tiles-open-a-preview) ```ts import { aparteGlobalConfig } from '@aparte/core'; declare function openLightbox(url: string, name: string): void; aparteGlobalConfig.setHostHandlers({ attachmentPreview: true }); document.addEventListener('aparte-attachment-preview', (event) => { openLightbox(event.detail.url, event.detail.name); }); ``` Declared, an attached image becomes a button that sends `aparte-attachment-preview`, with the image’s `url` and `name`; undeclared, it is a picture and nothing more. A tile of your own, drawn by [`setAttachmentRenderer`](/guides/customization/render-hooks/), gets none of this: send the event from your element yourself. ## Add a button to a message [Section titled “Add a button to a message”](#add-a-button-to-a-message) ```ts import { aparteGlobalConfig } from '@aparte/core'; declare function shareMessage(messageId: string | undefined): void; aparteGlobalConfig.registerAction({ id: 'share', icon: 'download', label: 'Share', zones: ['bubble'], bubble: { roles: ['assistant'] }, }); document.addEventListener('aparte-action', (event) => { const { actionId, messageId } = event.detail; if (actionId === 'share') shareMessage(messageId); }); ``` A registered action follows the built-in buttons, sorted by its `order`. `icon` is one of core’s [icon names](/reference/icons/), or SVG markup when it starts with `<` — pass the markup for any glyph core does not have: a name it does not know draws the action’s `iconFallback`, or nothing. `label` is the button’s accessible name, so an icon alone always has one. A click sends `aparte-action` with `{ actionId, zone, messageId, role, targetId }`, `targetId` being the chat’s id, and calls the action’s `onClick` if you gave one; `unregisterAction(id)` removes the button. The framework packages hand you the same detail: `onAction` in React, `@action` in Vue, `on:action` in Svelte, `(action)` in Angular. ## Add a button to the composer [Section titled “Add a button to the composer”](#add-a-button-to-the-composer) ```html
``` ```ts document.addEventListener('aparte-action-click', (event) => { if (event.detail.actionId === 'pin') pinDraft(); }); ``` [``](/components/composer/) sits where you write it, anywhere inside the composer, and greys out while a reply streams. `action-id` comes back on the event, which tells two of them apart, and `icon` takes a core icon name or SVG markup. Inside an ``, write the `` too, as [the composer toolbar](/guides/customization/composer-toolbar/) shows: a chat with no message list inside replaces its children with the default chat. The framework packages draw their own composer row, so put the button in their `toolbar` slot. For sending, stopping and attaching, the composer’s own elements already do the work: ``, `` and ``. ## Next [Section titled “Next”](#next) * [Message actions The buttons, live, in every state.](/components/message-actions/) * [The composer toolbar A row of controls under the composer.](/guides/customization/composer-toolbar/) # Redraw the whole message > A frame of your own around core's message, or your own element in its place — in a framework or in plain HTML. ## A frame of your own [Section titled “A frame of your own”](#a-frame-of-your-own) ```ts import { aparteGlobalConfig, escapeAttr, escapeHtml } from '@aparte/core'; aparteGlobalConfig.setBubbleShellRenderer(({ role, name }) => `
${escapeHtml(name)}
`); ``` The message stays core’s — it streams, keeps its buttons and its versions — and draws itself into your markup, finding each region by its class. The renderer receives `{ role, name, avatarInitial }`. What the frame must carry: * **`.aparte-message`** as its root. The message writes its role and its accessible name there, unless your root already has a `role`. * **`.aparte-message-content`**, holding `.aparte-segments`, `.aparte-content` and `.aparte-waiting`. * **`.aparte-waiting`**, with `.aparte-dots` around three `.aparte-dot` and an `.aparte-sr-only` label: the message shows and hides it, the dots are your markup. Leave it out and the message shows no waiting state. * **`.aparte-branch-picker`**, if the message can have versions, written `hidden` with the four parts above: the message shows it once there are two versions, and fills the label and the status. Keep `aria-live` on the status, the one thing a screen reader hears when the version changes; the arrows’ labels and glyphs are yours. * **`.aparte-action-bar`**, with `role="toolbar"` and a label of yours: the message puts its buttons there, and they share one tab stop with the arrow keys between them. * **`.aparte-name`**, holding the `name` you are given: the message updates it when the name or the language changes. Any of `.aparte-avatar`, `.aparte-timestamp`, `.aparte-attachments` and `.aparte-footer` that you want filled. A frame for one chat only goes on that chat’s own configuration ([Per-chat configuration](/guides/customization/#per-chat-configuration)). A message draws its frame once, when it appears, so a chat’s frame reaches the messages that appear after its configuration is attached: with a framework’s `config` prop, the messages there at the first render keep the page-wide frame. ## Your own element, in a framework [Section titled “Your own element, in a framework”](#your-own-element-in-a-framework) * React ```tsx } /> ``` * Vue ```vue ``` * Svelte ```svelte ``` * Angular ```html ``` Each message is then drawn by your component, from `message.content` and `message.segments`, which update while the reply streams. The buttons and the versions belong to core’s message: an element of your own draws and wires whatever it needs. ## Your own element, in plain HTML [Section titled “Your own element, in plain HTML”](#your-own-element-in-plain-html) ```html ``` ```ts const viewport = document.querySelector('aparte-chat-viewport'); viewport?.appendToken('m-42', 'Hello'); // reaches viewport?.completeMessage('m-42'); ``` Without a framework there is no reactive list, so the message list also calls your element: an element inside it carrying `data-aparte-bubble` and the message’s `message-id` receives what it would send core’s message — `appendToken`, `appendToSegment`, `addSegment`, `updateSegment`, `removeSegment`, `setUsage` and `updateMessage`. Implement the ones you need, and stream into it by its id from a loop of your own: `appendMessage` draws core’s message for that id beside yours. Three limits: `clearAll()` removes core’s messages only, and leaves yours behind; a retry, a move to another version of a reply, or loading a history rebuilds the list with core’s messages, so yours is replaced; and a client that appends each new reply draws core’s message for it. To draw every message yourself, set `framework-managed` on the viewport and add one element per message, as the framework packages do. The framework hooks above have none of these limits. ## Next [Section titled “Next”](#next) * [Render hooks Replace one piece instead of the whole frame.](/guides/customization/render-hooks/) * [Message Core's message, its parts and its options.](/components/message/) # Put controls under the composer > The row under the composer, for a mode picker, a model selector or a counter — in plain HTML and in each framework — and the slot above it for a banner. [A composer with a toolbar](/preview/demo/composer-toolbar/) ```html
``` Two buttons of your own, and the model selector pushed to the end of the toolbar. ## The toolbar [Section titled “The toolbar”](#the-toolbar) * Vanilla ```html
``` * React ```tsx } /> ``` * Vue ```vue ``` * Svelte ```svelte ``` * Angular ```html ``` [``](/components/composer/) is the row under the input. Its controls sit in the order you write them, and `margin-inline-start: auto` pushes one, with everything after it, to the end — on the right, or on the left in a right-to-left language. An empty toolbar draws nothing. The mode picker stands for a control of your own. The default chat has no toolbar. In plain HTML, write the whole chat: the message list, the `` panel where [questions and approvals](/guides/elicitation/) appear, and the composer. An `` with no message list inside replaces its children with the default chat, toolbar and all. The framework packages draw the row from their `toolbar` slot; in Angular, each node you project carries `slot="toolbar"` itself. The slot belongs to the default composer, so a composer of your own, put in through the `composer` slot, writes its own ``. `` comes from `@aparte/plugin-model-selector`, and draws nothing until that package is imported. Its `/react`, `/vue` and `/svelte` entries type the tag, and `/angular` exports `AparteModelSelectorDirective` for a standalone component’s `imports`; `persist` makes its pick the model the next message goes to. The [model selector](/components/model-selector/) and the [approval mode](/components/approval-mode/) are two controls made for this row. ## Above the composer [Section titled “Above the composer”](#above-the-composer) * Vanilla ```html

Replies can be wrong. Check what matters.

``` * React ```tsx Replies can be wrong. Check what matters.

} /> ``` * Vue ```vue ``` * Svelte ```svelte

Replies can be wrong. Check what matters.

``` * Angular ```html

Replies can be wrong. Check what matters.

``` Between the messages and the composer goes a banner, a row of suggestions or a notice. In plain HTML it is one more child of the chat, before the composer; the framework packages take it as `aboveComposer` in React and the `above-composer` slot elsewhere. The [Wrapper surface](/reference/wrappers/) lists every slot in each framework’s syntax. ## Next [Section titled “Next”](#next) * [Buttons A button of your own inside the composer row.](/guides/customization/actions/) * [Composer Every part of the composer, live.](/components/composer/) # Swap the icons and add avatars > Your own icon set, the extra glyphs core ships, an avatar beside each message, and an element of your own kept in sync when the icons or the language change. ## Your own icons [Section titled “Your own icons”](#your-own-icons) ```ts import { aparteGlobalConfig } from '@aparte/core'; aparteGlobalConfig.setIconProvider({ copy: () => '', send: () => '', }); ``` Give the icons you replace, by name; the others keep core’s. Each call replaces the whole set, so give every icon you replace in one call. A value is markup you trust: an SVG, an icon font’s element, an emoji, an ``. An SVG or an `` with `class="aparte-icon"` takes its size from the place it sits, through `--aparte-icon-size`, as core’s glyphs do; an icon font or an emoji is text, and follows the `font-size` around it. The [icon names](/reference/icons/) are listed with every glyph; the inline message editor’s save and cancel use `check` and `close`. ## The extra glyphs [Section titled “The extra glyphs”](#the-extra-glyphs) ```ts import { aparteGlobalConfig, type AparteIconProvider } from '@aparte/core'; import { historyIcon, sparklesIcon } from '@aparte/core/icons'; declare const myIcons: AparteIconProvider; // the set you already gave aparteGlobalConfig.setIconProvider({ ...myIcons, retry: () => historyIcon }); document.querySelector('#new-chat')?.insertAdjacentHTML('afterbegin', sparklesIcon); ``` `@aparte/core/icons` holds glyphs core does not draw itself, each imported by name, so a bundle carries the ones you use. Give one to a name core knows, or put it in your own markup. ## Avatars [Section titled “Avatars”](#avatars) [Messages with avatars](/preview/demo/message-avatars/) ```html ``` An avatar beside each message, drawn by the avatar provider. ```ts import { aparteGlobalConfig } from '@aparte/core'; aparteGlobalConfig.setAvatarProvider({ render(role, host) { host.setAttribute('aria-hidden', 'true'); // the message already names its author host.textContent = role === 'assistant' ? 'AI' : 'Me'; }, }); ``` A message has no avatar until you set a provider: the slot shows once `render` fills it, and `setAvatarProvider(null)` stops them on the messages drawn after. Core empties the slot and calls `render` again whenever the configuration changes — a new language, a new icon set — or the message’s role does. If `render` returns a function, core calls it before each new render and when the message goes away, so you can unmount a component you put there. `--aparte-avatar-image-user` and `--aparte-avatar-image-assistant` paint a picture in the slot, and `--aparte-avatar-bg-user` and `--aparte-avatar-bg-assistant` the background behind what your provider writes. For a picture alone, the provider still puts something in the slot, which shows only when filled: `host.innerHTML = ''`. ## Keep your own element in sync with the icons and language [Section titled “Keep your own element in sync with the icons and language”](#keep-your-own-element-in-sync-with-the-icons-and-language) ```ts import { subscribeConfigChange, resolveConfig } from '@aparte/core'; class MySendButton extends HTMLElement { private off: (() => void) | null = null; connectedCallback(): void { this.innerHTML = ''; this.paint(); this.off = subscribeConfigChange(this, () => this.paint()); } disconnectedCallback(): void { this.off?.(); this.off = null; } private paint(): void { const button = this.querySelector('button'); if (!button) return; const config = resolveConfig(this); button.innerHTML = config.getIcon('send'); button.setAttribute('aria-label', config.t('sendButton')); } } customElements.define('my-send-button', MySendButton); ``` `subscribeConfigChange` calls you back when the configuration that governs your element changes — a new icon set, another language — and returns the function that stops it. `resolveConfig(this)` reads that configuration: the chat’s own when the chat has one, the page-wide one otherwise, so place `` inside the chat it should follow. Repaint the parts that change, rather than rebuilding the element, so its focus and its listeners stay. ## Next [Section titled “Next”](#next) * [Icons Every glyph, its name, and the extra set.](/reference/icons/) * [Translate the chat The labels your element reads with t().](/guides/localization/) # Replace one piece of the chat > Your own markup for the error card, an attachment chip, the status line and the position between the version arrows — and the waiting dots, restyled. ## Return an element [Section titled “Return an element”](#return-an-element) ```ts import { aparteGlobalConfig } from '@aparte/core'; aparteGlobalConfig.setErrorRenderer(({ message, details }) => { const el = document.createElement('div'); el.className = 'my-error'; el.textContent = details ? `${message} (${details})` : message; return el; }); aparteGlobalConfig.setAttachmentRenderer((attachment) => { const el = document.createElement('span'); el.className = 'my-chip'; el.textContent = attachment.name; return el; }); ``` | Hook | Replaces | Receives | | ------------------------ | ------------------------------------------------------ | ------------------------------------------------ | | `setErrorRenderer` | the error card in a reply | `{ message, details }` | | `setAttachmentRenderer` | one attachment’s chip | the attachment | | `setSiblingNavRenderer` | the position between the version arrows, such as 2/3 | `{ count, index }` | | `setStatusRenderer` | the inside of your status line, `` | its text | | `setBubbleShellRenderer` | the frame of the whole message | see [The message](/guides/customization/bubble/) | Each hook takes a function that returns an element or a string, and `null` puts the built-in back. A string holds one root element for the error and attachment hooks: plain text draws nothing there. A hook replaces markup only: the version arrows and the event they send stay core’s, and so does the status line’s showing and hiding. Set the error and attachment hooks before the chat renders — a message already drawn keeps the markup it was drawn with. The status line redraws at once, and the position between the arrows at its next change. `setStatusRenderer` receives the line’s text when the line draws, `'Typing'` when it has none. A later change of that text does not call it again, so an element of yours that shows live text reads it from your own state. ## If you return a string [Section titled “If you return a string”](#if-you-return-a-string) ```ts import { aparteGlobalConfig, escapeHtml, escapeAttr } from '@aparte/core'; aparteGlobalConfig.setAttachmentRenderer((attachment) => ` ${escapeHtml(attachment.name)} `); ``` A string is inserted as HTML, without sanitizing: a file name or a model’s message written into it unescaped can run script on your page. Escape every value — `escapeHtml` between tags, `escapeAttr` inside an attribute written in quotes, never an unquoted one — or return an element and set its `textContent`. To put a value in a CSS selector, use `cssEscape`. ## A chip of yours is yours to open [Section titled “A chip of yours is yours to open”](#a-chip-of-yours-is-yours-to-open) ```ts import { aparteGlobalConfig } from '@aparte/core'; aparteGlobalConfig.setAttachmentRenderer((attachment) => { const button = document.createElement('button'); button.type = 'button'; button.className = 'my-chip'; button.textContent = attachment.name; button.addEventListener('click', () => { button.dispatchEvent(new CustomEvent('aparte-attachment-preview', { bubbles: true, composed: true, detail: { url: attachment.url, name: attachment.name }, })); }); return button; }); ``` The built-in image tile becomes a button that asks for a preview, with `aparte-attachment-preview`, once you declare that you answer it ([Buttons](/guides/customization/actions/#let-image-tiles-open-a-preview)). A chip you draw yourself gets none of that: make it a button and send the event, or open your own preview from it. ## The waiting dots [Section titled “The waiting dots”](#the-waiting-dots) While a reply has nothing in it yet, its message shows three dots, with a label a screen reader reads, and drops them with the first word. It decides from the message itself: `status: 'streaming'` or `'pending'`, or an assistant message with no status and nothing in it — so an empty message you mean as finished needs `status: 'completed'`. `isAwaitingReply(message)` gives the same answer to your own code. ```css aparte-chat.chat { --aparte-waiting-height: 1.5rem; --aparte-waiting-dot-gap: 6px; --aparte-status-dot-size: 7px; --aparte-status-color: var(--aparte-primary); } ``` Set them on a chat given `class="chat"` — `[data-aparte-chat].chat` in React, Vue and Svelte, as the [Theme](/guides/theming/#small-recipes) recipes do. The dot’s size and colour are the status line’s dot too. The dots stop moving under reduced motion. To draw them differently, give the message a frame of your own, where the dots are your markup ([The message](/guides/customization/bubble/)). The status line, ``, is something else: a channel for your own status, such as searching the web or reading the files. Core never turns it on. The framework packages expose it as `isTyping` and `typingText`, and turn it off when the first word of the reply arrives; `setStatusRenderer` draws its inside. ## Next [Section titled “Next”](#next) * [The message A frame of your own around the message, or your own element in its place.](/guides/customization/bubble/) * [Buttons Retry, edit, feedback and buttons of your own.](/guides/customization/actions/) # Show how long a segment took > Write "Thought for 8s" on a reasoning block or a tool call from the timing every segment carries, and attach numbers of your own to a segment. ## What a segment carries [Section titled “What a segment carries”](#what-a-segment-carries) | Field | What it holds | | ----------------------- | ------------------------------------------------------------------ | | `messageId` | the message it belongs to | | `index` | its place in that message’s `segments` | | `meta.aparte.startedAt` | when it arrived, in milliseconds since the epoch | | `meta.aparte.endedAt` | when its content last arrived — it moves while the segment streams | | `meta` | yours: token counts, cost, anything else | Core measures the span and draws none of it: you write the label. Read it with `segmentDuration(segment)`, ask `isSegmentSettled(segment)` whether it is final, and take the two numbers with `segmentTiming(segment)`. A segment you built by hand has not arrived yet, so it has no start. While a segment streams, its end moves with each piece of content, so the duration grows until the segment settles. A segment restored from storage keeps what you saved with it. ## Write the duration on a reasoning block [Section titled “Write the duration on a reasoning block”](#write-the-duration-on-a-reasoning-block) ```ts import { getSegmentRenderer, registerSegmentRenderer, installDefaultRenderersOnce, isSegmentSettled, segmentDuration } from '@aparte/core'; import type { AparteThinkingSegment } from '@aparte/core'; installDefaultRenderersOnce(); const builtIn = getSegmentRenderer('thinking')!; function parseRoot(html: string): HTMLElement { const t = document.createElement('template'); t.innerHTML = html; return t.content.firstElementChild as HTMLElement; } function writeDuration(host: HTMLElement, segment: AparteThinkingSegment): void { if (!isSegmentSettled(segment)) return; const ms = segmentDuration(segment); if (ms === undefined) return; const label = host.querySelector('.aparte-thinking-label'); if (label) label.textContent = ms < 1000 ? 'Thought for <1s' : `Thought for ${(ms / 1000).toFixed(1)}s`; } registerSegmentRenderer({ type: 'thinking', render(segment) { const out = builtIn.render(segment); const host = typeof out === 'string' ? parseRoot(out) : out; writeDuration(host, segment); return host; }, setup: (el, segment) => builtIn.setup?.(el, segment), update(el, segment) { builtIn.update?.(el, segment); writeDuration(el, segment); }, relabel(el, segment) { builtIn.relabel?.(el, segment); writeDuration(el, segment); }, }); ``` The built-in renderer keeps drawing the block; yours rewrites its label. Three places, because a block appears with `render`, settles later through `update`, and gets the built-in’s label back on a language switch through `relabel` — a label written in `render` only would read “Thinking” for good. The example writes English: take the words from your own translations inside `writeDuration`. Keep the root the built-in returned, without a wrapper of yours around it. For a chat with its own configuration, pass it to `getSegmentRenderer` and `registerSegmentRenderer` as their last argument. A block settles as soon as the stream closes it — its closing tag, or the first word of the answer for reasoning sent on its own channel — so the duration shows while the answer is still streaming. On a loop of your own, a segment settles when you update it with `isStreaming: false` or complete its message. ## Write the duration on a tool call [Section titled “Write the duration on a tool call”](#write-the-duration-on-a-tool-call) ```ts import { aparteGlobalConfig, isSegmentSettled, segmentDuration } from '@aparte/core'; aparteGlobalConfig.registerToolSummary('read_file', (segment) => { const ms = segmentDuration(segment); return isSegmentSettled(segment) && ms !== undefined ? `${(ms / 1000).toFixed(1)}s` : ''; }); ``` A tool’s summary is the line the default row writes after the tool’s name, read again each time the call changes. A call settles once its status leaves `pending` and `awaiting-approval`, so its span runs from the moment the call appears to its result, the time a person took to approve it included. ## Attach numbers of your own [Section titled “Attach numbers of your own”](#attach-numbers-of-your-own) ```ts import type { AparteChatImperativeApi } from '@aparte/core'; declare const chat: AparteChatImperativeApi; const last = chat.getMessages().at(-1); const thinking = last?.segments?.find((segment) => segment.type === 'thinking'); if (thinking) chat.updateSegment(thinking.id, { meta: { outputTokens: 1_240, device: 'webgpu' } }); ``` `getMessages()` returns the segments with everything on them, and `updateSegment` merges `meta`, so a key of yours never erases the timing. On a framework chat it reaches the last message; the viewport’s `updateSegment(messageId, segmentId, updates)` reaches any. Core has no channel from a provider to a segment’s `meta`: write it from your app, after the turn. ## Next [Section titled “Next”](#next) * [Reasoning The reasoning block, live, open and closed.](/components/reasoning/) * [Your own content Renderers of your own, and the four callbacks they answer.](/guides/customization/segments/) # Show your own content in a reply > A chart, a map or a form inside a message — the renderer that draws it, the ways it reaches a message, a tag the model writes turned into one, and defaults per type. [A view of your own in a reply](/preview/demo/custom-view/) ```html ``` A segment of a type of its own, drawn by its renderer inside the reply. ## Register a renderer [Section titled “Register a renderer”](#register-a-renderer) ```ts import { registerSegmentRenderer } from '@aparte/core'; import type { AparteSegment, AparteSegmentBase } from '@aparte/core'; interface ChartSegment extends AparteSegmentBase { type: 'chart'; values: number[]; } registerSegmentRenderer({ type: 'chart', render(segment) { const el = document.createElement('div'); el.className = 'my-chart'; el.textContent = segment.values.join(' · '); return el; }, getStyles: () => '.my-chart { font-variant-numeric: tabular-nums; }', }); // A chart in the reply a1, once it is on screen: const chart: ChartSegment = { id: 'c1', type: 'chart', values: [3, 5, 2] }; document.querySelector('aparte-chat-viewport')?.addSegment('a1', chart as unknown as AparteSegment); ``` A reply is a list of typed parts, segments, and a renderer draws every segment of its type. It returns an element, or a string of markup with a single root element that you escape yourself. `getStyles()` hands the renderer’s CSS to the page once. The methods that take a segment are typed for core’s own types, hence the cast for one of yours. ## How a segment reaches a message [Section titled “How a segment reaches a message”](#how-a-segment-reaches-a-message) * **In the message itself**: a message you add or restore with `segments: [...]` draws each one. * **Added later**: `viewport.addSegment(messageId, segment)` puts one into a message on screen, during a reply or after it; on a framework chat, `chat.addSegment(segment)` adds it to the last message. * **From a tag the model writes**, such as `…`: see [Turn a tag into a segment](#turn-a-tag-into-a-segment). * **From a tool’s result**: a tool renderer draws a tool call the way a segment renderer draws a segment ([Tools](/guides/tools/#custom-tool-renderer)). ## One type, several views [Section titled “One type, several views”](#one-type-several-views) ```ts import { registerSegmentRenderer, type AparteCustomSegment } from '@aparte/core'; declare function renderWeather(data: unknown): HTMLElement; declare function renderOnboarding(data: unknown): HTMLElement; registerSegmentRenderer({ type: 'custom', render(segment) { switch (segment.subType) { case 'weather': return renderWeather(segment.data); case 'onboarding': return renderOnboarding(segment.data); default: { const el = document.createElement('p'); el.textContent = segment.fallback ?? ''; return el; } } }, }); ``` The built-in `custom` type carries a `subType` and its `data`, so one renderer can draw a family of small views. A configuration has one renderer per type, so two sets of views cannot both own `custom`: give a set of your own its own `type`. Any segment can carry a `fallback`, a sentence drawn as text when no renderer claims its type — in an app that does not know your view, or a conversation replayed elsewhere. Without one, the chat shows `[Unknown segment type: chart]` and warns in the console. ## What a renderer is called for [Section titled “What a renderer is called for”](#what-a-renderer-is-called-for) | Callback | When | Rule | | --------- | ------------------------------------------------------------------------ | -------------------------------------------- | | `render` | the segment appears, and again on every change when there is no `update` | returns the element, or markup | | `setup` | right after `render` | wire listeners | | `update` | the segment changes: new content, settling, a field you update | change what is there; add or remove no child | | `relabel` | the language or the icon set changed | the same rule as `update` | Keep the root that `render` returned: the message finds a segment by it to update it in place. Implement `relabel` when your segment shows words or icons that come from the configuration. ## Turn a tag into a segment [Section titled “Turn a tag into a segment”](#turn-a-tag-into-a-segment) ```ts import { aparteGlobalConfig, isSafeUrl, registerSegmentRenderer } from '@aparte/core'; import type { AparteSegmentBase, AparteStreamBlockMatch } from '@aparte/core'; interface CitationSegment extends AparteSegmentBase { type: 'citation'; url: string; content: string; } aparteGlobalConfig.registerStreamBlock({ tag: 'cite', toSegment: ({ attrs, id }: AparteStreamBlockMatch): CitationSegment => ({ id, type: 'citation', url: attrs['url'] ?? '', content: '' }), }); registerSegmentRenderer({ type: 'citation', render(segment) { const a = document.createElement('a'); a.href = segment.url && isSafeUrl(segment.url, 'a') ? segment.url : '#'; a.textContent = segment.content; return a; }, }); ``` When the model writes `…`, the stream parser calls `toSegment` at the opening tag, with the attributes as written and an id, and streams the tag’s body into `content` until the closing tag. The attributes come from the model: treat them as untrusted, and pass an address through `isSafeUrl` before it becomes a link. The tag matches whole and by case — `` is not `` — and a tag registered while the model is writing applies from its next turn. `unregisterStreamBlock(tag)` removes it. Tags belong to a configuration: a chat with its own reads the tags registered on that one. ## Defaults for a type [Section titled “Defaults for a type”](#defaults-for-a-type) ```ts import { aparteGlobalConfig } from '@aparte/core'; aparteGlobalConfig.setSegmentDefaults('thinking', { collapsed: false }); // a block arrives open aparteGlobalConfig.setSegmentDefaults('chart', { values: [] }); ``` A default fills the fields a segment arrives without: one the producer set, even to `undefined`, keeps its value, and the identity fields — `id`, `type`, `messageId`, `index` — are never defaulted. Reasoning a provider sends on its own channel arrives folded, and folds again when the answer starts, whatever the default says. Defaults apply as segments arrive in a live reply or are added to one; a restored conversation keeps its segments as saved, and a segment already on screen does not change. A chat with its own configuration reads that configuration’s defaults. `getSegmentDefaults` reads them back and `clearSegmentDefaults` drops them. ## Drive the registry [Section titled “Drive the registry”](#drive-the-registry) Draw every type yourself, before the first message renders: ```ts import { declineDefaultRenderers } from '@aparte/core'; declineDefaultRenderers(); ``` Or install the built-ins now, which the first message would do, and list what is registered — in Node too: ```ts import { installDefaultRenderersOnce, getAllRenderers } from '@aparte/core'; installDefaultRenderersOnce(); const types = getAllRenderers().map((r) => r.type); ``` Declined, the built-ins stay out for good, so text, reasoning, code, errors and tool calls need renderers of yours. Each function takes a configuration as its last argument, to act on one chat’s registry. `new AparteClient({ autoRegister: false })` declines the built-ins the same way. ## Next [Section titled “Next”](#next) * [Custom view A segment of your own, live, with its variants.](/components/custom-view/) * [Durations How long a segment took, and numbers of your own on it.](/guides/customization/segment-timing/) # Let a coding agent read these docs > Point a coding agent at the docs — the llms.txt index and its files, one per topic, or @aparte/docs-mcp, an MCP server that searches and reads them. ## Give it the index [Section titled “Give it the index”](#give-it-the-index) [`/llms.txt`](/llms.txt) is the library in a paragraph and a file per topic, each named in a sentence. Hand the index to a model that can fetch, or only the file of the topic at hand: | Topic | Holds | | ------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Getting started, frameworks and guides](/_llms-txt/getting-started-frameworks-and-guides.txt) | Install, first render in vanilla or a framework, and every guide (theming, tools, persistence, branching, localisation, accessibility, troubleshooting). | | [Tools, approval and asking the user](/_llms-txt/tools-approval-and-asking-the-user.txt) | Registering tools, the tool-call row, human-in-the-loop approval, typed questions to the user (elicitation), and the ask-user plugin. | | [Run the agent loop on your server](/_llms-txt/run-the-agent-loop-on-your-server.txt) | createAparteAgentHandler on the server and createAgentStreamRunner in the page: tools run next to your server, approvals cross the network and survive a reload, browser tools still run in the page. | | [Bring your own loop (display-only)](/_llms-txt/bring-your-own-loop-display-only.txt) | Driving the transcript from a loop you own: appendMessage, segments, token streams, finishing a turn, the wrappers' imperative API, the backend transport. | | [Theming and the UI kit](/_llms-txt/theming-and-the-ui-kit.txt) | CSS variables, dark mode, the customization hooks, and the kit of plain classes (buttons, fields, alerts, tags, menus, tabs) with their HTML. | | [Providers and transports](/_llms-txt/providers-and-transports.txt) | OpenAI-compatible endpoints, the AI SDK bridge, a model in the browser, the scripted scenario provider, local models, writing your own provider. | | [Reference: components, segments, events, config](/_llms-txt/reference-components-segments-events-config.txt) | Every custom element with its attributes, events and CSS properties; every segment shape; the event map; the config object; the icons. | [`/llms-small.txt`](/llms-small.txt) is every page but the element pages and the generated references — classes, CSS variables, events, icons — shortened, for a small context window; [`/llms-full.txt`](/llms-full.txt) is everything. ## Or give it the MCP server [Section titled “Or give it the MCP server”](#or-give-it-the-mcp-server) ```bash claude mcp add aparte-docs -- npx -y @aparte/docs-mcp ``` ```json { "mcpServers": { "aparte-docs": { "command": "npx", "args": ["-y", "@aparte/docs-mcp"] } } } ``` A client that speaks the Model Context Protocol — Claude Code, Claude Desktop, Cursor and the others — gets the same text through four tools: | Tool | Ask it for | | ------------- | --------------------------------------------------------------------------------------------------- | | `list_sets` | the topics, a sentence each: start here | | `search_docs` | the pages that match plain words — `"approve a tool call"`, `"systemPrompt false"` — with a snippet | | `get_page` | one page’s Markdown, by its title or a distinctive part of it | | `get_set` | a whole topic at once | The server reads , so it serves the docs online, and it needs the network. `APARTE_DOCS_URL` points it at another docs build, served over http — for an offline or a staging setup: `claude mcp add aparte-docs -e APARTE_DOCS_URL=http://localhost:4321 -- npx -y @aparte/docs-mcp`, or `"env": { "APARTE_DOCS_URL": "http://localhost:4321" }` in the JSON entry. To embed it in a host of yours, `createDocsMcpServer({ baseUrl })` returns the `McpServer`, and `DocsSource`, `parseIndex`, `splitPages` and `searchPages` give the text without the protocol. ## Next [Section titled “Next”](#next) * [Troubleshooting The errors people meet first, and what each one means.](/guides/troubleshooting/) * [Installation and first chat What an agent should start from.](/guides/getting-started/) # Ask the user a question > Pause a run and get a choice, a yes or no, some text or a small form from the person — or a decision between options of yours — in a panel at the composer. [A question with choices, asked at the composer](/preview/demo/question/) ```html ``` A question asked at the composer: a single choice, answered by its click. ## Ask one thing [Section titled “Ask one thing”](#ask-one-thing) ```ts import { requestUserInput } from '@aparte/core'; const answer = await requestUserInput({ message: 'Which environment should I deploy to?', schema: { type: 'enum', options: [ { value: 'staging', label: 'Staging', recommended: true }, { value: 'prod', label: 'Production', description: 'Live traffic' }, ], }, }); if (answer.action === 'accept') { const env = answer.content as string; // 'staging', 'prod', or what the person wrote instead } ``` `requestUserInput` asks in a panel at the composer and resolves with the answer. It needs no client: call it from a tool handler, a button, anywhere. `message` can be a function, so the question follows a change of language while it waits. | `schema.type` | Drawn as | `content` on accept | | ------------- | -------------------------------------------------- | ----------------------- | | `enum` | one choice, or several with `multiple: true` | `string`, or `string[]` | | `boolean` | two choices, labelled `trueLabel` and `falseLabel` | `boolean` | | `string` | one line, or a box with `multiline: true` | `string` | A single choice — an `enum` without `multiple` or `default`, a `boolean` without `default` — is a row of buttons, and a click answers it. With a `default`, or after `aparteGlobalConfig.setElicitationOptions({ answerOnClick: false })`, it is radios, which the composer’s button sends; `multiple: true` makes checkboxes. A field also takes a `title`, a `description` and a `default`; a `string` takes a `placeholder`, `required`, `minLength` and `maxLength`. An `enum` offers an **Other…** entry for an answer of the person’s own, which `allowOther: false` on the field closes; `aparteGlobalConfig.setElicitationOptions({ allowOther: false })` closes it everywhere, and a field’s own setting wins. ## Ask several things [Section titled “Ask several things”](#ask-several-things) ```ts import { requestUserInput } from '@aparte/core'; const answer = await requestUserInput({ message: 'Open a pull request?', schema: { type: 'object', properties: { title: { type: 'string', header: 'Title', title: 'The title', placeholder: 'Fix the abort path' }, body: { type: 'string', header: 'Body', title: 'The description', multiline: true }, draft: { type: 'boolean', header: 'Draft', title: 'Open it as a draft?', default: true }, }, required: ['title'], }, }); if (answer.action === 'accept') { const { title, body, draft } = answer.content as { title: string; body: string; draft: boolean }; } ``` [Several questions in one request](/preview/demo/question-form/) ```html ``` Several questions, one at a time, with a chip for each. An `object` schema asks each of its fields, and `content` comes back keyed the same way. The questions come one at a time, with a chip above for each — its `header`, a word or two — that moves between them and marks the ones answered; the composer’s button sends the whole answer once every required question has one. `required` lists the questions that must be answered — leave it out and all of them must — and a text left empty comes back as `''`. `aparteGlobalConfig.setElicitationOptions({ layout: 'stacked' })` shows the questions all at once, as a form. ## When there is no answer [Section titled “When there is no answer”](#when-there-is-no-answer) ```ts import { requestUserInput, AparteElicitationAbortError } from '@aparte/core'; try { const answer = await requestUserInput({ message: 'Delete the branch?', schema: { type: 'boolean' } }); if (answer.action === 'decline') { // the person chose not to answer } } catch (err) { if (err instanceof AparteElicitationAbortError) { // no answer at all: err.reason is 'aborted' or 'no-presenter' } } ``` Declining is an answer: the person pressed **Skip**, in the panel’s corner, and the request resolves `{ action: 'decline' }`. A [decision](#ask-for-a-decision) has no Skip: a refusal is one of its options, or what the person writes instead. A request that ends without an answer rejects with an `AparteElicitationAbortError` — its `name` is `'AbortError'` — whose `reason` is `'aborted'` when the turn was stopped, a signal fired or the question was taken away, and `'no-presenter'` when nothing on the page can ask. A request you start without awaiting it therefore needs a `.catch()`. ## Ask from a tool [Section titled “Ask from a tool”](#ask-from-a-tool) ```ts import { requestUserInput } from '@aparte/core'; import type { AparteToolHandler } from '@aparte/core'; export const deleteBranch: AparteToolHandler = async (call, signal, context) => { const branch = String(call.input['branch']); const answer = await requestUserInput({ message: `Delete ${branch}? This cannot be undone.`, schema: { type: 'boolean', trueLabel: 'Delete it', falseLabel: 'Keep it' }, signal, target: context?.target, }); if (answer.action !== 'accept' || answer.content !== true) { return { toolCallId: call.id, content: 'The user did not confirm; nothing was deleted.' }; } return { toolCallId: call.id, content: `Deleted ${branch}.` }; }; ``` Register the handler as any [tool](/guides/tools/#register-a-tool), with `aparteGlobalConfig.registerTool(tool, deleteBranch)`. Pass it the handler’s `signal`, so a stopped turn or a timeout takes the question away, and the context’s `target`, so the right chat asks when a page has several. Answer a refusal with a result the model reads, rather than an error, so it can say what happened. ## Ask for a decision [Section titled “Ask for a decision”](#ask-for-a-decision) ```ts import { requestUserInput } from '@aparte/core'; const answer = await requestUserInput({ kind: 'approval', message: 'Run git status?', details: JSON.stringify({ cmd: 'git status' }, null, 2), options: [ { value: 'allow', label: 'Approve', tone: 'affirm' }, { value: 'allow-always', label: 'Approve, and always for git', description: 'git *', tone: 'affirm' }, { value: 'deny', label: 'Reject', tone: 'deny' }, ], }); if (answer.action === 'accept') { const { option, instruction } = answer.content as { option?: string; instruction?: string }; } ``` [An approval that can be remembered](/preview/demo/approval-scoped/) ```html ``` A decision between options of yours, one of them with a reach the person sees before choosing. A `kind: 'approval'` request offers your options, each answered by its click, and resolves with the chosen option’s `value` as `option` — or, when the person writes instead, with their `instruction`. Give an option that reaches further its own `value`, so your code knows to remember it, and say that reach in its `description`, under the label. `details` is shown as plain text under an **Arguments** heading, never as markup. The [tool approval](/guides/tools/#require-approval-human-in-the-loop) is this request, with two options. ## Let the model ask [Section titled “Let the model ask”](#let-the-model-ask) ```ts import { setupAskUser } from '@aparte/plugin-ask-user'; setupAskUser({ maxOptions: 4, maxQuestions: 3 }); ``` [The receipt a question leaves in the transcript](/preview/demo/question-receipt/) ```html ``` What the model asked and what the person chose, kept in the conversation. `@aparte/plugin-ask-user` gives the model an `ask_user` tool: it asks when it decides a choice is needed, and the answer, with what was asked, stays in the conversation as a receipt. Its options are the tool’s `name` and `description`, the `systemPrompt` that tells the model when to ask (`false` for none), the most options and questions in one call, and `receipt: false` to keep the receipt out. Ask from your own code when your code knows what to ask; let the model ask when it should decide. The [Question](/components/question/) page shows both. ## Draw it yourself [Section titled “Draw it yourself”](#draw-it-yourself) The panel comes from ``, which `` and the framework components already hold. A chat you compose in plain HTML writes it inside ``, beside the message list and the `` where the panel opens, as [the composer toolbar](/guides/customization/composer-toolbar/) shows. Most of the time a different look is one field: ```ts import { aparteGlobalConfig } from '@aparte/core'; aparteGlobalConfig.setElicitationFieldRenderer((field, ctx) => { if (field.type !== 'enum' || field.multiple) return null; // the built-in draws the others const el = document.createElement('div'); el.className = 'my-chips'; let picked = ''; for (const option of field.options) { const chip = document.createElement('button'); chip.type = 'button'; chip.textContent = option.label ?? option.value; chip.addEventListener('click', () => { picked = option.value; for (const c of el.children) c.setAttribute('aria-pressed', String(c === chip)); ctx.notifyChange(); }); el.append(chip); } return { el, getValue: () => picked, isComplete: () => picked !== '', focus: () => el.querySelector('button')?.focus() }; }); ``` A field renderer returns a control — its element, its value, whether it is complete, and a `focus` — or `null` to leave the field to the built-in. It replaces the whole field, its **Other…** entry included, and a click on it answers nothing: the composer’s button sends. Call `ctx.notifyChange()` on every change, so the button knows when the answer is complete; `ctx.key` names the field in a form. To draw the whole request your way — a modal, a page of your own — take the built-in out first: set the framework component’s `elicitation` option to `false`, or leave `` out of a chat you compose. Then register a presenter: ```ts import { aparteGlobalConfig, AparteElicitationAbortError } from '@aparte/core'; import type { AparteElicitationPresenter, AparteElicitationRequest, AparteElicitationResult } from '@aparte/core'; declare function openMyModal(request: AparteElicitationRequest): Promise; const present: AparteElicitationPresenter = (request) => new Promise((resolve, reject) => { request.signal?.addEventListener('abort', () => reject(new AparteElicitationAbortError('aborted')), { once: true }); openMyModal(request).then(resolve, reject); }); aparteGlobalConfig.setElicitationPresenter(present); ``` A presenter gets every request, tool approvals included: a question carries its `schema`; a decision carries `kind: 'approval'` and its `options`, and resolves `{ action: 'accept', content: { option } }`. When the request’s `signal` fires, reject with an `AparteElicitationAbortError`, as above. The most recent presenter answers; on a page with several chats, pass an element of one as the second argument, and the requests that name that chat reach its presenter. `aparteGlobalConfig.removeElicitationPresenter(fn)` takes one back. `buildElicitationPanel` and `buildApprovalPanel` give you the built-in panel to place yourself. ## Next [Section titled “Next”](#next) * [Question The panel, the form, the receipt, and their options.](/components/question/) * [Tools and approval Tools, and the approval asked before one runs.](/guides/tools/) * [Questions and approvals Every type and function the panel is built from.](/reference/core/elicitation/) # Run the agent loop on its own > The loop that streams a reply and runs its tools, as a package of its own — run it headless in Node or a worker, wrap the one AparteClient runs, own its history, or pause it on a call and resume later. ## Run it headless [Section titled “Run it headless”](#run-it-headless) ```bash npm install @aparte/engine ``` ```ts import { runStreamAgent, type StreamChatEvent, type StreamChatRequest } from '@aparte/engine'; // A stand-in for the model: it calls the clock, then answers with what the clock said. async function* reply(request: StreamChatRequest): AsyncIterable { const last = request.messages[request.messages.length - 1]; if (last?.role === 'tool') yield { type: 'text', delta: `It is ${String(last.content)}.` }; else yield { type: 'tool_use', id: 'c1', name: 'clock', input: {} }; yield { type: 'done' }; } await runStreamAgent({ messageId: 'a1', baseRequest: { messages: [{ role: 'user', content: 'What time is it?' }], modelId: 'my-model' }, transportCall: async (request) => reply(request), toolLookup: (name) => (name === 'clock' ? async () => ({ content: new Date().toISOString() }) : undefined), emitter: (event) => { if (event.type === 'text-delta') process.stdout.write(event.delta); }, signal: new AbortController().signal, }); ``` `runStreamAgent` is the loop `AparteClient` runs: it calls the model, reads the stream, runs the tools the model calls, gives their results back and calls again, until the model answers or a cap is reached. It has no dependency and no DOM, so it runs in the browser, Node, a worker or an Electron process, and installs alone; it is ESM only, and `@aparte/core` depends on it. It resolves to the run’s usage, and throws when the stream reports an error or a tool fails other than by an abort. `transportCall` returns the model’s events — `text`, `thinking`, `tool_use`, `error` and `done` — or a plain string from a model that does not stream. Each turn calls it again with the conversation so far, the `tool` messages answering the last calls included, as `reply` reads above. ## Options [Section titled “Options”](#options) | Option | What it is | | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `messageId` | the id of the reply this run writes; the loop does not read it, a runner that forwards the run does | | `baseRequest` | the first request: its `messages` and `modelId`, the tools, `toolChoice` | | `transportCall(request)` | calls the model with the request of each turn | | `toolLookup(name)` | the handler of a tool, `(call, signal) => Promise<{ content, structuredContent? }>` | | `toolConfigLookup(name)` | a tool’s `maxTurns`, `needsApproval` and `external` | | `approvalResolver(call, signal)` | answers a call that needs approval: `{ approved, payload?, instruction?, reason? }` | | `emitter(event)` | receives every event of the run | | `signal` | stops the run | | `maxTurns`, `toolTimeoutMs` | the turns of one run, ten by default, and a tool’s time, five minutes by default | | `onHistoryAppend(message)` | each message the loop adds to the history, for [a host that keeps its own](#own-the-history) | | `deferral` | [pause on a call](#pause-and-resume) rather than wait for it | | `idGen`, `providerId` | the id of the call the loop makes for a forced `toolChoice`, and your provider’s id, passed untouched to a runner that sends the run elsewhere | A tool’s `needsApproval` may be a function of the call, answering `true`, `false`, `'ask'` or `'deny'`. A gated call goes to `approvalResolver`, a denied one included, which is then refused without a wait being announced, its `reason` being what the model reads; without a resolver, a gated or denied call is aborted and the run ends. A tool marked `external` runs elsewhere: with `deferral` the run pauses for its result, and without it the call is aborted and the run ends. A `toolChoice` of `{ name, input }` runs that tool’s handler before the model is called, and the run goes on with no tools; `{ name }` alone makes the model call it on the first turn, and the later turns are back to `'auto'`. ## Events [Section titled “Events”](#events) | Event | When | | ---------------------------------------------------------- | ------------------------------------------------------------------- | | `run-start`, `turn-start` | the run begins; each streamed reply from the model begins | | `text-delta`, `thinking-delta` | the reply’s text and reasoning arrive | | `text-flush` | a turn’s text is complete | | `tool-start` | the model calls a tool: `{ toolCallId, name, input }` | | `tool-awaiting-approval`, `tool-approved`, `tool-rejected` | a call waits for a decision, and gets it | | `tool-resolved`, `tool-failed`, `tool-aborted` | a call’s result, its error, or its end without either | | `turn-limit-exceeded` | a cap was reached: `{ scope: 'global' \| 'tool', limit }` | | `run-aborted` | the run was stopped; `run-done` follows | | `run-done` | the run ends, with its usage | | `run-suspended` | the run ends paused, with its pending calls, in place of `run-done` | A run that fails emits neither of the last two: `runStreamAgent` throws. `StreamRunEvent` types them all, and the [engine reference](/reference/engine/) lists every field. ## The streamRunner seam [Section titled “The streamRunner seam”](#the-streamrunner-seam) ```ts import { AparteClient } from '@aparte/core'; import { runStreamAgent } from '@aparte/engine'; new AparteClient({ streamRunner: (opts) => runStreamAgent({ ...opts, emitter: (event) => { if (event.type === 'tool-resolved') console.info('tool answered', event.toolCallId); opts.emitter(event); }, }), }).start(); ``` `AparteClient` runs `runStreamAgent` by itself; `streamRunner` is the place to change that. Wrap it to watch or change a run, as above — a wrapped loop still calls the model from the page, so a provider has to be selected — or give the client another function with the same signature, an `AparteStreamRunner`: a loop of your own that sends the same events, which the chat draws as it draws its own. `createAgentStreamRunner` ([server loop](/guides/server-loop/)) and `createAgUiRunner` ([AG-UI](/providers/agent/ag-ui/)) are two. ## Own the history [Section titled “Own the history”](#own-the-history) ```ts const log = new PromptLog(); // your append-only transcript await runStreamAgent({ // …messageId, emitter, signal, toolLookup baseRequest: { messages: [], modelId: 'my-model' }, onHistoryAppend: (message) => log.append(message), // the calls, each result, each reply transportCall: () => myCompletion(log.render()), // your bytes, extended, never rebuilt }); ``` For a server with a prefix cache — llama.cpp slots, vLLM — keep the transcript yourself: `transportCall` may ignore `request.messages`, and `onHistoryAppend` reports each message the loop adds, in order, before the call that carries it. It never reports the messages you passed in `baseRequest`: write the person’s turn into your log yourself before the run. The assistant’s message is reported once, on its first call, and its `content` and `toolCalls` are completed later in the same turn: keep the reference and read it when you write, rather than a copy taken on arrival. Through the client, add it at the seam: `streamRunner: (opts) => runStreamAgent({ ...opts, onHistoryAppend })`. Writing tools, calls and results into a raw prompt stays yours. ## Pause and resume [Section titled “Pause and resume”](#pause-and-resume) ```ts import { runStreamAgent, unansweredTail } from '@aparte/engine'; // First run: it pauses on the call that needs a person. const history = [...run.baseRequest.messages]; await runStreamAgent({ ...run, deferral: {}, onHistoryAppend: (message) => history.push(message), emitter: (event) => { if (event.type === 'run-suspended') savePending(history, event.pending); // [{ toolCallId, name, input, reason }] }, }); // Later, with the answer: the history ends on the paused call, which runs first. await runStreamAgent({ ...run, baseRequest: { ...run.baseRequest, messages: history }, deferral: { outcomes: { [callId]: { kind: 'decision', approved: true } } }, }); unansweredTail(history); // the calls a history leaves open ``` With `deferral`, a call that needs a person, or an `external` one, is not awaited: the run reads the reply to its end, leaves its calls pending, and ends on `run-suspended`, whose `pending` says why each one waits — `'approval'`, `'external'`, or `'queued'` behind them. Keep the history as it grows, through `onHistoryAppend`: it ends on the assistant message that declares the paused calls, and the run that resumes needs it. A later run whose history ends on those calls, with their answers in `deferral.outcomes`, picks up where it stopped: a decision, a tool’s result, or a failure. A denied call is not paused: `approvalResolver` refuses it, as above. The [server loop](/guides/server-loop/) is built on this. The loop passes on the usage the model reports, and decides nothing about a long conversation: [Long conversations](/guides/long-conversations/) shows the gauge and the compaction that answer it. ## Next [Section titled “Next”](#next) * [Run the loop on your server The loop next to your tools, with the page drawing it.](/guides/server-loop/) * [Your own loop Show a loop that is not aparté's in the chat.](/guides/bring-your-own-loop/) * [Engine reference Every option, event and type, generated from the source.](/reference/engine/) # 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 React, Vue, Svelte or Angular? Your framework’s page has the same steps in its own syntax: [React](/frameworks/react/), [Vue](/frameworks/vue/), [Svelte](/frameworks/svelte/), [Angular](/frameworks/angular/). ## Install [Section titled “Install”](#install) ```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](#from-a-cdn). ## Register the components [Section titled “Register the components”](#register-the-components) ```ts import '@aparte/core'; import '@aparte/core/styles.css'; ``` The first import defines the `` elements, the second brings their styles. When your page’s markup is static HTML, import the styles from a stylesheet the `` links instead — `@import '@aparte/core/styles.css';` — so the page never paints without them. ## Add the markup [Section titled “Add the markup”](#add-the-markup) ```html ``` Left empty, [``](/components/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](/guides/attachments/)). To choose the parts yourself, write them inside it: ```html
``` Keep an `` somewhere inside: a chat that holds none replaces its content with the default one. The [Composer](/components/composer/) page has the other parts — a toolbar, attachments, a stop button. ## Stream a reply [Section titled “Stream a reply”](#stream-a-reply) ```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. ## Render Markdown [Section titled “Render Markdown”](#render-markdown) ```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(); ``` [A scripted reply, streaming](/preview/demo/getting-started-scripted/) ```html ``` 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](/guides/markdown/) colours the code blocks. ## Wire a real model [Section titled “Wire a real model”](#wire-a-real-model) 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`](/providers/ai/openai-compat/) talks to any OpenAI-compatible API, with presets for OpenAI, Mistral, OpenRouter, Z.ai, LM Studio and Ollama. [Models and agents](/providers/) 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](/components/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`](/guides/backend-transport/). For a model on your own machine — Ollama, LM Studio, llama.cpp — see [Local models](/guides/local-models/). ## Turn on retry and edit [Section titled “Turn on retry and edit”](#turn-on-retry-and-edit) ```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](/guides/conversations-branching/) shows the versions of a reply that retry keeps. ## From a CDN [Section titled “From a CDN”](#from-a-cdn) ```html ``` 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”](#stream-from-your-own-code) ```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](/guides/bring-your-own-loop/) goes further: reasoning, tool calls, stopping. ## Next [Section titled “Next”](#next) * [Theme Colours, shapes and fonts, through CSS variables.](/guides/theming/) * [How it fits The pieces between your page and your model, and the package that holds each.](/guides/how-it-fits/) * [Tools and approval Let the model call your functions, with the person approving what matters.](/guides/tools/) * [Troubleshooting When nothing streams, or the console warns about a key.](/guides/troubleshooting/) # How aparté fits together > What sits between your framework and your model — the elements, the client and its loop, the transport, the provider — and which package holds each. ## One message, from send to reply [Section titled “One message, from send to reply”](#one-message-from-send-to-reply) 1. The person sends. The composer fires `aparte-send` with the text and any files. 2. The **client**, [`AparteClient`](/reference/core/client/#aparteclient), adds the message to the conversation and builds the request: the history, the tools you registered, the system prompt. 3. The **loop**, [`@aparte/engine`](/guides/engine/), sends it and reads the reply as it streams. When the model calls a tool, the loop runs it — once the person approves, if the tool asks for that — sends the result back, and goes on until the model is done. 4. The **transport** carries each request: from the browser straight to the model, or through your server. 5. The **provider** speaks the model’s format: an OpenAI-compatible API, the Vercel AI SDK, a model running in the page. 6. The reply comes back as **segments** — text, reasoning, code, tool calls, errors — and the message draws each one as it streams. ## Where the request goes [Section titled “Where the request goes”](#where-the-request-goes) | Transport | The request goes | The key | | ------------------------------------ | --------------------------------------- | ------------------------------------------- | | `AparteDirectTransport`, the default | from the browser to the model | the person’s own, or none for a local model | | `AparteBackendTransport` | to your endpoint, which calls the model | stays on your server | The chat is the same either way. [Keep the key server-side](/guides/backend-transport/) has the endpoint, ready-made as `createAparteChatHandler`. ## Where the loop runs [Section titled “Where the loop runs”](#where-the-loop-runs) | Where | What you use | Guide | | --------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | | In the page | `AparteClient`, the default | [Installation and first chat](/guides/getting-started/) | | On your server | `createAparteAgentHandler` on the server, `createAgentStreamRunner` in the page | [Run the loop on your server](/guides/server-loop/) | | In an agent framework | `@aparte/runner-ag-ui`, for an agent served over AG-UI: Agno, LangGraph, Mastra, CrewAI, TanStack AI | [AG-UI](/providers/agent/ag-ui/), [TanStack AI](/providers/agent/tanstack-ai/) | | In your own code | the chat alone: you stream into it | [Use your own loop](/guides/bring-your-own-loop/) | The client, both transports and both server handlers come from `@aparte/core`, in the page and on the server alike. ## What the chat is made of [Section titled “What the chat is made of”](#what-the-chat-is-made-of) Three kinds of parts, all in the [components](/components/): * **Elements** — tags you put on the page, such as ``, `` or ``. They hold the behaviour: streaming, scrolling, focus, keyboard. * **[Segments](/segments/)** — what a message is made of, as data: text, reasoning, code, a tool call, an error, or a view of your own. Each kind has a renderer, and you can register yours. * **[The kit](/kit/)** — CSS classes for the controls around the chat: buttons, fields, menus, dialogs, in the same theme. 51 components in all: 24 for the chat, 5 for the page around it, 22 in the kit. ## In your framework [Section titled “In your framework”](#in-your-framework) The [framework packages](/frameworks/) wrap the same elements: a chat component for React, Vue, Svelte and Angular, with typed properties and events for every element. They depend on `@aparte/core` alone: the provider and the transport are set up the same way in every framework, and each package starts the client for you — `useAparteClient` in React, `AparteAiService` in Angular. ## Which package does what [Section titled “Which package does what”](#which-package-does-what) | Package | What it is | | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | The library | | | [`@aparte/core`](/reference/core/) | AI chat and agent UI components as Web Components, with the agent loop inside | | [`@aparte/engine`](/guides/engine/) | The agent loop on its own: runStreamAgent streams a model's turns and runs its tools, in the browser or in Node | | Frameworks | | | [`@aparte/angular`](/frameworks/angular/) | Angular 19 wrapper for aparté | | [`@aparte/react`](/frameworks/react/) | React 18+ wrapper for aparté | | [`@aparte/svelte`](/frameworks/svelte/) | Svelte wrapper for aparté | | [`@aparte/vue`](/frameworks/vue/) | Vue 3 wrapper for aparté | | Models | | | [`@aparte/provider-ai-sdk`](/providers/ai/ai-sdk/) | Bridge any Vercel AI SDK model into aparté | | [`@aparte/provider-openai-compat`](/providers/ai/openai-compat/) | One OpenAI-compatible chat-completions adapter for any /v1 endpoint | | [`@aparte/provider-scenario`](/providers/ai/scenario/) | A scripted model for aparté | | [`@aparte/provider-transformers`](/providers/ai/transformers/) | Run LLMs 100% in the browser via Transformers.js (WebGPU/WASM) | | Agent frameworks | | | [`@aparte/runner-ag-ui`](/providers/agent/ag-ui/) | Render an AG-UI agent in aparté | | Plugins | | | [`@aparte/plugin-approval`](/components/approval-mode/) | Approval modes for aparté tool calls | | [`@aparte/plugin-artifacts`](/components/artifact/) | Artifacts for aparté | | [`@aparte/plugin-ask-user`](/components/question/) | The ask\_user tool for aparté | | [`@aparte/plugin-compaction`](/guides/long-conversations/) | Conversation compaction for aparté | | [`@aparte/plugin-marked`](/guides/markdown/) | Markdown rendering for aparté via marked | | [`@aparte/plugin-model-selector`](/components/model-selector/) | Provider + model selector web component (\) for aparté | | [`@aparte/plugin-shiki`](/guides/markdown/) | Syntax highlighting for aparté via shiki | | [`@aparte/plugin-streaming-markdown`](/guides/markdown/) | Incremental (token-by-token) Markdown rendering for aparté via streaming-markdown, with a live URL-scheme guard | | [`@aparte/plugin-titler`](/guides/titles/) | Auto-title conversations with an aparte-titler model | | Translations | | | [`@aparte/locale-fr`](/guides/localization/) | French locale for aparté | | Tools | | | [`@aparte/docs-mcp`](/guides/docs-for-agents/) | The aparté documentation as an MCP server | ## Next [Section titled “Next”](#next) * [Installation and first chat Install, put the chat on a page, stream a first reply.](/guides/getting-started/) * [Models and agents Pick the provider for your model, or the runner for your agent.](/providers/) * [Tools and approval Let the model call your functions, with the person approving what matters.](/guides/tools/) * [Use your own loop Keep your loop, and stream its replies into the chat.](/guides/bring-your-own-loop/) # What aparté is > A chat for your web app — web components that stream a model's reply, run its tools and ask the person when they should, in any framework and with any model. [A scripted reply, with Markdown](/preview/demo/intro-chat/?play=What's%20the%20weather%20in%20Lille%3F) ```html ``` The library itself, with a scripted model in place of an AI: it calls a weather tool and answers, with no key and no server. Ask it for a haiku, a table or some code. ## What comes with it [Section titled “What comes with it”](#what-comes-with-it) * **Messages that stream as they are written**: the [response](/components/response/), the model’s [reasoning](/components/reasoning/), [code](/components/code-block/), [tool calls](/components/tool/), [errors](/components/error/), and [views of your own](/components/custom-view/). Markdown and code highlighting come from [plugins](/guides/markdown/). * **Tools, with a person in the loop**: the model calls your functions, the ones you choose wait for the person’s [approval](/guides/tools/), and the model can [ask a question](/guides/elicitation/) in the middle of a turn. * **Conversations**: [retry](/guides/conversations-branching/) keeps each answer as a version you can step through, edit rewrites a message and its reply — both one line to turn on — with a [conversation list](/components/conversation-list/), [attachments](/guides/attachments/) when you ask for them, and [storage](/guides/conversation-persistence/) you plug in. * **Your look**: colours, sizes and shapes are [CSS variables](/guides/theming/), and a [kit of classes](/kit/) draws the controls around the chat. * **Any model**: [OpenAI-compatible APIs](/providers/ai/openai-compat/), the [Vercel AI SDK](/providers/ai/ai-sdk/), a [model in the browser](/providers/ai/transformers/), or an agent that runs its own loop, over [AG-UI](/providers/agent/ag-ui/). * **Any framework**: [React, Vue, Svelte, Angular](/frameworks/), or none. [51 components](/components/) in all, each on a page of its own with live examples. ## What it leaves to you [Section titled “What it leaves to you”](#what-it-leaves-to-you) Routing, sign-in, settings, and where conversations are kept belong to your app. aparté draws the chat and runs the conversation; storage is an interface you implement over your own database, IndexedDB or API. ## What you need [Section titled “What you need”](#what-you-need) * A recent browser: the [support matrix](/reference/support/) has the exact versions. * npm and a bundler, or one script tag from a CDN. * Node 18 or later, for the parts that run on your server. * A model: the scripted one to start, then the person’s own key, a model on their machine, or your server. ## Where it stands [Section titled “Where it stands”](#where-it-stands) aparté is in beta. Its public surface — elements, attributes, events, exports — is [frozen](/reference/stability/), and every release is in the [changelog](/changelog/). ## Next [Section titled “Next”](#next) * [Installation and first chat Install, put the chat on a page, stream a first reply.](/guides/getting-started/) * [How it fits The pieces between your page and your model, and the package that holds each.](/guides/how-it-fits/) * [Your framework The same chat as a React, Vue, Svelte or Angular component.](/frameworks/) * [Templates Whole apps built with aparté, live, with their code.](/templates/) # Place the chat on your page > Size the chat, fill the page like ChatGPT, float the composer, put a preview beside it, centre the welcome state, add a rail for long threads, and build the whole page around it. ## Give the chat a height [Section titled “Give the chat a height”](#give-the-chat-a-height) ```html ``` ```css /* under a header, in a flex column */ .app { display: flex; flex-direction: column; height: 100dvh; } .app aparte-chat { flex: 1; min-height: 0; height: auto; } ``` ```css /* the whole window */ html, body { height: 100%; margin: 0; } aparte-chat { height: 100dvh; } ``` The message list is what scrolls, not the page, so the chat needs a height to scroll inside: `` fills its parent, and a chat with nothing sized above it grows with its messages and never scrolls. `min-height: 0` lets a flex item shrink below its content, and `100dvh` follows a phone’s browser bar as it hides and shows. Keep the message list as the scroller: the chat follows a streaming reply, offers the jump back to the bottom and stops following when the person scrolls up, all from that list. ## Fill the page, like ChatGPT [Section titled “Fill the page, like ChatGPT”](#fill-the-page-like-chatgpt) ```css /* not this: a boxed chat puts its scrollbar inside the page */ .app { max-width: 820px; margin: 0 auto; } /* this: the chat spans the page, its messages keep a centred column */ .app { display: flex; flex-direction: column; height: 100dvh; } :root { --aparte-message-max-width: 48rem; } ``` [The reading column: an unboxed chat, the column from a token](/preview/demo/layout-reading-column/) ```html
``` The column is a width on the messages, not a box around the chat, so the scrollbar runs down the frame's edge. The messages and the composer share one centred column, `--aparte-message-max-width` wide, inside a list that spans the chat. Leave the chat unboxed and the scrollbar lands on the window’s edge while the reading column stays centred. ## Float the composer over the transcript [Section titled “Float the composer over the transcript”](#float-the-composer-over-the-transcript) ```html ``` [overlay-composer: the scroll surface spans the column, the composer floats over it](/preview/demo/layout-overlay/) ```html
``` The scrollbar runs past the composer to the bottom edge, and the last message stays clear of it. With `overlay-composer`, the message list takes the whole chat and the composer floats over its bottom, so the scrollbar runs the full height. Every other child of the chat, except the [scroll rail](#jump-back-in-a-long-thread), floats at the bottom with the composer, and the list measures that stack to keep the last message, and the jump-to-bottom button, clear of it. The framework packages take it as `overlayComposer`. Set it in the first markup: it is read when the chat mounts. A composer of your own, floated without the attribute, sets the space it covers itself: `aparte-chat-viewport { --aparte-bottom-inset: 96px; }`. ## Pick a shape [Section titled “Pick a shape”](#pick-a-shape) | Shape | Like | How | | ----------------------- | ------------------------------------- | ------------------------------------------------------------------------------------- | | Reading column | ChatGPT, Claude, Gemini | the default, unboxed: the section above | | Full-width feed | Slack, Discord | `aparte-chat { --aparte-message-max-width: none; }` | | Side panel | an editor’s or a browser’s chat strip | a panel with a height, and `.panel aparte-chat { --aparte-message-max-width: none; }` | | A chat beside a preview | Canvas, Artifacts, v0 | ``, below | | A floating widget | a support chat on a product’s site | the [Support widget](/templates/support-widget/) template builds one | A message list or a composer narrower than 520px switches to its narrow spacing by itself — a container query, not a media query — so a side panel and a phone get the same spacing. That spacing is set on the messages themselves, so a value you put on the chat does not reach them there: change it in a container query of your own. ```css @container (max-width: 520px) { .aparte-message { --aparte-message-padding-inline: 12px; } } ``` ## Put a preview beside the chat [Section titled “Put a preview beside the chat”](#put-a-preview-beside-the-chat) ```html
your preview
``` ```css .page { display: flex; flex-direction: column; height: 100dvh; } .page aparte-split { flex: 1; min-height: 0; } .pane-switch { display: none; } @media (max-width: 48rem) { .pane-switch { display: flex; gap: 0.5rem; } } ``` [The builder split: a chat beside a pane, on a seam you can drag](/preview/demo/layout-split/) ```html
``` Drag the seam, or tab to it and use the arrows. [``](/components/split/) holds two panes and the seam between them; `position` is the primary pane’s size, as a percentage: the first pane, unless `primary="end"`. It keeps nothing itself: `aparte-split-resize` fires once the position settles, never during a drag, which is when you save it. A pane holds a chat, never the other way round. Under its `breakpoint`, 48rem by default, it shows one pane at a time. A control carrying `data-aparte-split-pane="start"` or `"end"` switches the pane with no script, and the media query above hides those controls where both panes show. `single` shows one pane at every width — the chat alone, until there is something to preview. ## With an artifact in the pane [Section titled “With an artifact in the pane”](#with-an-artifact-in-the-pane) ```ts import { aparteGlobalConfig } from '@aparte/core'; import { setupArtifacts, createArtifactTool, artifactHandler } from '@aparte/plugin-artifacts'; import type { ArtifactInput } from '@aparte/plugin-artifacts'; declare function showInPane(artifact: ArtifactInput): void; // your preview const split = document.querySelector('aparte-split'); setupArtifacts(); aparteGlobalConfig.registerTool(createArtifactTool(), async (call) => { const result = await artifactHandler(call); showInPane(result.structuredContent as ArtifactInput); if (split) split.single = false; // open the pane beside the chat return result; }); ``` [The builder split with an artifact: the card in the chat, the result in the pane](/preview/demo/layout-artifact/) ```html
``` The reply is an artifact card; the pane beside it is the same document, opened by the app. When the model writes a document, [`@aparte/plugin-artifacts`](/components/artifact/) shows it as a card in the reply. To open it beside the chat too, start the split with `single` and register the tool again with a handler of yours: it returns the plugin’s result, so the card still renders, and it puts the same document in the pane. The [Research assistant](/templates/research-assistant/) template does this with its report. ## Centre the welcome state [Section titled “Centre the welcome state”](#centre-the-welcome-state) ```html

What can I help with?

``` ```css .welcome { display: none; text-align: center; } aparte-chat[data-empty] .welcome { display: block; } ``` [The welcome state: a centred composer until the first message](/preview/demo/layout-empty-state/) ```html

What are we building today?Ask anything, or start from one of these.

``` Send one message: the composer slides from the middle to the bottom. `center-empty` holds the composer in the middle of an empty chat and slides it down with the first message. While the chat is empty it carries `data-empty`, the hook for the greeting, which goes away as that message lands; [``](/components/suggestions/) shows its starters on the same condition. In the framework packages the switch is `centerWhenEmpty`, the greeting goes in `emptyState` (React) or the `empty-state` slot (Vue, Svelte, Angular), shown only while the chat is empty, and the hook for anything else is `data-aparte-empty`. ## Jump back in a long thread [Section titled “Jump back in a long thread”](#jump-back-in-a-long-thread) ```html
``` [``](/components/scroll-rail/) draws one tick per question along the message list, marks the one being read, and jumps to any of them on a click; `every="message"` ticks every message. It shows from the second tick on, and stays hidden on a touch screen, where its ticks are too thin to press. A click fires `aparte-scroll-rail-jump` (`detail.messageId`) first: cancel it to load older history, then call `rail.jumpTo(messageId)` yourself and let that second event through. Outside the chat — beside a framework component, say — give the chat an `id` and the rail a `target` naming it. ## The whole page: sidebar, header and chat [Section titled “The whole page: sidebar, header and chat”](#the-whole-page-sidebar-header-and-chat) ```html
My app
New chat
``` [The application shell: sidebar, header, chat](/preview/demo/app-shell/) ```html
aparté
Deploy checklist
gpt-4.1
``` The sidebar, the header, the chat. In a narrow frame the sidebar is a drawer behind the header's button. [`.aparte-app-shell`](/components/app-shell/) lays out the page: the sidebar beside, the [header](/components/header/) above, the chat in the rest. [``](/components/sidebar/) collapses, and becomes a drawer on a narrow screen, with a scrim, Escape to close and the focus handed back. Its search field filters the [conversation list](/components/conversation-list/) by title, and any control with `data-aparte-sidebar-toggle` opens and closes it: the header’s button shows while the sidebar is closed, and always on a narrow screen, so the one in the sidebar’s own header is what closes it on a wide one. None of it needs script. A split goes in `__main` the same way as a chat. Settings belong in the kit’s [dialog](/components/dialog/), and where the conversations come from is [Save conversations](/guides/conversation-persistence/). When the shell is static HTML, link `@aparte/core/styles.css` from the `` so it never paints bare — [Register the components](/guides/getting-started/#register-the-components) shows the line. ## Next [Section titled “Next”](#next) * [Templates Whole apps in these layouts: an assistant, an agent workshop, a support widget, a research assistant.](/templates/) * [Theme Colours, shapes and density for every layout.](/guides/theming/) * [Split Every attribute, event and key of the seam.](/components/split/) # Put a chat on a model on your machine > Put a chat on Ollama, LM Studio or llama.cpp through their OpenAI-compatible endpoint — the browser calls the server directly, with no key and no backend. ## Start the server and wire the chat [Section titled “Start the server and wire the chat”](#start-the-server-and-wire-the-chat) ```bash npm install @aparte/core @aparte/provider-openai-compat ``` * Ollama ```bash OLLAMA_ORIGINS=* ollama serve # PowerShell: $env:OLLAMA_ORIGINS = "*"; ollama serve ollama pull llama3.2 # in a second terminal ``` ```ts import { aparteGlobalConfig, AparteClient } from '@aparte/core'; import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat'; import '@aparte/core/styles.css'; aparteGlobalConfig.registerAIProvider(createOpenAICompatProvider(presets.OLLAMA)); // http://localhost:11434/v1 aparteGlobalConfig.setModelConfig({ defaultProvider: 'ollama', defaultModel: 'llama3.2:latest' }); new AparteClient().start(); ``` * LM Studio Load a model, start the server in the Developer tab, and turn on **Enable CORS** there. ```ts import { aparteGlobalConfig, AparteClient } from '@aparte/core'; import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat'; import '@aparte/core/styles.css'; aparteGlobalConfig.registerAIProvider(createOpenAICompatProvider(presets.LMSTUDIO)); // http://localhost:1234/v1 aparteGlobalConfig.setModelConfig({ defaultProvider: 'lmstudio', defaultModel: 'qwen2.5-7b-instruct' }); // the id LM Studio shows new AparteClient().start(); ``` * llama.cpp ```bash llama-server -m ./models/qwen2.5-7b-instruct-q4_k_m.gguf ``` ```ts import { aparteGlobalConfig, AparteClient } from '@aparte/core'; import { createOpenAICompatProvider } from '@aparte/provider-openai-compat'; import '@aparte/core/styles.css'; aparteGlobalConfig.registerAIProvider(createOpenAICompatProvider({ id: 'llamacpp', name: 'llama.cpp', baseURL: 'http://localhost:8080/v1', // llama-server's default port isLocal: true, })); aparteGlobalConfig.setModelConfig({ defaultProvider: 'llamacpp', defaultModel: 'default' }); // it answers with the model it loaded new AparteClient().start(); ``` ```html ``` The three servers speak OpenAI’s chat completions at `/v1`, so [`@aparte/provider-openai-compat`](/providers/ai/openai-compat/) reaches them all, and the browser calls the server itself — the default transport — with no key to hold and no backend to write. `presets.OLLAMA` and `presets.LMSTUDIO` are marked local, so no key is asked for; llama.cpp has no preset, and an `id`, a `baseURL` and `isLocal: true` make one, `name` labelling it. A reply the server logs but the page never shows is the browser refusing it: the server has to allow the page’s origin, which is what `OLLAMA_ORIGINS` and **Enable CORS** do ([CORS on a local server](/guides/troubleshooting/#cors-on-local-byok-lm-studio--ollama)). ## Let the person pick the model [Section titled “Let the person pick the model”](#let-the-person-pick-the-model) ```ts import { aparteGlobalConfig } from '@aparte/core'; import '@aparte/plugin-model-selector'; aparteGlobalConfig.setModelPreferenceProvider({ save: (provider, model) => localStorage.setItem('model', JSON.stringify({ provider, model })), load: () => JSON.parse(localStorage.getItem('model') ?? 'null'), }); aparteGlobalConfig.restoreModelPreference(); // before the chat mounts ``` ```html
``` A local server lists the models it holds at `/v1/models`, and a local provider fetches that list with no key; `llama-server` lists the one it was started with. The [model selector](/components/model-selector/) shows the list, `auto-select` picks the first model when none is selected, and `persist` makes the pick the model the next message goes to — without it, the selector shows a choice nothing sends. The preference provider above keeps the pick across reloads. A toolbar needs the chat written out, as here: an `` with no `` inside replaces its children with the default chat. ## Show how full the context is [Section titled “Show how full the context is”](#show-how-full-the-context-is) The `` in the toolbar above is the [context gauge](/components/context-gauge/). The model list gives it a window only when the server returns a `context_length` for each model, which a local server usually does not, so `window` gives it yourself, in tokens: the context size the server runs the model with. It fills in after the first reply, from the usage the server reports. ## Tools [Section titled “Tools”](#tools) The tools you register go with every request: every model the server lists is marked able to call them, and so is a model you list without `capabilities`; whether it uses them is the model’s call. [Tools and approval](/guides/tools/) work the same, the approval panel included, with a model that supports function calling. ## Next [Section titled “Next”](#next) * [Run a model in the browser No server at all: the model runs inside the page.](/providers/ai/transformers/) * [Keep the key on your server A model behind your own /api/chat, local or not.](/guides/backend-transport/) * [Own the history A prefix cache — llama.cpp slots, vLLM — that wants each turn to extend the last.](/guides/engine/#own-the-history) # Translate the chat > Show the chat's own labels in French or in your language, switch at runtime, and set the date format and the reading direction. The chat’s own words — the composer’s placeholder, the Copy and Retry buttons, “Thinking”, the names a screen reader hears — come from a locale. English is built in, as `APARTE_DEFAULT_LOCALE`, so a chat with no locale set is in English. ## Switch to French [Section titled “Switch to French”](#switch-to-french) ```bash npm install @aparte/locale-fr ``` ```ts import { aparteGlobalConfig } from '@aparte/core'; import { fr } from '@aparte/locale-fr'; aparteGlobalConfig.setLocale(fr); ``` The switch is live: the chats already on the page relabel at once, so it can happen at any time, before or after they render. A chat with a [configuration of its own](/guides/customization/#per-chat-configuration) has its own locale: call `setLocale(fr)` on that configuration too. ## Let the person choose [Section titled “Let the person choose”](#let-the-person-choose) ```ts function setLanguage(lang: 'fr' | 'en'): void { if (lang === 'fr') aparteGlobalConfig.setLocale(fr); else aparteGlobalConfig.resetLocale(); } ``` `resetLocale()` puts English back, and `getLocale()` returns the locale in force. ## Write your own locale [Section titled “Write your own locale”](#write-your-own-locale) ```ts import { aparteGlobalConfig, APARTE_DEFAULT_LOCALE, type AparteLocale } from '@aparte/core'; const es: AparteLocale = { ...APARTE_DEFAULT_LOCALE, tag: 'es-ES', inputPlaceholder: 'Escribe un mensaje…', sendButton: 'Enviar', copy: 'Copiar', approvalAsk: '¿Ejecutar {tool}?', }; aparteGlobalConfig.setLocale(es); ``` Start from the English default and override the keys you translate: the type lists the chat’s built-in strings, and a key you leave out shows in English. These keys take a placeholder the chat fills in: `{tool}` in `approvalAsk`, `{name}` in `removeAttachment` and `{title}` in `deleteConversationConfirm`. `tag` and `direction` are not words: * `tag` is a BCP-47 language tag, such as `'es-ES'`. Message times, the conversation list’s dates and the context gauge’s numbers are formatted for it, so a French locale gets 24-hour times. Without it, they follow the browser. * `direction` is `'ltr'` or `'rtl'`, written as `dir` on the message list and the composer. Without it, the chat follows the page: a page that says `` gets a right-to-left chat with no locale setting at all. ## Add strings of your own [Section titled “Add strings of your own”](#add-strings-of-your-own) ```ts import { aparteGlobalConfig, subscribeConfigChange } from '@aparte/core'; import { fr } from '@aparte/locale-fr'; aparteGlobalConfig.setLocale(fr); aparteGlobalConfig.extendLocale({ myPluginRetry: 'Réessayer' }); const label = (): string => aparteGlobalConfig.getLocale().myPluginRetry ?? 'Try again'; function mount(el: HTMLElement): () => void { const paint = (): void => { el.textContent = label(); }; paint(); return subscribeConfigChange(el, paint); } ``` A locale holds your keys beside the chat’s. `extendLocale` merges them into the locale in force; `setLocale` and `resetLocale` replace the whole object, your keys included, so set the package first and extend it after, or pass `{ ...fr, myPluginRetry: 'Réessayer' }` in one call — which is what a language toggle does on every switch. Read your key off `getLocale()` with its English default at the call site, and repaint on a switch with `subscribeConfigChange`, which returns the function that stops it. A [segment renderer](/guides/customization/segments/#what-a-renderer-is-called-for) relabels in its `relabel` callback instead, called on every change. ## What stays in English [Section titled “What stays in English”](#what-stays-in-english) The status line the framework components show while `isTyping` is set reads their `typingText` prop, `'Assistant is thinking...'` by default: pass your translation. `` itself shows the `text` you give it, and without one takes its word from the locale’s `typing` key. The words the model reads stay in English whatever the locale, since they are part of the request, not of the interface: the question tool’s description and instructions in `@aparte/plugin-ask-user` (its `description` and `systemPrompt` options replace them), the compaction plugin’s summary prompt (`prompt`), and the artifacts tool’s instructions (`systemPrompt` on `createArtifactTool`). ## Next [Section titled “Next”](#next) * [Accessibility The names the locale gives a screen reader, and what stays yours to add.](/guides/accessibility/) * [Customization Your own parts of the chat, and their labels.](/guides/customization/) # Keep a long conversation inside the model's window > Show how much of the context window a conversation uses, and summarise the older turns when it fills up — automatically, from a button, or your own way. [The gauge past its warning and danger thresholds](/preview/demo/context-levels/) ```html ``` The context gauge past its two thresholds: warn, then danger. ## Install and set up [Section titled “Install and set up”](#install-and-set-up) ```bash npm install @aparte/plugin-compaction ``` ```ts import { setupCompaction } from '@aparte/plugin-compaction'; const compaction = setupCompaction(); ``` ```html
``` [``](/components/context-gauge/) measures how much of the model’s window the conversation uses, from the usage each reply reports: it shows nothing until a reply reports its usage — a provider that reports none leaves it empty — and goes blank again after a compaction, until the next reply. It turns `warn` at 75 % of the window and `danger` at 90 %, which its `warn` and `danger` attributes change. With `auto-compact`, a turn that ends in `danger` asks for a compaction; once that request is answered, the next turn still in `danger` asks again. `setupCompaction()` answers: it summarises the turns that no longer fit and keeps the recent ones as they are. Without the plugin, the gauge only measures. The gauge goes in the composer’s toolbar, which [the composer toolbar](/guides/customization/composer-toolbar/) places in plain HTML and in each framework. The window is the current model’s `contextWindow`. A `window` attribute sizes the gauge alone: when the model declares no window, give the compaction the same number through its [selector](#choose-what-stays), or it keeps the last four messages and summarises the rest. ## Compact from a button [Section titled “Compact from a button”](#compact-from-a-button) ```ts document.querySelector('#compact')?.addEventListener('click', async () => { const outcome = await compaction.compact(); // never throws if (!outcome.ok) console.warn(outcome.error); }); ``` `compact()` compacts the chat whose id it is given, or the first on the page, and resolves to what happened: `{ ok: true, skipped: false, summary, kept, dropped }` when it did; `{ ok: true, skipped: true, reason }` when there was nothing to do — `'empty'`, `'nothing-to-drop'`, or `'running'` and `'streaming'` when a compaction or a reply is under way; and `{ ok: false, error }` when it could not — no chat or provider found, a summary that failed or was cancelled, a conversation that changed meanwhile. A button can also send what the gauge sends: `window.dispatchEvent(new CustomEvent('aparte-compact', { detail: { targetId: 'main-chat' } }))`. A host that only calls `compact()` itself passes `listen: false`, and the setup listens to no event. ## Show progress, and cancel [Section titled “Show progress, and cancel”](#show-progress-and-cancel) ```ts declare const spinner: { show(): void; hide(): void; fail(error: unknown): void }; // yours window.addEventListener('aparte-compact-start', () => spinner.show()); window.addEventListener('aparte-compact-done', () => spinner.hide()); window.addEventListener('aparte-compact-error', (event) => spinner.fail(event.detail.error)); ``` A compaction is a model call, so it takes a moment. The events go out on `window`, each with the chat’s `targetId`; `aparte-compact-done` carries `{ summary, kept, dropped }`, or `{ skipped: true, reason }`. `compaction.running` is `true` meanwhile, and `compaction.abort()`, or an `aparte-abort` sent for the chat, cancels it and leaves the conversation as it was; the cancel arrives as `aparte-compact-error` with `error: 'Compaction aborted'`, and with `listen: false` only `abort()` cancels. A summary that comes back after the conversation was switched is not applied. `compaction.dispose()` removes the setup. ## Choose what stays [Section titled “Choose what stays”](#choose-what-stays) ```ts import { aparteGlobalConfig } from '@aparte/core'; import { setupCompaction, createCompactionSelector } from '@aparte/plugin-compaction'; setupCompaction({ selector: createCompactionSelector({ contextWindow: 128_000, systemPrompt: () => aparteGlobalConfig.resolveSystemPrompt(), tools: () => aparteGlobalConfig.getTools(), minKeep: 6, // never summarise the last three exchanges }), }); ``` By default the newest turns that fit the current model’s window stay word for word, after room for the system prompt, the tools, the reply, a buffer of a tenth of the window and a safety margin, and the older ones are summarised. A compaction at the default 90 % therefore frees a few turns; a larger `autocompactBufferPct` in the selector’s `config`, or a lower `danger`, frees more. The selector above does the same against a window you choose, with `minKeep` as a floor; any function from the messages to `{ keep, drop }` can be a selector. When the model declares no window, the last four messages stay, which `keepWithoutWindow` changes. There is one setup per configuration: a second `setupCompaction` call replaces the first. Pass the selector, the prompt and every other option in one call. ## Summarise your way [Section titled “Summarise your way”](#summarise-your-way) ```ts import { setupCompaction } from '@aparte/plugin-compaction'; setupCompaction({ prompt: 'Summarise in French, for a support agent picking up the ticket. Do not answer the conversation, do not continue it, and do not call a tool.', keyResolver: (providerId) => localStorage.getItem(`key:${providerId}`), }); ``` The summary is asked of the current model, through the same transport as the chat, and the request carries `_meta: { compaction: true }`, so a server of yours can send it to a cheaper model. It holds the summarised turns as text, their tool calls and errors included. `prompt` replaces the instruction: keep its last sentence, or a conversation that ends in a question can come back answered instead of summarised. `keyResolver` is asked for the provider’s key first, and the configuration’s key is used when it returns nothing, as with `AparteClient`. `summarize: (request, signal) => Promise` replaces the call altogether — your own endpoint, any model — and needs no provider at all. ## Several chats, or a transcript in a store [Section titled “Several chats, or a transcript in a store”](#several-chats-or-a-transcript-in-a-store) One setup answers every chat on the page: the gauge’s request names its chat, and that one is compacted. A gauge names its chat when it sits in a chat that has an `id`, or carries a `target` naming one; with neither, the first chat on the page is the one compacted. A request that arrives while another compaction runs is skipped with `reason: 'running'`, and the gauge asks again on its next turn in `danger`. A chat with a configuration of its own takes a setup on that configuration, scoped to the chat’s id — `setupCompaction({ scopeToTargetId: 'support' }, support)` — so it answers that chat alone and `compact()` with no id compacts it. When your transcript lives in a store rather than in the page, `resolveTarget: (targetId) => ({ getMessages, clearAll, appendMessage })` hands the plugin your own message list. ## What the person and the model see [Section titled “What the person and the model see”](#what-the-person-and-the-model-see) [The summary that replaced the start of a long conversation](/preview/demo/compaction-notice/) ```html ``` The summary in the conversation, where the older turns were. The summarised turns are replaced by one notice, headed by the locale’s `compactionSummaryTitle` — “Conversation summary” in English — with the turns that stayed after it, and whatever arrived while the summary was being written. Only the versions on screen stay: the other versions of every answer, and their pickers, are dropped. The notice is a message with `compaction: true`, drawn apart from the others, without avatar or buttons. `AparteClient` sends it to the model as a summary of what came before; a summary you make by other means gets the same treatment from the same flag. ## Reference [Section titled “Reference”](#reference) The default selector estimates tokens as characters ÷ 3.8 (`estimateTokens`). Its budget is the window, less the system prompt, the tools, the reserves for thinking and for the reply, a buffer, a safety margin and a slot for the summary. | Export | What it is | | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `computeHistoryBudget`, `splitHistoryBudget` | the budget with its breakdown (`BudgetResult`, `BudgetBreakdown`), and its split into room for the summary and the verbatim window (`SplitBudget`) | | `estimateTokens`, `estimateTokensJson` | the estimate, for text and for a JSON value | | `DEFAULT_COMPACTION_CONFIG` | the reserves and ratios a `CompactionConfig` overrides | | `transcriptForSummary`, `messageText`, `DEFAULT_COMPACTION_PROMPT` | the transcript and the instruction the default summary uses, for a `summarize` of your own | | `CompactionSetupOptions`, `CompactionController` | what `setupCompaction` takes and returns | | `CompactionOutcome`, `CompactionSkipReason` | what `compact()` resolves to | | `CompactionMessageSelector`, `CompactionSelectorOptions`, `CompactionSelector`, `CompactionSelection`, `CompactableMessage` | the selector and its parts | | `CompactionSummarizer`, `CompactionKeyResolver`, `CompactionTarget` | the `summarize`, `keyResolver` and `resolveTarget` options | The events are typed in core’s event map — `AparteCompactEventDetail`, `AparteCompactStartEventDetail`, `AparteCompactDoneEventDetail` and `AparteCompactErrorEventDetail` — and listed in the [events reference](/reference/events/). ## Next [Section titled “Next”](#next) * [Context gauge The gauge, its levels, the ring and the bar.](/components/context-gauge/) * [Compaction notice The summary in the conversation.](/components/compaction-notice/) * [Save conversations Keep conversations across reloads.](/guides/conversation-persistence/) # Render Markdown and highlight code > Turn the Markdown a model writes into formatted text and coloured code, while the reply streams and once it is done. [Markdown rendered while it streams](/preview/demo/response-streaming/) ```html ``` Markdown formatted as it streams: the list grows item by item. ## Install and register [Section titled “Install and register”](#install-and-register) ```bash npm install @aparte/plugin-streaming-markdown streaming-markdown @aparte/plugin-marked marked @aparte/plugin-shiki shiki ``` ```ts import { setupStreamingMarkdownProvider } from '@aparte/plugin-streaming-markdown'; import { setupMarkedProvider } from '@aparte/plugin-marked'; import { setupShikiProvider } from '@aparte/plugin-shiki'; setupStreamingMarkdownProvider(); setupMarkedProvider(); setupShikiProvider({ theme: { light: 'github-light', dark: 'github-dark' } }); ``` Core has no Markdown of its own: with no renderer set, a reply shows its text as written, escaped. Each plugin needs its library installed beside it — `streaming-markdown`, `marked`, `shiki` — and none of it reaches a page that does not install the plugin. Register them before the chat renders: a message drawn earlier keeps the plain text it was drawn with. ## Which one formats what [Section titled “Which one formats what”](#which-one-formats-what) * **`@aparte/plugin-streaming-markdown`** formats the reply while it streams, adding what each chunk brings instead of formatting the whole reply again. Alone, it formats finished and restored messages too. * **`@aparte/plugin-marked`** formats a finished or restored message at full fidelity. Beside the streaming plugin, it formats a reply again once it finishes; alone, it formats a streaming reply too, from the start at each chunk. * **`@aparte/plugin-shiki`** colours code: the code blocks of a reply — the stream parser makes each fence its own block — the fences in a message given as plain content and in reasoning, and the JSON a tool call shows. A fence inside a `text` segment you build yourself stays plain: send it as a `code` segment. To pass marked its options, give them to the setup: `setupMarkedProvider({ gfm: true, breaks: true })`. With options it uses a marked of its own, so two configurations can format differently. Another parser plugs into the same place: `aparteGlobalConfig.setMarkdownProvider((markdown) => html)`, a synchronous function from the Markdown to its HTML. ## Light and dark code [Section titled “Light and dark code”](#light-and-dark-code) The theme pair above follows the page: the dark theme under `data-aparte-theme="dark"` on any element around the code, or under a dark system unless `` carries `data-aparte-theme="light"`; the light one otherwise. A chat set light inside a dark page therefore keeps dark code. A single theme name, such as `{ theme: 'github-dark' }`, keeps that look in both, and with no `theme` at all code uses `github-dark` in both. A language shiki does not know shows as plain text. ## Ship only the grammars you need [Section titled “Ship only the grammars you need”](#ship-only-the-grammars-you-need) ```bash npm install @shikijs/langs @shikijs/themes ``` ```ts import { createHighlighterCore } from 'shiki/core'; import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'; import ts from '@shikijs/langs/typescript'; import bash from '@shikijs/langs/bash'; import githubDark from '@shikijs/themes/github-dark'; import { setupShikiProviderFromHighlighter } from '@aparte/plugin-shiki/core'; createHighlighterCore({ themes: [githubDark], langs: [ts, bash], engine: createJavaScriptRegexEngine(), }).then((highlighter) => setupShikiProviderFromHighlighter(highlighter, { theme: 'github-dark' })); ``` This replaces the `setupShikiProvider` call of the first step: an import of `@aparte/plugin-shiki` anywhere in your app brings every grammar back. That entry loads each grammar the first time a reply needs it, but your bundler cannot know which ones that will be, so it emits a file for every language shiki knows. The `/core` entry takes a highlighter you build with the grammars you choose, and your build carries those alone; a language it lacks shows as plain text. For a light and dark pair, load both themes into it: `themes: [githubLight, githubDark]`. The options are typed: `ShikiProviderOptions` for `setupShikiProvider`, `ShikiCoreProviderOptions` for the `/core` entry, `ShikiThemePair` for a light and a dark theme. A highlighter of your own passed to `/core` is a `ShikiHighlighterLike`, called with `ShikiRenderOptions`. ## Use another highlighter [Section titled “Use another highlighter”](#use-another-highlighter) ```ts import { aparteGlobalConfig } from '@aparte/core'; declare const myHighlighter: { toHtml(code: string, lang: string): Promise }; aparteGlobalConfig.setHighlightProvider((code, lang) => myHighlighter.toHtml(code, lang)); ``` The provider gets the code and its language, and returns the whole block as HTML, `
` included, as a string or a promise of one. ## Keep it safe [Section titled “Keep it safe”](#keep-it-safe) 
```ts
import DOMPurify from 'dompurify';
import { aparteGlobalConfig } from '@aparte/core';

aparteGlobalConfig.setHtmlSanitizer((html) => DOMPurify.sanitize(html));
```
 What marked and a highlighter return goes through the chat’s HTML sanitizer before it reaches the page, and so does a streamed reply once it finishes, so the Markdown a model writes cannot run script. While a reply streams, a link or an image with an unsafe address is dropped, and a link to another site opens in a new tab. The built-in sanitizer needs no dependency; [`setHtmlSanitizer`](/reference/core/config/#sethtmlsanitizer) swaps in one of yours, such as DOMPurify. ## Route the links a model writes [Section titled “Route the links a model writes”](#route-the-links-a-model-writes) 
```ts
declare const router: { push(path: string): void };   // your app's router

const chat = document.querySelector('aparte-chat');

chat?.addEventListener('aparte-link-click', (event) => {
  const url = new URL(event.detail.href, location.href);
  if (url.origin !== location.origin) return;   // another site: let the browser open it
  event.preventDefault();                       // this one is yours
  router.push(url.pathname + url.search);
});
```
 Before the browser follows a link in a reply, the message fires `aparte-link-click`. It bubbles, so one listener on the chat covers every message, and it can be cancelled: call `preventDefault()` and the navigation is yours — your router, a webview, a question first. The detail is `{ href, anchor, messageId }`. ## Two chats, two setups [Section titled “Two chats, two setups”](#two-chats-two-setups) 
```ts
import { AparteConfig, attachConfig } from '@aparte/core';
import { setupStreamingMarkdownProvider } from '@aparte/plugin-streaming-markdown';
import { setupMarkedProvider } from '@aparte/plugin-marked';
import { setupShikiProvider } from '@aparte/plugin-shiki';

const support = new AparteConfig();
setupStreamingMarkdownProvider(support);
setupMarkedProvider({ breaks: true }, support);
setupShikiProvider({ theme: 'github-dark' }, support);

const chat = document.querySelector('#support-chat');
if (chat) attachConfig(chat, support);
```
 Every setup takes a configuration as its last argument, the page-wide one by default. A configuration of your own starts from the built-in defaults and reads nothing the page-wide one registered, so set up on it every formatter that chat uses; attached to a chat, it formats that chat alone. The framework packages take it as their `config` prop. ## Next [Section titled “Next”](#next) * [Response The formatted reply, its variants and its options.](/components/response/) * [Code block Code as its own part of a reply, with its language and copy button.](/components/code-block/) * [Customization Your own way to draw a part of a message.](/guides/customization/)

# Run the agent loop on your server

> Run the agent loop next to your tools — a handler on your server, a runner in the page — with approvals that cross the network and survive a reload, and browser tools that still run in the page.

Your tools live on your server — files, a database, secrets — and the model has to call them. With the loop on the server, next to them, there is nothing to expose, and the page keeps its tool rows, Stop, the approval panel and your approval policy. To keep only the key off the browser, the [backend transport](/guides/backend-transport/) is enough; to drive the chat from a loop of your own, see [Your own loop](/guides/bring-your-own-loop/). ## The server [Section titled “The server”](#the-server) 
```ts
// app/api/agent/route.ts (Next.js)
import { createAparteAgentHandler } from '@aparte/core';
import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat';

export const POST = createAparteAgentHandler({
  providers: { openai: createOpenAICompatProvider(presets.OPENAI) },
  defaultModel: 'gpt-4.1-mini',               // when the page selects none
  resolveKey: () => process.env.OPENAI_API_KEY,
  authorize: (req) => isSignedIn(req),         // required: this route spends your key
  secret: process.env.AGENT_SECRET!,           // required, 32+ characters
  tools: [{
    tool: {
      name: 'read_file',
      description: 'Read a file from the site content.',
      inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
    },
    handler: readFileHandler,
  }],
});
```
 `createAparteAgentHandler` returns a Fetch handler, `Request` to `Response`, for a Next.js route in the Node runtime, Deno, Bun, or Node behind a small adapter — runtimes that resolve `@aparte/core` to its entry without a DOM ([On the server](/frameworks/elements/#on-the-server)). `authorize` returns `false`, for a 401, or a `Response` of its own. `secret` signs the calls a paused run leaves pending; an array rotates it, the first signing and all verifying. | Option | What it does | | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `approvalPolicy` | rules on each call, as `setApprovalPolicy` does in the page | | `clientTools` | whether the page’s own tools are accepted: `true` (the default), `false`, or a list of names | | `prepareRequest(request, req)` | rewrites the request before the run, on every request a resume included: a model, a temperature, a system prompt | | `maxTurns`, `toolTimeoutMs` | the model’s turns in one run, ten by default — a resume after a pause counts again — and a tool’s time, five minutes by default; a tool’s own `maxTurns` applies too | | `ticketMaxAgeMs` | how long a paused run can wait, seven days by default | | `onTicket(id)` | a check on each resume; `false` refuses it with a 409 | | `transport` | how the server calls the model, `new AparteDirectTransport({ byok: true })` by default | | `crypto` | the Web Crypto used to sign, for [Node 18](#node-18) | On Node’s own `http` server, hand it a `Request` and stream the `Response` back. Stop in the browser closes the connection, and the handler stops the run — the model call and any tool still running — when its request is aborted: 
```ts
import { createServer } from 'node:http';

// handler = createAparteAgentHandler({ … }), as above

createServer(async (req, res) => {
  const chunks: Buffer[] = [];
  for await (const chunk of req) chunks.push(chunk);
  const closed = new AbortController();
  res.on('close', () => closed.abort());
  const response = await handler(new Request(`http://localhost${req.url}`, {
    method: req.method, headers: req.headers as HeadersInit, body: Buffer.concat(chunks),
    signal: closed.signal,
  }));
  res.writeHead(response.status, Object.fromEntries(response.headers));
  if (response.body) for await (const chunk of response.body) res.write(chunk);
  res.end();
}).listen(3000);
```
 ## The browser [Section titled “The browser”](#the-browser) 
```ts
import { AparteClient, createAgentStreamRunner } from '@aparte/core';

const client = new AparteClient({
  streamRunner: createAgentStreamRunner({
    endpoint: '/api/agent',
    headers: async () => ({ authorization: `Bearer ${sessionStorage.getItem('token') ?? ''}` }),
  }),
});
client.start();
```
 The runner sends each turn to your endpoint and replays the server’s events, which the page draws as it draws a loop run in the page. A function for `headers` is called for each request, which is where a fresh token goes, and `fetch` replaces the one it calls. `credentials: 'include'` sends cookies to another origin; the handler sets no CORS headers, so your route or proxy answers the preflight (`OPTIONS`) and adds `Access-Control-Allow-Origin` (the page’s origin) and `Access-Control-Allow-Credentials: true`. The page registers no provider: the server runs its only provider, on `defaultModel`. When the handler has several, the page names one by its key in `providers`: 
```ts
import { aparteGlobalConfig } from '@aparte/core';

aparteGlobalConfig.setModelConfig({ defaultProvider: 'openai', defaultModel: 'gpt-4.1' });
```
 A model the page selects with `setModelConfig` wins over `defaultModel`; `prepareRequest` runs after it. ## Approvals across the network [Section titled “Approvals across the network”](#approvals-across-the-network) A server tool with `needsApproval: true`, or a call the server’s `approvalPolicy` answers `ask`, suspends the run: the stream ends with the pending call and a signed ticket, the page asks with the [approval panel](/guides/tools/#require-approval-human-in-the-loop), and the answer resumes the run on the server, in the same reply. The server decides which calls need a person; the page answers, through the person or through its own approval policy, such as [`@aparte/plugin-approval`](/components/approval-mode/)’s modes. The server can also refuse outright, with no one asked: 
```ts
createAparteAgentHandler({
  // …
  approvalPolicy: (call) => (call.name === 'delete_file' ? { verdict: 'deny', reason: 'Not from the website.' } : undefined),
});
```
 The person can leave and come back: nothing is stored on the server, the paused call lives in the conversation you already save. A [conversation manager](/guides/conversation-persistence/) saves the reply when the run pauses — the chat sends `aparte-message-paused` — and when it opens a conversation whose last reply is paused, the panel comes back. Saving another way, listen for `aparte-message-paused` on the chat — it bubbles — and save the message as it is, its segments included, since the paused call and its ticket are in them; after `setMessages`, call `await client.resumeDeferred()`. A new message sent instead of an answer closes the paused call. ## Browser tools [Section titled “Browser tools”](#browser-tools) A tool that needs the page stays registered in the page: `setupAskUser()` registers [`ask_user`](/components/question/), and `registerTool` your own. The runner sends its definition along; the server lets the model call it, pauses on it, and the page runs its handler and sends the result back. The page runs it as soon as the model calls it: its `needsApproval` and the page’s approval policy are not consulted on this path, so a browser tool that needs a person asks in its own handler, as `ask_user` does. To refuse browser tools, or accept a list of them: 
```ts
createAparteAgentHandler({
  // …
  clientTools: ['ask_user'],   // or false
});
```
 A browser tool may not share a server tool’s name: the request is refused. ## Open a file in the page when a server tool runs [Section titled “Open a file in the page when a server tool runs”](#open-a-file-in-the-page-when-a-server-tool-runs) To have the page do something when a call runs — open a file in the editor beside the chat — without a pause, make the tool a server tool that answers by itself, with what the page needs in its `structuredContent`, and act in the page when its call resolves: 
```ts
createAparteAgentHandler({
  // …
  tools: [{
    tool: {
      name: 'open_file',
      description: 'Open a file in the editor beside the chat.',
      inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
    },
    handler: async (call) => ({ toolCallId: call.id, content: 'opened', structuredContent: { openFile: String(call.input.path) } }),
  }],
});
```
 
```ts
import { AparteClient, createAgentStreamRunner } from '@aparte/core';
import type { AparteStreamRunner } from '@aparte/core';

declare function openFile(path: string): void;   // your page's own code

const runner = createAgentStreamRunner({ endpoint: '/api/agent' });
const openingFiles: AparteStreamRunner = (opts) => runner({
  ...opts,
  emitter: (event) => {
    if (event.type === 'tool-resolved') {
      const result = event.structuredResult as { openFile?: string } | undefined;
      if (result?.openFile) openFile(result.openFile);
    }
    opts.emitter(event);
  },
});

new AparteClient({ streamRunner: openingFiles }).start();
```
 `tool-resolved` arrives once the server tool has succeeded, a call resumed after a pause included, so a failed call opens nothing. A conversation opened later draws its finished calls from its history and runs none of them, so the file is not opened again. The same wrapping works on any runner. ## What the ticket protects, and what it does not [Section titled “What the ticket protects, and what it does not”](#what-the-ticket-protects-and-what-it-does-not) The ticket is a signature over the pending calls: a resume runs only calls this server emitted, for the reply it paused. Three things it does not do: * **It does not make a tool’s input safe.** The model chose the arguments; a tool checks the caller’s rights itself. Its handler gets the request being served as `context.request` — for a call answered after a pause, the request that resumed the run. * **It does not prove a person approved.** The browser sends the decision, so a caller holding the ticket can approve any call it names: `needsApproval` asks a person, and the handler still checks the caller’s rights. * **It is not single-use.** A server that stores nothing cannot tell a replay from a first use; if yours can, `onTicket` refuses a ticket it has seen. 
```ts
createAparteAgentHandler({
  // …
  onTicket: async (id) => {
    if (await seen.has(id)) return false;   // replayed: refused with a 409
    await seen.add(id);
    return true;
  },
  tools: [{
    tool: {
      name: 'delete_file',
      description: 'Delete a file.',
      inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
      needsApproval: true,
    },
    handler: async (call, signal, context) => {
      const user = await userOf(context?.request);
      const path = String(call.input.path);
      if (!user?.canWrite(path)) return { toolCallId: call.id, content: 'You cannot delete this file.' };
      await removeFile(path, { signal });
      return { toolCallId: call.id, content: `Deleted ${path}.` };
    },
  }],
});
```
 An approval’s `payload` — data an approval surface merges into the arguments — is dropped unless the tool opts in with `acceptsApprovalPayload: true`. ## Node 18 [Section titled “Node 18”](#node-18) The ticket is signed with Web Crypto, which browsers, Node 19 and later, Deno, Bun and Workers have. On Node 18, pass it in: 
```ts
import { webcrypto } from 'node:crypto';

createAparteAgentHandler({
  // …
  crypto: webcrypto.subtle as SubtleCrypto,
});
```
 ## The wire [Section titled “The wire”](#the-wire) NDJSON, one event per line: the [engine’s run events](/guides/engine/) as they are, a `run-suspended` line carrying the `ticket`, and a `run-error` line, never the vendor’s own words, when the run fails after the stream began. A server tool that throws ends the run: the page shows the call as failed — `The tool failed.` — then the reply fails, and the error itself goes to your server’s log; to let the model read a failure and go on, return it as `content`. An error before the stream is JSON with an HTTP status. The body is `{ v: 1, providerId?, messageId, request, clientTools, resume? }`, which the runner builds. Among the 400s, two concern tools: a `toolChoice` that names a tool, and a history ending on unanswered calls with no `resume` ticket.

# Theme the chat to your brand, in light and dark

> Restyle the whole chat with CSS variables — your brand colours, light and dark, density and shape — on the page, on one part of it, or on one chat.

[The chat in a brand of its own](/preview/demo/theme-brand/) 
```html



``` Eight variables on a wrapper, and every part of the chat follows them. ## Set your brand colours [Section titled “Set your brand colours”](#set-your-brand-colours) ```css :root, [data-aparte-theme="light"] { --aparte-primary: #b45309; /* the accent: send button, links, the user's bubble */ --aparte-primary-hover: #92400e; --aparte-bg: #fbf7f0; /* your page's background */ --aparte-surface-1: #ffffff; /* cards, code blocks, the composer */ --aparte-surface-2: #f4ece0; /* headers, inline code */ --aparte-text: #241a12; --aparte-text-muted: #7c6f60; --aparte-border: #e7dccb; } ``` The chat’s colours follow these eight: 241 of core’s variables read another one, such as `--aparte-input-bg`, which is `var(--aparte-surface-1)`, or `--aparte-avatar-bg-user`, which is `var(--aparte-primary)`. Set the bases, not each value they feed. The block wins whichever stylesheet loads first, and its second selector keeps it on an element [forced light](#light-and-dark). A few do not follow them. The status colours — `--aparte-info`, `--aparte-success`, `--aparte-warning`, `--aparte-error` — and `--aparte-secondary` are yours to set in the same block if your brand has an opinion, and so is `--aparte-neutral`, which draws the focus ring, the scrollbar and the neutral buttons. The chat paints no background of its own, so your page shows through it: `--aparte-bg` only tints the progress track and the loading skeleton, and is there for your page too (`body { background: var(--aparte-bg); }`). ### The text on a fill [Section titled “The text on a fill”](#the-text-on-a-fill) ```css :root { --aparte-primary: #1a1a2e; --aparte-on-primary: #f8fafc; } ``` The label on a filled button, badge or checkbox is worked out from the fill’s lightness, so it stays readable whatever colour you choose. Set its pair to choose it yourself: `--aparte-on-primary`, `--aparte-on-secondary`, `--aparte-on-neutral`, `--aparte-on-info`, `--aparte-on-success`, `--aparte-on-warning` and `--aparte-on-error`. Two numbers tune the worked-out ink: `--aparte-ink-flip`, the fill lightness where it turns from light to dark, and `--aparte-ink-dark`, how dark it gets. ## Light and dark [Section titled “Light and dark”](#light-and-dark) ```html … ``` With no attribute, the system’s `prefers-color-scheme` decides. `data-aparte-theme` forces either way, on the chat or on any ancestor, so a light panel inside a dark page stays light. The attribute puts core’s palette on the element that carries it, which is why your light block names `[data-aparte-theme="light"]` beside `:root`. ## Give dark its own colours [Section titled “Give dark its own colours”](#give-dark-its-own-colours) ```css /* light */ :root, [data-aparte-theme="light"] { --aparte-primary: #087ea4; /* …the rest of your light palette */ } /* dark, when your toggle sets the attribute */ [data-aparte-theme="dark"] { --aparte-primary: #58c4dc; /* …the same tokens, in dark */ } /* dark, when the system decides */ @media (prefers-color-scheme: dark) { :root:not([data-aparte-theme="light"]) { --aparte-primary: #58c4dc; /* …the same tokens, in dark */ } } ``` Without a dark block, a dark system shows core’s dark palette rather than your light colours. Give each dark block every token your light block sets. Core’s dark palette has a value for the eight, `--aparte-neutral`, the shadows, the scrim behind dialogs and the error colours (`--aparte-error-bg`, `--aparte-error-text`, `--aparte-error-border`, `--aparte-error-solid`), and any of those you leave out takes core’s dark value rather than yours. Keep the selector of the third block as it is: a bare `:root` inside the media query loses to core’s dark palette. ## Theme one chat, or part of the page [Section titled “Theme one chat, or part of the page”](#theme-one-chat-or-part-of-the-page) ```html ``` A variable set on an element reaches everything inside it: one [chat](/components/chat/), a sidebar, a section of the page. A token that reads another one, such as `--aparte-avatar-bg-user` or `--aparte-radius-bubble`, is worked out again on every `aparte-chat`, every `[data-aparte-chat]` root the React, Vue and Svelte components render, and every `[data-aparte-theme]` element, so a chat given its own `--aparte-primary` gets its own user bubble and avatar too. The same rule stops a value you give such a token on `:root` at the chat: set it on the chat, in a rule with a class as above, which outweighs core’s own declaration there whatever order the stylesheets load in. The [CSS variables](/reference/css-variables/) reference marks those tokens **anchored**. In React, Vue and Svelte, the chat’s root is a `
`, and the same tokens are worked out again there: give it a class and write `[data-aparte-chat].sales { … }`. ## Make it denser, squarer or larger [Section titled “Make it denser, squarer or larger”](#make-it-denser-squarer-or-larger) ```css :root { --aparte-space-unit: 1.5px; /* every gap and padding, tighter */ --aparte-radius-unit: 2px; /* every corner, squarer */ --aparte-font-scale: 1; /* every text size, a notch smaller */ --aparte-btn-size-md: 28px; /* the medium control, and every size read from it */ } ``` The sizes are scales, each a base times a step, so one base moves a whole family: | Scale | Tokens | Base | | -------- | --------------------------------------------------- | ---------------------- | | Spacing | `--aparte-space-1` … `--aparte-space-8` | `--aparte-space-unit` | | Corners | `--aparte-radius-xs` … `--aparte-radius-xl` | `--aparte-radius-unit` | | Text | `--aparte-font-size-2xs` … `--aparte-font-size-2xl` | `--aparte-font-scale` | | Controls | `--aparte-btn-size-sm`, `-md`, `-lg`, `-xl` | each its own | `--aparte-radius-full`, the pill, stays a pill whatever the unit. A single step can be set too, on the chat with a class: `aparte-chat.chat { --aparte-space-6: 8px; }` pulls in the paddings that use it and leaves the rest of the scale alone. The kit’s controls read the same tokens, so a page of [buttons](/components/button/) and [fields](/components/field/) moves with the chat. ## Small recipes [Section titled “Small recipes”](#small-recipes) They set tokens on ``. In React, Vue and Svelte, give the chat’s root the class with `className` and write `[data-aparte-chat].chat`. ### Give the assistant a bubble too [Section titled “Give the assistant a bubble too”](#give-the-assistant-a-bubble-too) ```css aparte-chat.chat { --aparte-message-content-bg-assistant: var(--aparte-surface-2); } .aparte-message[data-role="assistant"] .aparte-message-content { width: fit-content; padding: var(--aparte-message-content-padding); } ``` The assistant’s reply is plain text across the column by default, and only the person’s words sit in a bubble. The token colours the reply; the rule gives it the user bubble’s shape. ### Resize the composer [Section titled “Resize the composer”](#resize-the-composer) ```css aparte-chat.chat { --aparte-composer-control-size: 52px; } ``` The input’s height and every button in the composer follow it. On a touch screen they take `--aparte-touch-target-size` instead. ### Change the code font [Section titled “Change the code font”](#change-the-code-font) ```css :root { --aparte-code-font-family: 'JetBrains Mono', ui-monospace, monospace; } ``` ### Mark a row as chosen [Section titled “Mark a row as chosen”](#mark-a-row-as-chosen) ```css :root { --aparte-mark-tint: 12%; /* a quieter ground, for marked rows and the user's bubble */ --aparte-mark-bar: 3px; /* a bar on the start edge, which has none by default */ } ``` ```html
  • Chosen
  • Accepted
  • Declined
  • ``` The [mark](/components/mark/) is how the chat shows what was chosen: a select’s option, a checked choice in a question, the answer a question kept. The same class marks a row of yours. `--success`, `--danger` and `--neutral` change the intent; `--quiet` is the one that did not happen, with no tint and muted text. ### Type a theme in TypeScript [Section titled “Type a theme in TypeScript”](#type-a-theme-in-typescript) ```ts import type { AparteThemeVariables } from '@aparte/core'; export const brand: AparteThemeVariables = { '--aparte-primary': '#b45309', '--aparte-surface-1': '#ffffff', }; for (const [name, value] of Object.entries(brand)) { if (value) document.documentElement.style.setProperty(name, value); } ``` A key that does not start with `--aparte-` is a compile error. The name after the prefix is not checked, which is what the next recipe is for. ### Check that every token you use exists [Section titled “Check that every token you use exists”](#check-that-every-token-you-use-exists) A `var(--aparte-text-primary)` that names a token the library does not have fails without a word: the property falls back to its inherited or initial value, and the page looks almost right. The stylesheet you import names every token it declares or reads, so a script can check your CSS against it: ```js // aparte-tokens.mjs — node aparte-tokens.mjs src/**/*.css import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; const sheet = readFileSync(createRequire(import.meta.url).resolve('@aparte/core/styles.css'), 'utf8'); const known = new Set(sheet.match(/--aparte-[\w-]+/g)); let bad = 0; for (const file of process.argv.slice(2)) { for (const name of new Set(readFileSync(file, 'utf8').match(/--aparte-[\w-]+/g) ?? [])) { if (!known.has(name)) { bad++; console.log(`${file}: ${name} is not an @aparte/core token`); } } } process.exit(bad ? 1 : 0); ``` Run it with your tests. A name it reports that you declared yourself is fine, though a token of your own reads better under a prefix of your own. ## Inside a shadow root of your own [Section titled “Inside a shadow root of your own”](#inside-a-shadow-root-of-your-own) Load `@aparte/core/styles.css` inside the shadow root — a `` in it, or `adoptedStyleSheets` — since a document’s stylesheet does not cross into it. The stylesheet declares its tokens on `:host` as well as `:root`, so the chat themes the same way: set the variables on the host or on any element inside, and put `data-aparte-theme` on the host itself, since an attribute on an element outside the shadow root does not reach the chat. `` and `` look their partner up in the document; inside a shadow root, put them in the chat and leave `target` out. ## Next [Section titled “Next”](#next) * [Customization Past colours and sizes: your own icons, avatars, buttons and parts of a message.](/guides/customization/) * [CSS variables Every token, its default, and whether it is anchored.](/reference/css-variables/) * [Layouts A reading column, a full-width feed, a side panel, a floating composer.](/guides/layout/) * [The kit The controls around the chat, in the same theme.](/kit/) # Give each conversation a short title > Title conversations from their first message — with a small model that runs in the browser, or with your own titling on a server or a model. ## When a title is decided [Section titled “When a title is decided”](#when-a-title-is-decided) A conversation in a [manager](/guides/conversation-persistence/) takes its title from the person’s first message, written as they typed it — even over a title the conversation was created with. Editing that message gives it a new title while the title is still the manager’s, which `autoTitle: true` says. A title typed in after that, through `updateTitle` or the list’s rename, is never replaced; the list renames through the manager when it has `manage`, or when you answer its `aparte-conversation-rename`. A title provider replaces the “as typed” part: it gets the message and answers a short title. ## Title in the browser [Section titled “Title in the browser”](#title-in-the-browser) ```bash npm install @aparte/plugin-titler @aparte/titler-latin ``` ```ts import { aparteGlobalConfig } from '@aparte/core'; import { loadTitler } from '@aparte/titler-latin'; import modelUrl from '@aparte/titler-latin/model?url'; import { setupTitler } from '@aparte/plugin-titler'; const manager = aparteGlobalConfig.getConversationManager(); // the one your chat saves to if (manager) setupTitler(manager, { titler: () => loadTitler(modelUrl) }); ``` The model reads the first message in the browser, with no API call, and picks the words that make the title: it copies them in the message’s order and does not rephrase. `loadTitler` runs once, the first time a conversation needs a title, and fetches the model file from your own site; a load that fails is tried again on the next title. The `?url` import is Vite’s way of serving the package’s model file; with another bundler, copy the `.bin` from the package’s `model` folder into what you serve and give `loadTitler` its path. Run the setup once the manager is registered — after `setConversationManager`, or once a framework helper’s `init` has resolved. Left to its default, the model keeps among its six best-scored words those it is confident about, never fewer than three, and every word of a short message. `budget: 4` keeps exactly four of its best-scored words instead. `setupTitler` returns the function that gives the manager back its previous provider, unless another was set since. [Try the model](/models/titler/#demo) on its own page. ## Pick a model [Section titled “Pick a model”](#pick-a-model) | Package | Reads | Model file | | --------------------------- | -------------------------------------------------------------------------------- | ---------- | | `@aparte/titler-latin` | 17 languages: en, fr, es, de, pt, it, nl, pl, sv, da, fi, cs, ro, no, hu, hr, lt | 133 KB | | `@aparte/titler-latin-mini` | the same 17, with a smaller vocabulary | 96 KB | | `@aparte/titler-efigsp` | 6 languages: en, fr, es, de, pt, it | 77 KB | For a model file of your choice, download a `.bin` from the [model repository](https://huggingface.co/apartejs/aparte-titler), serve it from your site, and give its bytes to the runtime alone, `@aparte/titler`: ```ts import type { AparteConversationManager } from '@aparte/core'; import { loadTitler } from '@aparte/titler'; import { setupTitler } from '@aparte/plugin-titler'; declare const manager: AparteConversationManager; const bytes = (url: string): Promise => fetch(url).then((r) => { if (!r.ok) throw new Error(`${url}: ${r.status}`); return r.arrayBuffer(); }); setupTitler(manager, { titler: () => loadTitler(bytes('/models/titler-fr.bin')) }); ``` ## Title your own way [Section titled “Title your own way”](#title-your-own-way) ```ts import type { AparteConversationManager } from '@aparte/core'; declare const manager: AparteConversationManager; manager.setTitleProvider(async (text) => { const response = await fetch('/api/title', { method: 'POST', body: text }); return response.ok ? response.text() : ''; }); ``` A title provider gets the message’s text and the message, and answers a title, at once or as a promise: a request to your server, a call to a model, a rule of yours. An empty answer, or an error, keeps the message as typed, and an error is logged. `setTitleProvider(null)` puts the default back. ## Without a setup call [Section titled “Without a setup call”](#without-a-setup-call) ```ts import { AparteConversationManager, type AparteStorageAdapter } from '@aparte/core'; import { loadTitler } from '@aparte/titler-latin'; import modelUrl from '@aparte/titler-latin/model?url'; import { createTitleProvider } from '@aparte/plugin-titler'; declare const adapter: AparteStorageAdapter; const manager = new AparteConversationManager(adapter, { titleProvider: createTitleProvider({ titler: () => loadTitler(modelUrl) }), }); ``` `createTitleProvider` is the provider alone, for a manager you build with one. Its options are a `TitlerOptions`, and any object with `title(message, budget?)` — a `TitlerLike` — can be the `titler`. ## Next [Section titled “Next”](#next) * [aparte-titler The model, running in your browser, and the numbers behind it.](/models/titler/) * [Save conversations The manager the titles are written to, and the list that shows them.](/guides/conversation-persistence/) * [Conversation list The sidebar list, with each conversation under its title.](/components/conversation-list/) # Let the model call your code, and ask a person first > Register a tool the model can call, show each call as it runs, say what it targets, and make a person approve it before it runs. ## Register a tool [Section titled “Register a tool”](#register-a-tool) ```ts import { aparteGlobalConfig, AparteClient } from '@aparte/core'; import type { AparteTool, AparteToolHandler } from '@aparte/core'; const getTime: AparteTool = { name: 'get_time', description: 'Return the current time in a given IANA timezone.', inputSchema: { type: 'object', properties: { timezone: { type: 'string' } }, required: ['timezone'], }, }; const handleGetTime: AparteToolHandler = async (call) => ({ toolCallId: call.id, content: new Date().toLocaleString('en-US', { timeZone: String(call.input['timezone']) }), }); aparteGlobalConfig.registerTool(getTime, handleGetTime); new AparteClient().start(); ``` `AparteClient` offers every registered tool to the model, runs your handler when the model calls it, gives the result back, and lets the model go on. `inputSchema` is a JSON Schema, sent as it is. The handler gets the call — `{ id, name, input }` — an `AbortSignal` that fires when the turn is stopped or the tool runs too long, and a context whose `target` is the chat the turn belongs to. It answers `{ toolCallId, content }`: `content` is what the model reads, and an optional `structuredContent` reaches a [renderer of yours](#custom-tool-renderer) as `segment.structuredResult`, never the model. A tool’s `systemPrompt` tells the model when to use it, and its `maxTurns` is the last turn of a reply in which it may still run. On the client, `toolTimeoutMs` is the time a handler may take, five minutes by default: a call past its turns or its time is stopped, and the reply ends with it. The client’s `maxTurns` caps the model’s turns in one reply, ten by default, and a reply that reaches it ends on an error message. A model whose declared `capabilities` leave out `function_calling` gets no tools; a model that declares nothing gets them. ## What the row shows [Section titled “What the row shows”](#what-the-row-shows) [Every status a tool call can be in](/preview/demo/tool-statuses/) ```html ``` A tool call in each of its states. Each call is one line in the reply, in the order it happened: the tool’s name and its state. Opened, it shows the arguments the model chose under **Input** and the result under **Output**, as formatted JSON, coloured when a [highlighter](/guides/markdown/) is registered. | State | Word | When | | ------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `awaiting-approval` | Waiting | a person has not decided yet | | `pending` | Running | the handler runs, with a spinner | | `resolved` | Done | the handler answered | | `rejected` | Rejected | a person, or a [policy](#decide-in-code), refused it | | `aborted` | Stopped | the reply was stopped, or the call could not run — no handler, no one to ask, past its time or its turns — and the reply ends there | | `failed` | Failed | the handler threw | A handler that throws marks its call failed and ends the reply on the error. A failure the model should read and work around is a result instead: return it as `content`. The words are locale keys, and `extendLocale` changes them: ```ts import { aparteGlobalConfig } from '@aparte/core'; aparteGlobalConfig.extendLocale({ toolRunning: 'Working…', toolCompleted: 'Done', toolRejected: 'Refused', toolStopped: 'Stopped', toolFailed: 'Failed', approvalWaiting: 'Waiting', toolInput: 'Arguments', toolOutput: 'Result', }); ``` The row is `.aparte-segment-tool-call`: its line `.aparte-tool-summary`, rounded by `--aparte-tool-row-radius`, the word `.aparte-tool-state`, and the opened part `.aparte-tool-detail`. The [class reference](/reference/classes/) lists them all. ## Show what a call targets [Section titled “Show what a call targets”](#show-what-a-call-targets) ```ts import { aparteGlobalConfig } from '@aparte/core'; aparteGlobalConfig.registerToolSummary('read_file', (segment) => String(segment.toolCall.input['path'] ?? '')); ``` [A tool row that says what it ran on](/preview/demo/tool-summary/) ```html ``` read\_file with the file it read, without opening the row. A summary is the line the row writes after the tool’s name: `read_file skills.json` rather than `read_file` alone. It is read again each time the call changes and when the language switches, and written as text; an empty string, or a summary that throws, shows nothing. A tool drawn by a [renderer of its own](#custom-tool-renderer) shows no summary. ## Require approval (human-in-the-loop) [Section titled “Require approval (human-in-the-loop)”](#require-approval-human-in-the-loop) ```ts import { aparteGlobalConfig } from '@aparte/core'; aparteGlobalConfig.registerTool( { name: 'delete_file', description: 'Delete a file from the workspace.', inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] }, needsApproval: true, }, async (call) => ({ toolCallId: call.id, content: `Deleted ${String(call.input['path'])}` }), ); ``` [A tool call waiting for a person](/preview/demo/approval/) ```html ``` The decision, asked at the composer: the question, the arguments, the choices. With `needsApproval`, the call waits before its handler runs. The row says which tool waits, and the decision is asked at the composer: the question, the arguments the model chose, the choices, and a field to write something else. The panel is ``, which `` and the framework wrappers already hold; a layout you compose yourself has to include it, or, with no one to ask, the call is stopped. Its words are the locale keys `approvalAsk` (`Run {tool}?`), `approveTool`, `rejectTool` and `approvalInstructionPlaceholder`. * **Approve** runs the handler. * **Reject** never runs it: the model reads the refusal, the other calls of that turn are skipped, and the model gets a turn to answer it. * **Writing instead of choosing** is a refusal that carries your words, which the model reads — except with an [AG-UI agent](/providers/agent/ag-ui/), which gets only `{ approved: false }`. * **Stop** ends the reply, and the call shows Stopped; the model is told nothing. Each time a call starts to wait, the chat sends `aparte-tool-approval-request` with `{ toolCallId, toolName, input }`, for a notification of yours. When the loop runs on your server, the same panel answers its tools, even after a reload: [approvals across the network](/guides/server-loop/#approvals-across-the-network). Approval is not authorization The approval runs in the browser: it spares the person a surprise, it does not protect your system. A tool that acts on your backend is checked there, in the endpoint it calls, every time: who is calling, and whether they may. When the loop runs on your server, the handler reads the caller from `context.request` ([server loop](/guides/server-loop/)). ## Decide in code [Section titled “Decide in code”](#decide-in-code) ```ts import { aparteGlobalConfig } from '@aparte/core'; aparteGlobalConfig.setApprovalPolicy((call) => { if (call.name === 'run_command' && String(call.input['cmd']).startsWith('rm ')) return { verdict: 'deny', reason: 'Deleting is off in this workspace.' }; if (call.name.startsWith('read_')) return { verdict: 'allow' }; return undefined; // the tool's own needsApproval decides }); ``` A policy rules on each call before anyone is asked: `allow` runs it, `ask` asks the person as `needsApproval` would, and `deny` refuses it, with the `reason` the model reads. The modes people know from agent apps — plan, ask, auto-edit, auto — with a switch in the composer’s toolbar, are [`@aparte/plugin-approval`](/components/approval-mode/). To answer with no panel at all — from a command line, a webhook, a team channel — give the client an `approvalResolver`. It answers every call of a `needsApproval` tool, and a policy set with `setApprovalPolicy`, the approval modes included, is then not consulted: rule on the call in the resolver, or use a policy, not both. ```ts import { AparteClient } from '@aparte/core'; declare function askTheTeam(name: string, input: Record, signal: AbortSignal): Promise; new AparteClient({ approvalResolver: async (call, signal) => ({ approved: await askTheTeam(call.name, call.input, signal), instruction: 'Use the staging bucket instead.', // read by the model on a refusal }), }).start(); ``` A resolver may also answer a `payload`, an object merged into the arguments before the handler runs, to correct a path while approving; the row still shows what the model sent. ## Custom tool renderer [Section titled “Custom tool renderer”](#custom-tool-renderer) ```ts import { aparteGlobalConfig } from '@aparte/core'; import type { AparteToolRenderer } from '@aparte/core'; const searchRenderer: AparteToolRenderer = { render(segment) { const el = document.createElement('div'); el.className = 'my-search'; el.textContent = `Searching for ${String(segment.toolCall.input['query'] ?? '')}`; return el; }, update(el, segment) { if (segment.status === 'awaiting-approval') el.textContent = 'Waiting for your approval'; else if (segment.status === 'pending') el.textContent = 'Searching…'; else if (segment.status === 'resolved') el.textContent = segment.result ?? ''; else el.textContent = `Search ${segment.status}`; // rejected, aborted, failed }, }; aparteGlobalConfig.registerToolRenderer('web_search', searchRenderer); ``` [A tool drawn by a renderer of your own](/preview/demo/tool-renderer/) ```html ``` A tool drawn by a renderer of its own, in place of the row. A renderer draws every call of one tool in place of the row, in every state: `render` when the call appears, `update` when it changes — a decision is made, its result lands — and `relabel` when the language or the icons change. Without `update`, the call is drawn again from `render` at each change. Read `segment.status`, as above: the renderer shows the wait for approval itself, and a refused or failed call carries the refusal or the error in `segment.result`. Return one element or a string of markup. An empty string returned as the call starts keeps it out of the reply; returned at any other time — a later change, a stored conversation drawn again — it brings back the default row. The arguments and the result come from the model and the tool: never write them into markup as they are. Set them as `textContent`, as above, or pass them through `escapeHtml` and `escapeAttr`. A result meant to be shown as a thing — a document, a form, a receipt — comes as the handler’s `structuredContent`, which the renderer reads as `segment.structuredResult`; the [question](/components/question/) and [artifact](/components/artifact/) plugins draw their cards so. A message clips what its parts draw outside it, so a renderer’s menu or popover goes on `document.body`, placed from its trigger’s position: ```ts function openMenu(trigger: HTMLElement): HTMLElement { const menu = document.createElement('div'); menu.className = 'aparte-menu'; const r = trigger.getBoundingClientRect(); menu.style.cssText = `position: fixed; left: ${r.left}px; top: ${r.bottom}px;`; document.body.append(menu); return menu; } ``` ## Next [Section titled “Next”](#next) * [Ask the user a question A choice, a yes or no, some text or a small form, in the middle of a run.](/guides/elicitation/) * [Run the loop on your server The same tools and approvals, with the loop and the key on your server.](/guides/server-loop/) * [Tool The row in every state, its parts and its options.](/components/tool/) # Troubleshooting > The failures people meet first — a bare import the browser cannot resolve, a local model the browser refuses, no provider, the key warning, missing buttons — and what an error tells you. ## `Failed to resolve module specifier "@aparte/core"` [Section titled “Failed to resolve module specifier "@aparte/core"”](#failed-to-resolve-module-specifier-apartecore) The page imports `@aparte/core` by name with no bundler in front of it, and a browser cannot resolve a bare name on its own. Use a bundler — Vite, Next, Astro, esbuild, any of them — as every snippet here assumes, or load the packages from a CDN by URL, as in [From a CDN](/guides/getting-started/#from-a-cdn). ## CORS on local BYOK (LM Studio / Ollama) [Section titled “CORS on local BYOK (LM Studio / Ollama)”](#cors-on-local-byok-lm-studio--ollama) The server’s log shows the request, and the reply ends in an error such as `Failed to fetch` (code `NET_ERROR`): the browser called the local server itself — `http://localhost:1234` for LM Studio, `http://localhost:11434` for Ollama — and refused the answer, because the server did not allow the page’s origin. * **LM Studio:** turn on **Enable CORS** in the server settings, in the Developer tab, then restart the server. * **Ollama:** start it with `OLLAMA_ORIGINS` set to the page’s origin, or `*` while you develop. ```bash OLLAMA_ORIGINS=* ollama serve ``` ```powershell $env:OLLAMA_ORIGINS = "*" ollama serve ``` If the Ollama app is running, quit it first, or set `OLLAMA_ORIGINS` as a user environment variable and restart the app. [Local models](/guides/local-models/) has the whole setup for both, and for llama.cpp. ## ”Provider is not registered”, “No provider selected” [Section titled “”Provider is not registered”, “No provider selected””](#provider-is-not-registered-no-provider-selected) ``` [AparteClient] Send dropped: no model is selected. … ``` ```ts import { aparteGlobalConfig, AparteClient, AparteDirectTransport } from '@aparte/core'; import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat'; aparteGlobalConfig.registerAIProvider(createOpenAICompatProvider(presets.OLLAMA)); aparteGlobalConfig.setModelConfig({ defaultProvider: 'ollama', defaultModel: 'llama3.2' }); aparteGlobalConfig.setTransport(new AparteDirectTransport({ byok: true })); new AparteClient().start(); ``` Neither error appears in the chat: the person’s message gets no reply, the console warns, and `aparte-message-error` carries the code. **Provider is not registered:** the selected `defaultProvider` names an id no provider was registered under. **No provider selected:** nothing chose a model — no `setModelConfig`, and no [model selector](/components/model-selector/) has picked one; `aparteGlobalConfig.hasSelectedModel()` is `false` until one is. A provider registered alone with a single model is selected by itself; for a list fetched from the server, `` picks one. To keep the composer from sending until a model is chosen, `aparteGlobalConfig.setRequireModelSelection(true)`. A page that never calls `new AparteClient().start()` looks the same from the outside: nothing streams, because nothing answers the send. ## ”Sending the API key straight from the browser” warning [Section titled “”Sending the API key straight from the browser” warning”](#sending-the-api-key-straight-from-the-browser-warning) ``` [Aparte] AparteDirectTransport is sending the "" API key straight from the browser — it is visible to anyone who opens devtools. … ``` `AparteDirectTransport` sent a real key from the browser, and says so once per transport. When the key is the person’s own, or the model is local, say so and the warning stops: `new AparteDirectTransport({ byok: true })`; a keyless local provider never triggers it. When the key is yours, anyone with devtools can read and spend it: send the requests through [your server](/guides/backend-transport/) instead. ## `[Unknown segment type: …]` in a message [Section titled “\[Unknown segment type: …\] in a message”](#unknown-segment-type--in-a-message) No renderer claims that segment’s type. * **A type of yours:** register a renderer for it with `registerSegmentRenderer`, or give the segment a `fallback`, a sentence drawn as text when nothing claims it. A `custom` segment’s `subType` is not a key of its own: one renderer answers every `custom` segment and reads its `subType` ([Custom segment types](/guides/customization/#custom-segment-types)). * **A built-in type** — `text`, `thinking`, `code`, `tool_call`, `error` — means the built-in renderers were declined, with `new AparteClient({ autoRegister: false })`. Drop the option, or register what you need yourself. ## The retry, edit or ⓘ buttons are not there [Section titled “The retry, edit or ⓘ buttons are not there”](#the-retry-edit-or-ⓘ-buttons-are-not-there) ```ts import { aparteGlobalConfig } from '@aparte/core'; aparteGlobalConfig.setBubbleActions({ retry: true, edit: true }); // you run an AparteClient aparteGlobalConfig.setBubbleActions({ feedback: true, info: true }); // you answer these events aparteGlobalConfig.setHostHandlers({ attachmentPreview: true }); // you open it on aparte-attachment-preview ``` Only copy is on by default: the others need something outside the chat to answer them, so they stay off until you say it is there. ⓘ also needs the reply’s token usage: it shows on a reply whose provider reported usage. The bar hides while a reply streams, and a bar with no action in it is not drawn at all. [Buttons](/guides/customization/actions/) lists each one. ## The composer toolbar is not showing [Section titled “The composer toolbar is not showing”](#the-composer-toolbar-is-not-showing) An empty toolbar is hidden: it carries `data-empty` while it holds nothing visible, and whitespace or comments do not count. If you put a control in it and still see `data-empty`, either the control never arrived — a mistyped slot name, a framework that rendered nothing — or it hides itself: a child with `hidden` or `data-empty` does not count, and `` stays `data-empty` until the first reply reports its usage. A toolbar that shows but is unstyled, with your control at the start instead of the end, means the stylesheet did not load: import `@aparte/core/styles.css` ([The composer toolbar](/guides/customization/composer-toolbar/)). ## What an error tells you [Section titled “What an error tells you”](#what-an-error-tells-you) ```ts document.querySelector('aparte-chat')?.addEventListener('aparte-message-error', (event) => { const { error } = event.detail; // an AparteError console.error(error.code, error.message, error.httpStatus); }); ``` A failure that reaches the chat — a refused key, a rate limit, a dropped connection — is an `AparteError`: its `message`, a `code`, the `httpStatus` when there was one, its `data` and the `originalError`. The message shows it as an [error](/components/error/), and the chat sends `aparte-message-error` with `{ messageId, error }`. A missing or unregistered provider is the exception: the send is refused before a reply exists, so nothing appears in the chat and only the event reports it. A vendor’s HTTP status sets the code: | Status | `code` | | ------------- | ---------------------- | | 400 | `USAGE_BAD_REQUEST` | | 401, 403 | `CONFIG_INVALID_KEY` | | 408 | `NET_TIMEOUT` | | 429 | `USAGE_RATE_LIMIT` | | 503 | `PROVIDER_UNAVAILABLE` | | any other 5xx | `PROVIDER_ERROR` | A network failure is `NET_ERROR`, or `NET_OFFLINE` when the browser says it is offline; a timeout is `NET_TIMEOUT`; no provider selected is `CONFIG_NO_PROVIDER`, and one not registered `CONFIG_MISSING_KEY`. Anything else is `UNKNOWN_ERROR` — except under a runner that calls the model elsewhere (`createAgentStreamRunner`, an AG-UI runner), where a failed or cut run, and a status the table does not name, is `PROVIDER_ERROR`. `AparteError.from(error)` turns any error into one by the same rules, and `AparteError.codeForStatus(status)` is the table above, for a provider of yours. To change how the error looks, use `aparteGlobalConfig.setErrorRenderer`. ## Next [Section titled “Next”](#next) * [Installation and first chat The setup these failures come from, step by step.](/guides/getting-started/) * [Docs for AI agents Give a coding agent these docs to read.](/guides/docs-for-agents/) # Why aparté > What aparté sets out to be, the choices it is built on, and when another library fits better. aparté is a chat runtime that stays a library. The chat, its streaming, its tools and its loop come as web components you place in any page, with no framework, no backend and no vendor imposed — and you take as much of it as you need. ## The choices it is built on [Section titled “The choices it is built on”](#the-choices-it-is-built-on) * **Web components, with one dependency of its own.** `@aparte/core` is plain custom elements, and its only dependency is `@aparte/engine`, the agent loop, released with it. Nothing from outside has to match your stack’s versions, and the [React, Vue, Svelte and Angular packages](/frameworks/) are thin bridges over the same elements. * **The format is not the transport.** A provider knows how to talk to a model; a transport decides where the request goes and who holds the key — [the browser, or your server](/guides/backend-transport/). The chat is the same either way. * **The loop is a part, not a lock-in.** `AparteClient` drives the loop in [`@aparte/engine`](/guides/engine/); the engine also runs alone, on a server or out of the page. Or keep your own loop and [stream its replies](/guides/bring-your-own-loop/) into the chat. ## What aparté is not [Section titled “What aparté is not”](#what-aparté-is-not) No routing, no sign-in, no settings screens, no database: those belong to your product. Storage is an interface you implement — [`AparteStorageAdapter`](/guides/conversation-persistence/) — not a backend that comes with it. ## Where it stands [Section titled “Where it stands”](#where-it-stands) aparté is in beta. Every `@aparte/*` package is a `0.x`, released together, and its public surface is [frozen](/reference/stability/); what changed, release by release, is in the [changelog](/changelog/). ## When another library fits better [Section titled “When another library fits better”](#when-another-library-fits-better) You only want visual building blocks and already have a chat runtime you like; or you are all-in on one framework and want its idioms end to end rather than web components. If that changes, every package here is MIT-licensed and works on its own — core alone, the engine alone, or the whole set. [Compare](/compare/) puts it side by side with the others.