This is the abridged developer documentation for aparté # Compare aparté with Loquix, assistant-ui, AI Elements & more (2026) > A fair, dated comparison of AI chat UI libraries — Loquix, assistant-ui, Vercel AI Elements, deep-chat, kitn, OpenAI ChatKit — on framework lock-in, runtime, local/in-browser models, human-in-the-loop 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 in **August 2026**; 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 more components. 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. ## The matrix [Section titled “The matrix”](#the-matrix) | | aparté | assistant-ui | AI Elements | deep-chat | Loquix | kitn | | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | --------------------------------------- | ----------------------------------- | ------------------------------- | ----------------------------------- | ----------------------------- | | Frameworks | Web Components + React, Vue, Svelte, Angular wrappers | React | React | Web Component (+ React wrapper) | Web Component (Lit) | Web Component | | 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 — presentational | | 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 + online editor | | 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](/plugins/)) | 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.** 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.** A few dozen Lit components 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 wider than aparté’s; its roadmap points at provider integrations, which is the ground aparté already covers. ## kitn [Section titled “kitn”](#kitn) **The presentational twin.** A single custom element that renders tool calls, reasoning traces and typed cards (confirmations, choices, forms) — “you bring the model” is the stated contract. Its theming is its strength: a small token set, dozens of presets and an online theme editor that exports CSS. **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. Not a chat component; if that is what you need, aparté is not it. * **NLUX** — React and vanilla, with a modified MPL licence to read before adopting. * **TanStack AI** — type-safe, framework-agnostic hooks with no UI (alpha). Closer to aparté’s engine than to its components. * **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 alpha (`0.x`, released in lockstep, renames land as renames — see [Why aparté](/why/#where-it-is-in-its-life)). 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. # Open Source ChatGPT Clone UI, Assembled with aparté > A complete chat application in Web Components — sidebar with conversation list and search, header, chat with attachments — talking to a scripted model. Copy it, point it at your model. The whole application, assembled from what ships: the shell grid and the header are **recipes** (classes), the sidebar and the conversation list are **elements** (they have behaviour — the drawer, the search, the selection), and the chat is ``. It talks to a **scripted model** here — no key, no network — so everything you see run is real except the model: the loop, the transcript, the tool call, the attachments. Width 375 768 Full [Open in a tab](/preview/demo/example-chatgpt-like/) [A ChatGPT-like assistant, assembled](/preview/demo/example-chatgpt-like/) Type haiku, table, code or weather. Narrow the frame and the sidebar becomes a drawer behind the toggle. Open it in a tab for the full screen. ```html
(aparté)
What is aparté?
scripted model
``` ## What you are looking at [Section titled “What you are looking at”](#what-you-are-looking-at) * **`.aparte-app-shell`** — a two-column grid: the sidebar spans both rows, the header and the main area take the second column. Give it a height and it fills it. * **``** — collapsible, a drawer under its `breakpoint`, and it listens for `data-aparte-sidebar-toggle` and `data-aparte-sidebar-search` on its own, so the header’s button and the search field need no script. * **``** — takes a `conversations` array, marks `active-id`, and dispatches `aparte-conversation-select`. The header title above follows it in one listener. * **``** — the chat with the file picker; the host consumes the files (an `AparteClient` does). ## Make it yours [Section titled “Make it yours”](#make-it-yours) Three moves turn this into your application: 1. **The model.** Replace the scripted provider with yours — an OpenAI-compatible endpoint (`@aparte/provider-openai-compat`, presets for OpenAI, Mistral, OpenRouter, Ollama, LM Studio), the Vercel AI SDK, or a model in the browser (`@aparte/provider-transformers`) — or keep the key on your server with the [backend transport](/guides/backend-transport/). 2. **The conversations.** Feed `conversations` from your store and persist with the [conversation manager](/guides/conversation-persistence/). 3. **The look.** It is all CSS variables and light DOM — see [theming](/guides/theming/). The recipe behind the shell, and its mobile behaviour, are on the [app shell guide](/guides/app-shell/); the layouts a chat can take — split, feed, side panel — on the [layout guide](/guides/layout/). # AI Chat Components for React, Vue, Svelte & Angular > Use aparté from React, Vue, Svelte or Angular — thin wrappers over the framework-agnostic web components, with ergonomic components plus a generic escape hatch. `@aparte/core` is vanilla web components, so it already works in **any** framework. The framework packages add ergonomics on top — you don’t have to hand-wire refs, events and lifecycle yourself. Each wrapper ships **three layers**: * **An opinionated component** — e.g. React’s ``: the full chat surface (viewport + composer + slots) as one idiomatic component, plus hooks/stores/services for state and the client. * **A typed surface for every element** — see [Placing elements, typed](/frameworks/elements/). Real attributes and real events on the real tag, checked by your compiler: JSX intrinsics in React, `GlobalComponents` in Vue, `SvelteHTMLElements` in Svelte, a standalone directive per element in Angular. This is how you place a model selector, a conversation list, or a composer you compose yourself. * **A generic escape hatch** — e.g. React’s ``: mounts **any** custom element as a framework component (props + events forwarded). It takes a tag name, not a registry lookup, so an element of your own or a third party’s works the same way; whatever defines the element has to be imported, and until it is the tag mounts empty and inert. For aparté’s own elements, reach for the typed surface instead. The wrappers depend **only** on `@aparte/core` — never on a specific provider. You register a provider (or none) in the [config](/providers/); the wrapper streams whatever’s configured. See [Providers](/providers/) for the model side. ## React, Vue, Svelte and Angular chat components [Section titled “React, Vue, Svelte and Angular chat components”](#react-vue-svelte-and-angular-chat-components) * **[React chat component](/frameworks/react/)** — `@aparte/react` (React 18/19). * **[Vue chat component](/frameworks/vue/)** — `@aparte/vue` (Vue 3.5+). * **[Svelte chat component](/frameworks/svelte/)** — `@aparte/svelte` (Svelte 4 and 5). * **[Angular chat component](/frameworks/angular/)** — `@aparte/angular` (Angular 19, standalone). All four expose the **same slots and the same six callbacks** — `messageSent`, `action`, `messagesChange`, `messageAppended`, `typingChange`, `conversationCreated` — each in its own idiom, and that is checked mechanically rather than promised. Both tables, with the four syntaxes side by side, are generated from the wrapper source: [Wrapper surface](/reference/wrappers/). # Angular AI chat component, standalone — @aparte/angular > The @aparte/angular wrapper — an ergonomic standalone component plus services over the aparté web components. `@aparte/angular` wraps `@aparte/core` for Angular 19: an ergonomic [``](/components/conversation/aparte-chat/) standalone component, services for the client and conversations, a typed directive for every element, and a generic `` escape hatch. ```bash npm install @aparte/angular @aparte/core @angular/core @angular/common rxjs ``` `@aparte/core`, `@angular/core`, `@angular/common` and `rxjs` are **peer dependencies**. Angular 19 only, deliberately The peer range is `^19.2.0` because 19 is the only major this wrapper is built and browser-tested against. It is a thin bridge over standard custom elements — no private Angular API, no `NgModule` — so 20 through 22 will very likely work, and an override or `--legacy-peer-deps` will let you try. We do not widen the range on “very likely”: [`@aparte/svelte`](/frameworks/svelte/) claims two majors because each is compiled and driven in a real browser in CI, and Angular has no second example yet. Tell us if you need a newer major — that is what would justify building one. ## `` [Section titled “\”](#aparte-chat) The components are **standalone** — import them directly, no NgModule: ```ts import { Component } from '@angular/core'; import { AparteChatComponent, type AparteMessage } from '@aparte/angular'; import '@aparte/core/styles.css'; @Component({ standalone: true, imports: [AparteChatComponent], template: `

Ask me anything…

`, }) export class Chat { // The chat owns its thread. Observe it via (messagesChange) — do NOT push it // back through [messages]: the user's message is appended for you on send, so // re-adding it in a (messageSent) handler double-counts it. messages: AparteMessage[] = []; } ``` Slots are **content projection** by attribute: `[slot='empty-state']`, `[slot='composer']`, `[slot='above-composer']`, `[slot='toolbar']` — the last one being the composer’s bottom row, with an example under [The composer toolbar](/guides/customization/#the-composer-toolbar). For a fully custom bubble, pass a template instead: ```html
{{ message.content }}
``` Outputs: `messageSent`, `messagesChange`, `messageAppended`, `action`, `typingChange`, `conversationCreated` — the same six on all four wrappers, with the payloads and the other three syntaxes side by side in the generated [Wrapper surface](/reference/wrappers/#callbacks). The imperative API (streaming, branch/edit, `scrollToBottom`, `getViewport`) is on the component instance — grab it with a `@ViewChild`. `injectTokenStream` takes the cross-wrapper `AsyncIterable` — the exact call that works on React/Vue/Svelte — **or** an RxJS `Observable` (the Angular-idiomatic shape); everything else mirrors the other wrappers. ## Wiring a real model [Section titled “Wiring a real model”](#wiring-a-real-model) The wrapper is **provider-agnostic**. `provideAparte()` registers your providers and client options at bootstrap **and starts the client** (`autoConnect`, on by default) — composer sends stream replies with zero extra wiring: main.ts ```ts import { bootstrapApplication } from '@angular/platform-browser'; import { AparteDirectTransport, aparteGlobalConfig } from '@aparte/core'; import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat'; import { provideAparte } from '@aparte/angular'; aparteGlobalConfig.setTransport(new AparteDirectTransport({ byok: true })); bootstrapApplication(App, { providers: [ provideAparte({ providers: [createOpenAICompatProvider(presets.OPENROUTER)], clientOptions: { /* AparteClientOptions */ }, }), ], }); ``` That’s it — no lifecycle wiring in your components. To own the client lifecycle yourself, pass `autoConnect: false` and use the service: ```ts // only with autoConnect: false — the manual escape hatch import { inject } from '@angular/core'; import { AparteAiService } from '@aparte/angular'; export class Chat { private ai = inject(AparteAiService); ngOnInit() { this.ai.connect(); } // idempotent — safe even if already connected ngOnDestroy() { this.ai.disconnect(); } } ``` `provideAparte()` is **config sugar and fully optional** — the components work without it, and you can call `aparteGlobalConfig.*` directly exactly like the React/Vue/Svelte wrappers do. Its `plugins` slots take **objects or loader functions** you supply, and `locale` takes an `AparteLocale` **object** (e.g. `locale: fr` from `@aparte/locale-fr`) — none of them take package-name strings — so this package stays a leaf with no plugin catalog. Pass a per-instance `[config]` to scope providers/transport to a single `` instead of `aparteGlobalConfig`. ## Any aparté element: a typed directive [Section titled “Any aparté element: a typed directive”](#any-aparté-element-a-typed-directive) Every element has a standalone directive whose selector is the tag, so you write the real element with typed Inputs and one Output per event — and **no `CUSTOM_ELEMENTS_SCHEMA`**, which would switch template checking off for every unknown tag in the file. Import the ones you use, or `APARTE_ELEMENT_DIRECTIVES` for all of them: ```ts import { AparteSelectDirective } from '@aparte/angular'; // then: @Component({ imports: [AparteSelectDirective], … }) ``` ```html @if (showPicker) { } ``` The `@if` is the point: the element is really in the template, so control flow and content projection reach it. Full set and the rules on [Placing elements, typed](/frameworks/elements/). ## Any OTHER element: `` [Section titled “Any OTHER element: \”](#any-other-element-aparte-ui) For an element aparté does not define — one of yours, or a third party’s — mount it generically. It forwards the interactive aparté events by default; pass `[events]` to listen to others: ```html ``` This used to be how you placed a model selector. It still works, and it is still the only way to mount a tag aparté knows nothing about — but for aparté’s own elements the directive above gives you type checking, one output per event, and an element the template can actually wrap. ## Also exported [Section titled “Also exported”](#also-exported) * `ConversationManagerService` — signal-based view over the core `AparteConversationManager` (list / create / archive), for a multi-conversation sidebar. ## Testing it [Section titled “Testing it”](#testing-it) Vitest, Karma — every runner — executes on Node, so `@aparte/core` resolves to its DOM-free entry and no `` element upgrades under jsdom: the tag stays a plain `HTMLElement` and every assertion about it fails for a reason nothing explains. Alias the specifier to [`@aparte/core/browser`](/frameworks/elements/#testing-your-components), the entry with the elements in it. # Placing elements, typed > Every aparté element has a typed surface in all four frameworks — real attributes, real events, checked by your compiler. Plus the escape hatch for an element aparté does not define. `` gives you the whole turn in one tag. Everything else — the model selector, the conversation list, a composer you compose yourself — is a **custom element you place**, and this page is about placing those with your compiler on your side. ## What “typed” means here [Section titled “What “typed” means here”](#what-typed-means-here) Every **core** element’s attribute surface is declared once in `@aparte/core` and consumed by all four wrappers. The registry is `AparteElementAttributes`, one entry per tag, with `AparteElementTagName` as its key union: ```ts import type { AparteElementAttributes, AparteElementTagName, AparteSelectAttributes } from '@aparte/core'; // One entry per tag. The wrappers derive their own typing from this, so an element added // to core is typed in every framework the moment it lands here. type SelectAttrs = AparteElementAttributes['aparte-select']; // = AparteSelectAttributes type EveryTag = AparteElementTagName; // 'aparte-chat' | 'aparte-select' | … const preset: AparteSelectAttributes = { placeholder: 'Pick a model', searchable: true }; ``` The per-element interfaces are exported individually too, when you want to type your own wrapper around one: `AparteChatAttributes`, `AparteChatViewportAttributes`, `AparteChatBubbleAttributes`, `AparteChatStatusAttributes`, `AparteComposerAttributes`, `AparteComposerInputAttributes`, `AparteComposerActionAttributes`, `AparteComposerAddAttachmentAttributes`, `AparteComposerToolbarAttributes`, `AparteConversationListAttributes`, `AparteSelectAttributes`, `AparteOptionAttributes`, `AparteOptgroupAttributes`, `AparteProgressSpinnerAttributes`, and `AparteNoAttributes` for the four that observe nothing. An element that does **not** come from core — from a plugin, or one of yours — is not in this registry, and that is the boundary rather than an omission. See [Your own element, or a plugin’s](#your-own-element-or-a-plugins). ### The one thing to know about presence attributes [Section titled “The one thing to know about presence attributes”](#the-one-thing-to-know-about-presence-attributes) An aparté element is **attribute-driven**: it reacts to an attribute being present, not to a property being assigned. In a template that means `false` is the wrong value to write, because React, Vue and Svelte all stringify what they set on a custom element — `searchable={false}` renders `searchable="false"`, and code that tests `hasAttribute` reads that as **on**. So in those three, a presence attribute is `'' | null | undefined`, and the types enforce it. Write `''` to set and `null` (or `undefined`) to remove. `AparteTemplateAttrs` and `AparteAttrValue` are the mapping that does it, exported in case you build your own template integration: ```ts import type { AparteTemplateAttrs, AparteAttrValue, AparteSelectAttributes } from '@aparte/core'; type InATemplate = AparteTemplateAttrs; // searchable?: '' | null | undefined ← not boolean, on purpose // placeholder?: string type Presence = AparteAttrValue; // '' | null | undefined ``` Angular is the exception, and its directives take a real `boolean` — see below. ## React [Section titled “React”](#react) The `aparte-*` tags are typed JSX intrinsics as soon as you import from `@aparte/react`. Nothing to register: ```tsx // A composer you compose yourself, slotted into .
``` Attribute names are the HTML ones (`max-height`, `message-id`, `data-role`). A wrong VALUE type is a type error; so is an attribute the element does not observe. Events reach you by ref, and they are typed through the DOM because `@aparte/core` augments `HTMLElementEventMap`: ```ts const select = document.querySelector('aparte-select'); select?.addEventListener('aparte-select-change', (e) => { // e.detail is AparteSelectChangeDetail — value, label, previousValue console.info(e.detail.value, e.detail.previousValue); }); ``` ## Vue [Section titled “Vue”](#vue) Declared through `GlobalComponents`, so `vue-tsc` checks them in any template once the package is imported: ```vue GPT-4o mini ``` Remember `:searchable="null"` to remove rather than `:searchable="false"`. ## Svelte [Section titled “Svelte”](#svelte) Declared through `SvelteHTMLElements`, so `svelte-check` covers them: ```svelte pick(e.detail.value)}> GPT-4o mini ``` ## Angular [Section titled “Angular”](#angular) Angular is the one wrapper that ships code for this, for two structural reasons: its template compiler rejects a tag nothing claims, and `[placeholder]="x"` writes a *property* — which on an attribute-driven element is a silent no-op, or a throw on one of [``](/components/input/aparte-composer/)’s eight getter-only accessors. So each element has a **standalone directive** whose selector is the tag. Import the ones you use, or all of them at once: ```ts import { Component } from '@angular/core'; import { APARTE_ELEMENT_DIRECTIVES } from '@aparte/angular'; @Component({ selector: 'app-picker', standalone: true, imports: [...APARTE_ELEMENT_DIRECTIVES], template: ` @if (showPicker) { } `, }) export class PickerComponent { protected readonly showPicker = true; protected use(value: string): void { console.info(value); } } ``` Three things that follow from the directive, and none of them work through ``: * **No `CUSTOM_ELEMENTS_SCHEMA`.** The directive claims the tag, so you keep template checking for every *other* unknown tag in that file — which the schema switches off wholesale. * **`@if` and `@for` work on the element**, and so does content projection, because the tag is really in the template. * **Inputs take `boolean`**, not `''`: `[searchable]="true"` goes through Angular’s `booleanAttribute` and the directive writes or removes the attribute for you. Outputs emit the event’s **detail**, which is the Angular idiom — `(selectChange)="pick($event.value)"`. When you need the event itself (to call `stopPropagation`), add a plain host listener. There is one directive per element, named `AparteDirective` — every component page under [Components](/components/) shows the exact symbol in its Angular tab — and `APARTE_ELEMENT_DIRECTIVES` imports them all at once. [``](/components/conversation/aparte-chat/) has no directive on purpose: `AparteChatComponent` already claims that tag and renders the whole turn. ## Your own element, or a plugin’s [Section titled “Your own element, or a plugin’s”](#your-own-element-or-a-plugins) The typing above covers `@aparte/core`’s elements — the ones each wrapper depends on. Nothing else is in it, including aparté’s own plugins, and that is deliberate: a third-party plugin’s author cannot add a line to `@aparte/core`, so shipping typing for *our* plugins would give our packages a privilege theirs could never have. The rule is symmetric instead — **whoever owns the element owns its contract and its bindings.** Two mechanisms, and they are the same amount of work for us as for you. ### 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) All three learn a tag through **module augmentation**, and the augmentation does not have to come from us. Put it in your own `.d.ts` and it applies exactly when your package is in the program — install it and the tag is typed, don’t and it isn’t. TypeScript enforces that, nobody has to. `AparteTemplateAttrs` is exported for this: it takes any interface of yours and gives back the template spelling, so you inherit the presence-attribute rule rather than rediscovering it. ```ts import type { AparteTemplateAttrs } from '@aparte/core'; interface MyWidgetAttributes { label?: string; compact?: boolean } declare module 'vue' { interface GlobalComponents { 'my-widget': import('vue').DefineComponent>; } } ``` Events need nothing from us at all — augment `HTMLElementEventMap` with your own detail type and `e.detail` is typed everywhere, in every framework, the same way core’s own events are. ### Angular: a directive, and it is six lines [Section titled “Angular: a directive, and it is six lines”](#angular-a-directive-and-it-is-six-lines) Angular has no types-only path: claiming a tag needs a directive class, which is runtime code. The one non-obvious part is already exported — `applyElementProps` is core’s attribute-versus-property rule, which is what makes a presence attribute land as `attr=""` and a `false` remove it: ```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 }); } } ``` A plugin that wants to spare its users those six lines ships them itself, and [`@aparte/plugin-model-selector`](/plugins/model-selector/#typed-in-your-framework) is the worked example: one subpath per framework, `@angular/core` an optional peer, and the directive generated from its own manifest by the same script that generates core’s. Run `scripts/gen-element-bindings.mjs` against your package’s manifest and you get the same output. If you would rather not, `CUSTOM_ELEMENTS_SCHEMA` still works, and so does `` below. One thing to know either way: a hyphenated tag is legal HTML whether or not anything defines it, so an element whose package you never imported mounts empty and inert with no error, and upgrades on its own the moment the definition arrives. That is what makes lazy plugin loading work — and it means the types promise a *shape*, never a *definition*. ## `` is the escape hatch, not the default [Section titled “\ is the escape hatch, not the default”](#aparte-ui-is-the-escape-hatch-not-the-default) Every wrapper still ships `AparteUi`, a pass-through that mounts **any** element by name and forwards its events. It exists for an element aparté does not define — one of yours, or a third-party web component: ```tsx ``` `name` is a string, `props` is an untyped bag, and the element is created imperatively — so no control flow or projection reaches it. For core’s elements the typed surface above is strictly better; for anything else, the two mechanisms in the previous section beat it as soon as you care about types. `` earns its place when you want none of that ceremony for a one-off. ## On the server [Section titled “On the server”](#on-the-server) A custom element extends `HTMLElement`, so it cannot exist without a DOM — but importing `@aparte/core` on a server is fine, and that is the part worth stating plainly because the two facts sound contradictory. A `node` export condition resolves the same specifier to a **DOM-free entry**, so `import '@aparte/core'` works in Node, in an Electron *main* process, or during an SSR pass (Next, Nuxt, SvelteKit, Angular Universal) with no DOM shim: ```ts // Same specifier. The `node` condition picks the DOM-free build. const { AparteClient, createAparteChatHandler, contentToText } = await import('@aparte/core'); ``` **You keep** the client, the chat host, the transports and `createAparteChatHandler`, the conversation and message runtime, config, the parsers, and every type. **You lose** the custom elements themselves; `registerAllComponents()` is a safe no-op there. Reading `src/index.ts` is misleading on this point — that is the *browser* entry, the one that defines the elements, and the workspace resolves it first by design. The contract is enforced by `pnpm check:node-import`, which imports the built packages in real Node on every CI run, rather than being promised here. ### What each wrapper does about it, which is not the same thing [Section titled “What each wrapper does about it, which is not the same thing”](#what-each-wrapper-does-about-it-which-is-not-the-same-thing) The core contract above is uniform. What the four wrappers do on top of it is not, and a page that implied otherwise would send a Nuxt reader looking for a bug that is a missing line: | Wrapper | On a server | | ------- | --------------------------------------------------------------------------------------------------------------------------------- | | React | `AparteChat.tsx` opens with `'use client'`, so the Next App Router keeps it out of the server pass for you. | | Angular | `provideAparte()` guards `autoConnect` with `typeof window !== 'undefined'`, so Angular Universal boots without touching the DOM. | | Vue | Nothing in the wrapper. Under Nuxt, import it in a client-only context yourself. | | Svelte | Nothing in the wrapper. Under SvelteKit, same — keep the import on the client. | Nothing here is a hard failure of the library: the elements are browser-only by nature, and the two wrappers with no guard simply leave that to you. It is written down so that leaving it to you is a decision you can see rather than one you discover. ## Testing your components [Section titled “Testing your components”](#testing-your-components) The same `node` condition that makes the server safe is what breaks a test runner, and the symptom does not look like a resolution problem at all: **vitest, jest and friends run on Node**, so they take the `node` condition, get the DOM-free entry, and no `` element ever upgrades. Your jsdom `document.createElement('aparte-chat')` returns a plain `HTMLElement`, every assertion about the element’s own properties fails, and nothing on the page says why. `@aparte/core/browser` is the entry with the elements in it, by name. Point your runner at it: vitest.config.ts ```ts import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'jsdom', alias: [ // Node resolves `@aparte/core` to the DOM-free entry — correct for SSR, useless // under jsdom, where you want the elements to register. // // The array form matches on a REGEX, and that is the point: an object alias is a // PREFIX alias, so `'@aparte/core': '@aparte/core/browser'` would also rewrite // `@aparte/core/icons` and `@aparte/core/styles.css` to paths under `/browser` // that the exports map does not carry. { find: /^@aparte\/core$/, replacement: '@aparte/core/browser' }, ], }, }); ``` `registerAllComponents()` says so out loud if you forget: called on the DOM-free entry with a DOM present, it logs one warning naming this specifier. It is a warning and not a throw — the environment is legal, only surprising. Two notes. The main entry is deliberately left alone: `.` must keep resolving `node` first, because that is what makes `import '@aparte/core'` safe in Next, Nuxt, SvelteKit and Angular Universal. And `@aparte/core/package.json` is exported too, so a config that would rather compute the path than hardcode it can call `require.resolve('@aparte/core/package.json')`. ## Where the facts come from [Section titled “Where the facts come from”](#where-the-facts-come-from) The attribute and event surface of every element is in the generated [element reference](/components/), including each event’s detail type. Both are produced from the custom-elements manifest, which is built from the element source — so the reference, the types on this page and the elements themselves cannot drift apart. # React AI chat component, with hooks — @aparte/react > The @aparte/react wrapper — an ergonomic component plus hooks over the aparté web components. `@aparte/react` wraps `@aparte/core` for React 18/19: an ergonomic `` component, hooks for state and the client, typed JSX for every element, and a generic `` escape hatch. ```bash npm install @aparte/react @aparte/core react react-dom ``` `@aparte/core`, `react` and `react-dom` are **peer dependencies**. ## `` + `useAparteChat` [Section titled “\ + useAparteChat”](#apartechat--useapartechat) The `useAparteChat` hook owns the message state and the component ref, so you just spread them: ```tsx import { AparteChat, useAparteChat } from '@aparte/react'; import '@aparte/core/styles.css'; export function Chat() { const chat = useAparteChat(); return ( Ask me anything…

} centerWhenEmpty /> ); } ``` The user’s message is appended to the thread **automatically** on send — don’t add it yourself. `onMessageSent` is optional and fires *after* that append, for side-effects only (scroll, analytics, a backend call). Slots are plain props: `emptyState`, `composer`, `aboveComposer`, `toolbar` (the composer’s bottom row — mode picker, model selector: see [The composer toolbar](/guides/customization/#the-composer-toolbar) for an example), and `renderBubble` for a fully custom bubble — driven by the reactive `messages` list, so re-render from `message.content` / `message.segments` and it streams live ([details](/guides/customization/#custom-bubbles)). The imperative handle (`chat.ref`) exposes streaming, branch/edit and `scrollToBottom`. The other five callbacks — `onAction`, `onMessagesChange`, `onMessageAppended`, `onTypingChange`, `onConversationCreated` — take the same payloads as everywhere else; the table with all four frameworks side by side is generated from this wrapper’s own props: [Wrapper surface](/reference/wrappers/#callbacks). ## Wiring a real model [Section titled “Wiring a real model”](#wiring-a-real-model) The wrapper is **provider-agnostic**. Register a provider + transport once (see [Providers](/providers/)) and mount an `AparteClient` with `useAparteClient` — it bridges composer sends to the model: ```tsx import { aparteGlobalConfig, AparteDirectTransport } from '@aparte/core'; import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat'; import { useAparteClient } from '@aparte/react'; aparteGlobalConfig.registerAIProvider(createOpenAICompatProvider(presets.OPENROUTER)); aparteGlobalConfig.setTransport(new AparteDirectTransport({ byok: true })); function Chat() { useAparteClient(); // streams replies from the configured provider // … as above } ``` Pass a per-instance `config` prop to scope providers/transport to a single `` instead of `aparteGlobalConfig`. The `` presenter — what the built-in approval gate and `requestUserInput()` ask through — renders inside the host **by default**, as it does in ``; pass `elicitation={false}` when you register a presenter of your own. The root element (`[data-aparte-chat]`) takes `className` and `style`, merged after core’s own class, so a utility-first app sizes the chat column from JSX: ```tsx ``` ## Any aparté element: typed JSX [Section titled “Any aparté element: typed JSX”](#any-aparté-element-typed-jsx) The `aparte-*` tags are typed JSX intrinsics as soon as you import from `@aparte/react` — nothing to register. Attribute names are the HTML ones, and a typo or a wrong value type is a compile error: ```tsx GPT-4o mini ``` Presence attributes are `''`, not `true` — React stringifies what it sets on a custom element, so `searchable={false}` would render `searchable="false"` and an element testing `hasAttribute` reads that as on. Events reach you by ref and are typed through the DOM. The rules and the full set are on [Placing elements, typed](/frameworks/elements/). ## Any OTHER element: `` [Section titled “Any OTHER element: \”](#any-other-element-aparteui) For an element aparté does not define — one of yours, or a third party’s: ```tsx import { AparteUi } from '@aparte/react'; ``` It mounts any tag name, which is exactly what you want for a foreign element and exactly what you do not need for aparté’s own — those are typed above. ## Also exported [Section titled “Also exported”](#also-exported) * `useConversationManager` — React-state view over the core `AparteConversationManager` (list / create / archive), for a multi-conversation sidebar. ## Testing it [Section titled “Testing it”](#testing-it) Vitest, Jest — every runner — executes on Node, so `@aparte/core` resolves to its DOM-free entry and no `` element upgrades under jsdom: the tag stays a plain `HTMLElement` and every assertion about it fails for a reason nothing explains. Alias the specifier to [`@aparte/core/browser`](/frameworks/elements/#testing-your-components), the entry with the elements in it. # Svelte 4/5 AI chat component, with stores — @aparte/svelte > The @aparte/svelte wrapper — an ergonomic component plus stores over the aparté web components. `@aparte/svelte` wraps `@aparte/core` for Svelte 4 **and 5**: an ergonomic `` component, store factories for state and the client, and a generic `` escape hatch. ```bash npm install @aparte/svelte @aparte/core svelte ``` `@aparte/core` and `svelte` are **peer dependencies**. On the server This wrapper carries **no** server guard. Under SvelteKit, keep the import on the client — a custom element extends `HTMLElement` and cannot be constructed during an SSR pass. `@aparte/core` itself imports cleanly on a server through its DOM-free entry: see [On the server](/frameworks/elements/#on-the-server). ## `` + `createAparteChat` [Section titled “\ + createAparteChat”](#apartechat--createapartechat) The `createAparteChat` store factory owns the `messages` store and mirrors the imperative API, so you bind the store and connect the component with `bind:this`: ```svelte chat.onMessagesChange(m)} >

Ask me anything…

``` The user’s message is appended to the thread **automatically** on send — don’t add it yourself. `onmessageSent` is optional and fires *after* that append, for side-effects only (scroll, analytics, a backend call). Every callback also exists as a component event — `on:messagesChange={(e) => chat.onMessagesChange(e.detail)}` — which is the Svelte 4 spelling and still works on Svelte 5. Svelte 5 documents `createEventDispatcher` as deprecated and recommends callback props, so the callbacks are the path written here; both fire for the same occurrence, the callback with the payload itself, the event with it under `event.detail`. Slots are named slots: `empty-state`, `composer`, `above-composer`, `toolbar` (the composer’s bottom row — mode picker, model selector: see [The composer toolbar](/guides/customization/#the-composer-toolbar) for an example; use `` to project several nodes), and the `bubble` slot (`
`) for a fully custom bubble. Every imperative method (streaming, branch/edit, `scrollToBottom`) is mirrored on the `chat` store and reachable via `bind:this`. The other five are `onaction`, `onmessagesChange`, `onmessageAppended`, `ontypingChange` and `onconversationCreated` (as events: `on:action`, `on:messagesChange`, … with the payload under `event.detail`) — the table with all four frameworks side by side is generated from the wrapper source: [Wrapper surface](/reference/wrappers/#callbacks). ## Wiring a real model [Section titled “Wiring a real model”](#wiring-a-real-model) The wrapper is **provider-agnostic**. Register a provider + transport once (see [Providers](/providers/)) and start an `AparteClient` with `createAparteClient` — it bridges composer sends to the model: ```svelte chat.onMessagesChange(m)} /> ``` Pass a per-instance `config` prop to scope providers/transport to a single `` instead of `aparteGlobalConfig`. ## Any aparté element: typed in the markup [Section titled “Any aparté element: typed in the markup”](#any-aparté-element-typed-in-the-markup) The `aparte-*` tags are declared through `SvelteHTMLElements`, so `svelte-check` covers both their attributes and their `on:` handlers — no `AparteUi` needed: ```svelte use(e.detail.value)} > GPT-4o mini ``` On Svelte 5 in runes mode, `on:` on an element is the one form the compiler flags as deprecated (`event_directive_deprecated`); write the event attribute instead — `onaparte-select-change={(e) => …}` — which `svelte-check` types the same way. Presence attributes take `''` to set and `null` to remove, never `false` — Svelte stringifies what it sets on a custom element, so `searchable={false}` would render `searchable="false"` and an element testing `hasAttribute` reads that as on. The rules and the full set are on [Placing elements, typed](/frameworks/elements/). ## Any OTHER element: `` [Section titled “Any OTHER element: \”](#any-other-element-aparteui) For an element aparté does not define — one of yours, or a third party’s: ```svelte console.log(e.type, e.detail)} /> ``` `onelementEvent` receives the element’s own `CustomEvent`. The component event `on:elementEvent` still fires too, with that event under `e.detail` — the Svelte 4 spelling. It mounts any tag name, which is what a foreign element needs and what aparté’s own no longer do. ## Also exported [Section titled “Also exported”](#also-exported) * `createConversationManager` — Svelte stores over the core `AparteConversationManager` (list / create / archive), for a multi-conversation sidebar. ## Testing it [Section titled “Testing it”](#testing-it) Vitest — every runner — executes on Node, so `@aparte/core` resolves to its DOM-free entry and no `` element upgrades under jsdom: the tag stays a plain `HTMLElement` and every assertion about it fails for a reason nothing explains. Alias the specifier to [`@aparte/core/browser`](/frameworks/elements/#testing-your-components), the entry with the elements in it. # Vue 3 AI chat component, with composables — @aparte/vue > The @aparte/vue wrapper — an ergonomic component plus composables over the aparté web components. `@aparte/vue` wraps `@aparte/core` for Vue 3.5+: an ergonomic `` component, composables for state and the client, and a generic `` escape hatch. ```bash npm install @aparte/vue @aparte/core vue ``` `@aparte/core` and `vue` are **peer dependencies**. On the server This wrapper carries **no** server guard. Under Nuxt, import it in a client-only context yourself — a custom element extends `HTMLElement` and cannot be constructed during an SSR pass. `@aparte/core` itself imports cleanly on a server through its DOM-free entry: see [On the server](/frameworks/elements/#on-the-server). ## `` + `useAparteChat` [Section titled “\ + useAparteChat”](#apartechat--useapartechat) The `useAparteChat` composable owns the `messages` ref and the component ref, so you bind them and skip the manual `@messages-change` → `messages` round-trip: ```vue ``` The user’s message is appended to the thread **automatically** on send — don’t add it yourself. `@message-sent` is optional and fires *after* that append, for side-effects only (scroll, analytics, a backend call). Slots are named slots: `empty-state`, `composer`, `above-composer`, `toolbar` (the composer’s bottom row — mode picker, model selector: see [The composer toolbar](/guides/customization/#the-composer-toolbar) for an example), and the scoped `bubble` slot (`#bubble="{ message }"`) for a fully custom bubble. The imperative handle (`chat.chatRef`) exposes streaming, branch/edit and `scrollToBottom` — also available as plain methods straight off the `chat` object. The six callbacks are `@message-sent`, `@action`, `@messages-change`, `@message-appended`, `@typing-change` and `@conversation-created`. Vue hands you the payload directly — the table with all four frameworks side by side is generated from the wrapper source: [Wrapper surface](/reference/wrappers/#callbacks). ## Wiring a real model [Section titled “Wiring a real model”](#wiring-a-real-model) The wrapper is **provider-agnostic**. Register a provider + transport once (see [Providers](/providers/)) and mount an `AparteClient` with `useAparteClient` — it bridges composer sends to the model: ```vue ``` Pass a per-instance `config` prop to scope providers/transport to a single `` instead of `aparteGlobalConfig`. ## Any aparté element: typed in the template [Section titled “Any aparté element: typed in the template”](#any-aparté-element-typed-in-the-template) The `aparte-*` tags are declared through Vue’s `GlobalComponents`, so `vue-tsc` checks them in any template once the package is imported — no `AparteUi`, no `isCustomElement` guesswork about names: ```vue ``` Presence attributes take `''` to set and `null` to remove, never `false` — Vue stringifies what it sets on a custom element, so `:searchable="false"` would render `searchable="false"` and an element testing `hasAttribute` reads that as on. The rules and the full set are on [Placing elements, typed](/frameworks/elements/). ## Any OTHER element: `` [Section titled “Any OTHER element: \”](#any-other-element-aparteui) For an element aparté does not define — one of yours, or a third party’s: ```vue ``` It mounts any tag name, which is what a foreign element needs and what aparté’s own no longer do. ## Also exported [Section titled “Also exported”](#also-exported) * `useConversationManager` — Vue-reactive view over the core `AparteConversationManager` (list / create / archive), for a multi-conversation sidebar. ## Testing it [Section titled “Testing it”](#testing-it) Vitest — every runner — executes on Node, so `@aparte/core` resolves to its DOM-free entry and no `` element upgrades under jsdom: the tag stays a plain `HTMLElement` and every assertion about it fails for a reason nothing explains. Alias the specifier to [`@aparte/core/browser`](/frameworks/elements/#testing-your-components), the entry with the elements in it. # Accessibility > What aparté's components do for accessibility, what they leave to you, and what is checked automatically rather than promised. A chat is a hard surface to get right: content arrives on its own, the newest thing is at the bottom, and a reply can take ten seconds during which nothing visibly happens. This page is the split — what core does, what it leaves to you, and which half is verified by a machine rather than by this sentence. Everything below was read off the source. Where a number appears, it was counted. ## What core does [Section titled “What core does”](#what-core-does) ### The transcript announces itself [Section titled “The transcript announces itself”](#the-transcript-announces-itself) The viewport’s scroll container is a live region: `role="log"` with `aria-live="polite"`, so a screen reader reads a reply as it arrives rather than only on demand. Each message is a `role="article"` with an `aria-label` naming its author. The waiting state is separate and deliberate: `` is `role="status"` / `aria-live="polite"`, so a turn that is still being produced says so, instead of leaving a silence a reader cannot distinguish from a hang. The branch picker announces its move through a visually hidden `.aparte-sr-only` node rather than by moving focus — pressing `›` should not steal the caret from wherever you were. ### Names come from the locale [Section titled “Names come from the locale”](#names-come-from-the-locale) Every `aria-label` core writes is read from the active locale, not baked into the markup. Switching language relabels the controls that are already on the page — which is why `relabel()` exists on the elicitation panel rather than a rebuild: a reader may be halfway through answering. That also means an unlocalised string is a missing translation, not a missing label: each falls back to English. ### Keyboard [Section titled “Keyboard”](#keyboard) `` implements the combobox pattern in full: `Enter` or `Space` opens, `ArrowDown` opens and moves, `ArrowUp` moves back, `Home` and `End` jump to the ends, `Escape` closes, and `Enter` chooses. It carries `role="combobox"`, `aria-expanded`, `aria-controls` and `aria-activedescendant`, and its listbox carries `role="listbox"` / `role="option"` with `aria-selected`. Those combobox attributes sit on whichever element has focus. Add `searchable` and opening the dropdown focuses the filter field, so the field becomes the combobox and the trigger becomes a `role="button"` — one combobox per control, and always the one a reader is on. The composer’s editor is a `role="textbox"` with `aria-multiline="true"`, labelled from its placeholder. `Enter` sends and `Shift`+`Enter` breaks the line; `submit-on-enter="false"` swaps them for a composer where the reverse is the expectation. A conversation-list row is activatable with `Enter` or `Space`, and its two row actions are real buttons in the tab order. ### Focus, contrast and motion [Section titled “Focus, contrast and motion”](#focus-contrast-and-motion) * **30** `:focus-visible` rules across the stylesheets — every control core draws has a visible keyboard ring, and it is `:focus-visible` rather than `:focus`, so a mouse click does not leave one behind. * **22** `forced-colors` blocks. Windows High Contrast drops backgrounds and shadows, so anything whose whole silhouette was a fill gets a real border there instead — the segmented tab’s active pill and the tooltip both do, and the tooltip’s arrow is hidden rather than left as an unlabelled diamond. * `prefers-reduced-motion` is honoured **at the source**: the duration tokens themselves are overridden, so every transition and animation that reads one stops — including in CSS the library does not own. A second sweep, scoped to aparté’s own tags and never to your page, catches whatever the tokens cannot reach. The announcement does not stop with the motion: activity keeps being conveyed through `aria-live` and `aria-busy`. * Under `pointer: coarse` the control set re-sizes to `--aparte-touch-target-size` (44px). ## What core leaves to you [Section titled “What core leaves to you”](#what-core-leaves-to-you) None of these is an oversight. Each is a place where the library cannot know enough to be right, and guessing would be worse than saying so. **The neutral classes draw, they do not announce.** `.aparte-menu`, `.aparte-accordion`, `.aparte-popover` and `.aparte-tooltip` are surfaces — the roles (`role="menu"`, `role="menuitem"`, `role="tooltip"`) and the arrow-key handling those patterns require are yours. A menu that looks like a menu and reports as a group of buttons is worse than one that looks plain, so put the roles on. **Arrow-key navigation in the conversation list.** Rows are reachable and activatable, and `role="navigation"` says what the list is — but there is no roving `tabindex`, so a long history is a long tab sequence. If that matters for your app, it is yours to add. **A name for each chat, when there are several.** One `` on a page needs nothing. Two need `aria-label` to tell a screen-reader user which log they are in. **Custom actions need a `label`.** `registerAction({ icon, label })` uses the label as the button’s accessible name. An icon-only action without one is an unlabelled button. **Render hooks that return a string** carry the accessible name too, not just the escaping. Returning an element and setting `textContent` is the safer half of the same advice — see [Customization](/guides/customization/#render-hooks). **Everything outside the chat.** A heading structure, a landmark for the page, a skip link: core renders one region of your app and has no view on the rest of it. ## What is actually checked [Section titled “What is actually checked”](#what-is-actually-checked) Two mechanisms, neither of them this page: **axe, in a real browser, on every run.** Six checks, and they fail on any *critical* or *serious* violation. The engine coverage is deliberately uneven, and this page would rather say so than round it up: all **seven** example apps in Chromium, **five** of them under WebKit (`vanilla`, `vanilla-dist`, `react`, `vue`, `svelte4`) and **two** under Firefox (`vanilla`, `react`) — the lists are `WEBKIT_APPS` and `FIREFOX_APPS` in `e2e/playwright.config.ts`. The checks are: * an idle chat, * a streamed exchange, * an open model dropdown, * a turn in flight, * a failed turn, * and one that is not an axe scan at all — the composer is reached, filled and sent using the keyboard alone. The last two matter most, because they are the states a manual pass skips: nobody thinks to audit the error card, and nobody tabs to the send button. **A unit test** pins the conversation list’s row actions in the tab order, which is the regression that would otherwise be invisible. What none of that proves: axe catches what a machine can see. It does not know whether your labels *say* anything useful, whether the reading order matches the visual one in your own layout, or whether the whole flow is usable with a screen reader — which is a person’s job, on your app, once. # ChatGPT-style app shell: sidebar, header and chat > A sidebar of conversations, a header and the chat — the ChatGPT-style page, built from aparté's shell recipes and one element with behaviour. A chat product is the transcript plus the chrome around it: a column of conversations with a new-chat button and a search field, a header with the title and a model picker, and the chat in the rest of the window. aparté ships that chrome as three recipes and one element — enough to build the whole page on the library alone, and nothing that owns your data. Width 375 768 Full [Open in a tab](/preview/demo/app-shell/) [The application shell: sidebar, header, chat](/preview/demo/app-shell/) The shell: a sidebar, a header, the chat. Narrow the frame to 375 and the sidebar becomes a drawer behind the header's toggle (the demo sets the breakpoint to 30rem; the shell's own default is 48rem, wider than this frame). ```html
aparté
Deploy checklist
gpt-4.1
``` ## The three pieces [Section titled “The three pieces”](#the-three-pieces) | Piece | What it is | Why | | -------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | | `.aparte-app-shell` | a recipe: a two-column, two-row grid — sidebar beside, header above, `__main` in the rest | a grid has no behaviour | | `.aparte-app-header` | a recipe: a toggle (shown under 48rem), a title, an `__actions` zone pushed to the end | a header has no behaviour either | | `` | an element wearing the `.aparte-sidebar` recipe | it collapses, it becomes a drawer, and its search filters the list — three behaviours | That split is the rule the whole kit follows: a recipe draws, an element exists only where there is something to do. The [UI kit](/kit/button/) has every recipe with its markup, one family per page; the [element page](/components/shell/aparte-sidebar/) has the sidebar’s attributes and events. ## What the sidebar does [Section titled “What the sidebar does”](#what-the-sidebar-does) **Collapse.** `collapsed` is an attribute — set it, read it, persist it. Any element carrying `data-aparte-sidebar-toggle` toggles the nearest sidebar (or the one whose id the attribute names), so the header’s hamburger needs no script, and the sidebar keeps `aria-expanded` and `aria-controls` on it in step. `aparte-sidebar-toggle` fires on every change, whoever caused it (`AparteSidebarToggleDetail`: `{ collapsed, drawer }`); the `AparteSidebar` class exposes `open()`, `close()`, `toggle()` and `filter(query)`. **Drawer.** Under 48rem of window (`breakpoint`, a length or `none`) the sidebar leaves the grid and slides over the page: open, it draws a scrim, closes on Escape or a click outside, and hands the focus back to the control that opened it. It is the same element in the same place, positioned fixed — no portal, which is what a sidebar can do and a dialog cannot. **Search.** An input carrying `data-aparte-sidebar-search` filters the conversation list below it by title as the user types, case- and accent-insensitive; a date group with nothing left hides with its rows. Client-side, on the titles the list already holds — an index over message bodies belongs to your storage adapter. ## A pane beside the chat [Section titled “A pane beside the chat”](#a-pane-beside-the-chat) A builder is this shell with one more piece: the sidebar, then the chat, then a seam, then whatever the chat is producing. `` goes in `__main`, and **a pane contains a chat; a chat never contains a split** — the shell’s rule is a direct-child selector, so a split nested *inside* an `` would sit between the transcript and the composer, where nothing sizes it. ```html
Landing page
your pane
``` No height on the split and none on the chat: the shell’s grid gives `__main` its row, the sheet gives a split or a chat sitting directly in `__main` the full height of it, and the chat inside a pane fills the pane. The seam, the drag, the arrow keys, the breakpoint and the persistence event are all on the [layout guide](/guides/layout/#builder-split). ## Settings, in a dialog [Section titled “Settings, in a dialog”](#settings-in-a-dialog) A settings hub is a modal, and the kit’s modal is the browser’s own `` wearing `.aparte-dialog`: `showModal()` gives the top layer, the focus trap, Escape and the focus return, and the recipe gives the box — header, a body that scrolls, footer, the backdrop, a full-screen sheet under 30rem. Three attributes wire it without a line of script: a control with `data-aparte-dialog-open="id"` opens the dialog it names, `data-aparte-dialog-close` inside one closes it (its value becomes `returnValue`, read in the native `close` event), and a click on the backdrop closes it unless the dialog carries `data-aparte-dialog-static`. Width 375 768 Full [Open in a tab](/preview/demo/dialog/) [A settings dialog on the native \](/preview/demo/dialog/) The kit's dialog on a native \: open it, tab through it, press Escape or click the backdrop. ```html

Nothing saved yet.

Settings

Any OpenAI-compatible server.

``` The tabs of a settings hub are [`.aparte-tabs`](/kit/tabs/); what goes in each panel is yours. The wiring is installed when `@aparte/core` is imported; a page built before that import calls `installDialogTriggersOnce()` itself, once. ## Wiring the data [Section titled “Wiring the data”](#wiring-the-data) The sidebar renders what you put in it, and the list is the [conversation list](/components/conversation/aparte-conversation-list/) you already wire to a manager — see [persistence](/guides/conversation-persistence/). The header’s title is the active conversation’s, the new-chat button calls `manager.createNew()`, and the model picker is [`@aparte/plugin-model-selector`](/plugins/model-selector/) in `__actions`. What stays yours: routing, authentication, the storage adapter, and the contents of a settings panel. ## The knobs [Section titled “The knobs”](#the-knobs) | Token | Default | Moves | | ---------------------------- | --------------------- | ------------------------------- | | `--aparte-sidebar-width` | `260px` | the column, and the drawer | | `--aparte-sidebar-bg` | `--aparte-surface-2` | the column’s ground | | `--aparte-app-header-height` | `48px` | the header row’s minimum height | | `--aparte-scrim` | `rgba(0, 0, 0, 0.35)` | the shade behind an open drawer | The breakpoint is 48rem, written in the sheets rather than a token: a media query cannot read a custom property. # Attachments > File attachments in the aparté composer — the built-in picker, the programmatic API, and how files reach your provider. The composer handles **file attachments** with one flag — and nothing shows the user a paperclip until you set it. ## Turning them on [Section titled “Turning them on”](#turning-them-on) Two elements make the UI: * **[``](/components/input/aparte-composer/)** — the picker button (opens the file dialog). * **[``](/components/input/aparte-composer/)** — the chips row showing the pending files. The default composer shell mounts **neither**. Add `attachments` and it mounts both, in their canonical positions: * Vanilla ```html ``` * React ```tsx ``` * Vue ```vue ``` * Svelte ```svelte ``` * Angular ```html ``` Composing your own composer? The flag doesn’t apply — drop the two elements in wherever you want them, as with any other primitive. ## Dragging files in [Section titled “Dragging files in”](#dragging-files-in) `` installs drag & drop on the composer **root**, not on itself — so a drop anywhere over the composer attaches, not only onto the paperclip. While a drag is over it the root carries `aparte-is-dragover`, and the dashed outline is drawn on `.aparte-composer-shell` when your markup has one and on the composer element when it does not. It reads `--aparte-focus-outline-width`, `--aparte-primary` and `--aparte-radius-input`, so it follows your theme with nothing to set. Two behaviours worth knowing, because they are asymmetric on purpose: * `disabled` on the composer removes the drop target and greys the button out. `streaming` does neither: attaching a file while a reply arrives is part of preparing the next message, so the button and the drop target stay live mid-turn — only the send is gated. * The drop handler calls `preventDefault()` even while disabled, so a missed drop can never navigate the page away to the file. The button is what installs it, so a composer you compose yourself gets drag & drop by including `` — and gets none without it. ## Clicking a picture — the preview is yours [Section titled “Clicking a picture — the preview is yours”](#clicking-a-picture--the-preview-is-yours) An image attachment renders as a thumbnail, in the composer and in the sent message. Clicking one **asks** for a full-size view by emitting `aparte-attachment-preview` — core has no lightbox, no modal, no opinion about how a picture should open. So the tile is inert until you say you can open something: ```ts aparteGlobalConfig.setHostHandlers({ attachmentPreview: true }); document.addEventListener('aparte-attachment-preview', (e) => { const { url, name } = e.detail; // open your own dialog / router / gallery }); ``` Declared, the image is a real button — `role="button"`, a tab stop, Enter and Space. Which element carries that differs between the two strips, and both are right. In a **sent message** the tile has no ✕, so the tile itself is the whole control and wears the role. In the **composer’s pending strip** the tile wraps the remove button, and no role permits a button inside a button — so the role, the tab stop and the `aria-label` sit on the ``, with the ✕ beside it. Style `.aparte-thumbnail__image[role='button']` for the composer’s, `.aparte-thumb--image[role='button']` for a sent message’s. Undeclared, it is a plain picture: no role, no tab stop, not even a pointer cursor, because looking clickable is the same promise in a quieter voice. The event itself is always public; the declaration only decides whether the trigger is rendered. The vanilla and React examples do exactly this in \~15 lines with a `` — see [`apps/examples`](https://github.com/apartejs/aparte/tree/main/apps/examples). ## Programmatic API [Section titled “Programmatic API”](#programmatic-api) The [``](/components/input/aparte-composer/) element exposes attachments directly: ```ts composer.addAttachments(files); // FileList | File[] composer.removeAttachment(file); composer.clearAttachments(); composer.attachments; // File[] (current selection) ``` ## Getting the files on send [Section titled “Getting the files on send”](#getting-the-files-on-send) When the user submits, the pending files ride along on the **`aparte-send`** event detail. Each wrapper surfaces that same detail under its own convention — one event, five spellings. * Vanilla ```ts composer.addEventListener('aparte-send', (e) => { const { content, files } = e.detail; // files?: File[] }); ``` * React ```tsx { if (e.files) upload(e.files); }} /> ``` * Vue ```vue ``` * Svelte ```svelte { if (e.detail.files) upload(e.detail.files); }} /> ``` * Angular ```html ``` One difference worth keeping straight: React, Vue and Angular hand you the **detail** directly, while Svelte re-wraps it in a `CustomEvent` the way the DOM event itself is — so it is `e.detail.files` in the Vanilla and Svelte tabs and `e.files` in the other three. To observe the pending selection live (e.g. to enable a send button), listen for **`aparte-composer-change`** — its `detail.state.attachments` is the current `File[]`. Driving your own loop? `filesToAttachments(files)` converts that `File[]` into the `attachments` an `AparteChatMessage` renders — the same conversion the built-in send path does, so your user bubble shows the chips instead of a bare line of text: ```ts import { filesToAttachments } from '@aparte/core'; // The VIEWPORT owns `appendMessage`, not ``. The shell matches the // host selectors but delegates rendering to the viewport inside it, so calling // `appendMessage` on the shell is a runtime `TypeError` — this snippet used to. const viewport = document.querySelector('aparte-chat-viewport')!; viewport.appendMessage({ id, role: 'user', content, timestamp: Date.now(), ...(files?.length ? { attachments: filesToAttachments(files) } : {}), }); ``` ### Releasing the previews [Section titled “Releasing the previews”](#releasing-the-previews) Each attachment’s `url` comes from `URL.createObjectURL`, which keeps the underlying `File` alive for as long as the document. That is what you want while the attachment is on screen — and a leak once it is not: a long session that sends many files holds on to every one of them. `revokeAttachmentUrls(attachments)` releases them. Only you know when an attachment stops being rendered (a persisted conversation may re-render one much later), so it is a call you make rather than something the conversion can schedule: ```ts import { revokeAttachmentUrls } from '@aparte/core'; import type { AparteMessage } from '@aparte/core'; function dropConversation(messages: AparteMessage[]): void { for (const message of messages) revokeAttachmentUrls(message.attachments); } ``` Calling it twice is harmless, and the `blob` is left in place so a storage adapter can still rebuild the url. [``](/components/conversation/aparte-chat-viewport/)’s `clearAll()` already does this for the messages it drops. ## What gets sent to the model [Section titled “What gets sent to the model”](#what-gets-sent-to-the-model) Before the provider sees anything, `AparteClient` decides which pending files are inlined into the request, via its `rawFileInject` option: * **`'all'`** (default) — images *and* recognized text files (`.md`, `.json`, `.csv`, source code, `.env`, `.log`, …): images become image parts, text files are read client-side and injected **in full** as text. * **`'images-only'`** — only images are inlined. Pair it with a `requestInterceptor` that retrieves relevant chunks (RAG) instead of flooding the context with whole files. * **`'none'`** — nothing is inlined; your `requestInterceptor` owns all file handling. Text files are sent in full In the default `'all'` mode a dropped text file — a `.env` or a log included — goes to the model verbatim. That’s the intended batteries-included behavior for a user deliberately attaching a file; if your host shouldn’t forward such content (secrets, PII), pick `'images-only'` or `'none'`, or veto individual files with `fileInjectFilter` (below). For per-file control on top of the mode, `fileInjectFilter` is called for each file the mode would inject — return `false` to keep it out of the request (the file still rides on the `aparte-send` event for your upload/RAG layer): ```ts new AparteClient({ // keep the inline UX, but never forward env files or keys fileInjectFilter: (f) => !/(^|\.)env$|\.(pem|key)$/i.test(f.name), }); ``` ## Reaching the model [Section titled “Reaching the model”](#reaching-the-model) Whether files are actually sent to the model is the **provider’s** job (multimodal support varies): the OpenAI-compatible adapter maps image parts to the vendor’s `image_url` format, for example. A provider that doesn’t support a given file type simply ignores it. See [Providers](/providers/). Attachment chips inherit the surface and border tokens like everything else — see [Theming](/guides/theming/). # Keep the key server-side — chat through your own /api/chat > An OpenAI-compatible chat frontend over your own backend — route chat through your /api/chat with AparteBackendTransport and createAparteChatHandler, the key never in the browser. Every aparté chat goes through a **transport**: *where* the request goes and *how* the key is handled. [`AparteDirectTransport`](/guides/getting-started/#wire-a-real-model) calls the vendor straight from the browser — fine for BYOK or a local model, but it puts the key in devtools. `AparteBackendTransport` instead POSTs to **your own endpoint**; your server resolves the vendor key, calls the vendor, and streams normalized events back. The key never reaches the browser. ## When to use it [Section titled “When to use it”](#when-to-use-it) | | `AparteDirectTransport` | `AparteBackendTransport` | | ------------ | --------------------------------------------------- | ---------------------------------------- | | Key location | Browser (devtools-visible) | Server only | | Good for | BYOK, local models (Ollama, LM Studio), prototyping | Production / SaaS with a key you pay for | | Client needs | The vendor’s format adapter | Only a `providerId` string | If your app pays for the API key, use `AparteBackendTransport`. If the *user* supplies their own key (or the model runs locally, keyless), `AparteDirectTransport` is simpler and there’s no server hop. ### Writing your own transport [Section titled “Writing your own transport”](#writing-your-own-transport) `AparteAIProvider` is a union of two arms: a provider either **shapes payloads** (a format adapter — `buildRequest`, `parseStream`, an endpoint and a way to present a key) or **owns its I/O** in a `chat()` method, the way `@aparte/provider-transformers` runs a model locally. The compiler tells an author which arm they implemented; `isFormatAdapter` tells a *transport* which arm it was handed, and narrows the type as it answers: ```ts import { isFormatAdapter, aparteGlobalConfig, type AparteChatRequest } from '@aparte/core'; async function dispatch(providerId: string, request: AparteChatRequest) { const provider = aparteGlobalConfig.getAIProvider(providerId); if (!provider) throw new Error(`no provider registered for "${providerId}"`); if (isFormatAdapter(provider)) { // Narrowed: buildRequest / parseStream / defaultEndpoint are all non-optional here, // so you do the HTTP and the auth, and the provider only shapes the bytes. const { path, body } = provider.buildRequest(request); return { url: provider.defaultEndpoint + path, body }; } // The other arm: the provider does its own I/O, so stay out of the way. return provider.chat?.(request); } void dispatch; ``` Both built-in transports do exactly this — it is why one map of providers serves a browser-direct app and a server-held-key app without either provider knowing which it is in. ## 1. Build the server handler [Section titled “1. Build the server handler”](#1-build-the-server-handler) `createAparteChatHandler` builds a framework-free `/api/chat` handler: a plain `(req: Request) => Promise` using only the Web `fetch` API, so it drops into a Next.js route handler, Deno, Bun, or a Cloudflare Worker unchanged. It reads `{ providerId, request }`, runs the matching **format adapter** server-side (`buildRequest` → auth → vendor fetch → `parseStream`), and re-emits the result as NDJSON (one JSON object per line) — the exact wire format `AparteBackendTransport` expects on the way back. Importing `@aparte/core` on the server is fine: a `node` export condition resolves to a DOM-free entry (no custom elements, no CSS), and the same holds for the format-adapter providers. See **[On the server](/frameworks/elements/#on-the-server)** for what the DOM-free entry keeps and loses, and for what each wrapper does about it — the contract is enforced in CI by a real Node import, not just documented. ```ts // app/api/chat/route.ts (Next.js) — runs in the Node.js runtime import { createAparteChatHandler } from '@aparte/core'; import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat'; // Your own session lookup — the same one guarding your other authenticated routes. declare function getSession(req: Request): Promise<{ userId: string } | null>; export const POST = createAparteChatHandler({ providers: { openai: createOpenAICompatProvider(presets.OPENAI), }, resolveKey: (providerId) => process.env[`${providerId.toUpperCase()}_KEY`], // REQUIRED. This route spends your key, so your own auth goes here. Return false // for 401, a Response for your own status, or true to proceed. authorize: async (req) => Boolean(await getSession(req)), }); ``` `authorize` has to actually authenticate It is required so the decision cannot be skipped, but a required option can still be satisfied by something that decides nothing. `Boolean(req.headers.get('cookie'))` is the shape to avoid: **any** request carrying **any** cookie passes, including a cross-site one from a page you have never seen — so the route spends your vendor key for anyone who can load it. Call your real session lookup, the same one that guards your other authenticated routes. `createAparteChatHandler` and its `AparteChatHandlerOptions` type are exported from `@aparte/core`’s **Node/SSR entry** (resolved automatically via the `node` export condition when a server file does `import '@aparte/core'`) — that entry is DOM-free, so importing it on the server never touches `HTMLElement`. Handler options: * **`providers`** — a `Record` keyed by the `providerId` the client will send (the same `@aparte/provider-*` adapters you’d use with `AparteDirectTransport` — nothing changes about the adapter itself). Each entry must expose the **format-adapter** surface (`buildRequest` + `parseStream` + `defaultEndpoint`, plus `authHeaders` or `authQuery`) — `createOpenAICompatProvider(...)` already does. An unregistered `providerId` gets a `400`; a provider missing the adapter surface gets a `500`. * **`resolveKey(providerId)`** — pulls the vendor key from env/a secret store, server-side only. Return `undefined` for keyless/local providers. * **`fetchImpl`** — override the `fetch` used to call the vendor (defaults to global `fetch`), e.g. in tests or behind a proxy. * **`authorize(req)`** — an auth gate run on every request **before any work**. Return `false` to reject with `401`, a `Response` to reject with your own status/body (e.g. `403` + a message), or `true` to proceed. Read cookies/headers from `req`. Register one entry per vendor you support; the map key is what the client sends as `providerId`, so route between OpenAI, Mistral, OpenRouter, etc. by adding more entries. This endpoint spends your key `/api/chat` makes the vendor call with your server-held key, so anyone who can reach the route can spend your quota. Put your own auth in front of it — upstream (middleware/edge) or via the **`authorize(req)`** option above (return `false`/a `403` `Response` for an unauthenticated caller). The handler does not authenticate for you. ### SSRF safety [Section titled “SSRF safety”](#ssrf-safety) The client never sends a URL — only a `providerId` string. The vendor URL comes from `adapter.defaultEndpoint` inside **your** `providers` map, resolved on the server; nothing in the request body can redirect the server to an arbitrary host. A malicious or buggy client can pick a *registered* provider at most, never an arbitrary endpoint. Vendor errors (bad key, rate limit, etc.) keep their original **status**, but their **body is summarised** to `{ error: { message, code?, type? } }` rather than relayed. That is deliberate: an OpenAI 401 body reads `Incorrect API key provided: sk-proj-****abcd`, so passing it through would hand a caller your key’s prefix, tail and format — and other vendors echo organisation ids and request fragments. The machine-readable `code` / `type` survive, which is what a client actually branches on; the vendor’s prose belongs in your server’s logs. (`AparteDirectTransport` has no such concern: there, the key is the caller’s own.) The rest of the body is *not* constrained. `modelId`, the whole `messages` array (the `system` message included), `tools` and `maxTokens` come from the client and are forwarded to the adapter as-is — no size limit, no message count, no model allow-list. An authorized caller can therefore name any provider in your map and send a prompt of any length against your key: `authorize` decides *who* calls the route, never *what* they send. If your deployment needs that capped — per-tier models, a token budget — do it in your own wrapper around the handler, or inside `authorize` reading `req.clone().json()`. Clone it: the handler parses the original body itself, and a body read twice fails the request with a `400`. ## 2. Point the browser at it [Section titled “2. Point the browser at it”](#2-point-the-browser-at-it) On the client, skip the provider adapter entirely — the browser only needs to know the `providerId` and where your endpoint lives. Set `AparteBackendTransport` instead of `AparteDirectTransport` and drive the rest exactly as usual: ```ts import { aparteGlobalConfig, AparteClient, AparteBackendTransport } from '@aparte/core'; aparteGlobalConfig.setTransport(new AparteBackendTransport({ endpoint: '/api/chat' })); new AparteClient().start(); // .start() attaches the aparte-send/-retry/-edit listeners ``` No key, no adapter import, nothing devtools-visible — the browser just POSTs `{ providerId, request }` to `/api/chat` and streams the reply back into your bubbles. Caution `aparteGlobalConfig` still needs to know *which* `providerId` to send (e.g. via the model selector, or hardcoded if you only support one vendor) — `AparteBackendTransport` doesn’t need the provider’s format adapter registered client-side, but something has to pick the id. `BackendTransportOptions`: * **`endpoint`** — your chat route, e.g. `/api/chat`. * **`headers`** — extra headers sent with every request. A session cookie is sent automatically only when `endpoint` is same-origin (e.g. `/api/chat`); a cross-origin endpoint sends none, so authenticate it with a header here. * **`buildBody`** — override how the request is serialized to your backend. Defaults to `{ providerId, request }`; return any JSON-serializable value if your route expects a different shape. ## Wire format [Section titled “Wire format”](#wire-format) The NDJSON `AparteBackendTransport` reads back is aparté’s own — one JSON `AparteStreamEvent` per line — **not** the Vercel AI SDK Data Stream Protocol. You don’t need to think about this if you use `createAparteChatHandler` on the server (it produces exactly this format), but a hand-rolled route must match it if you skip the helper. ## Next steps [Section titled “Next steps”](#next-steps) * **[Providers](/providers/)** — the format adapters you register in the `providers` map (OpenAI-compatible, the AI SDK bridge, Transformers.js). * **[Getting started](/guides/getting-started/#wire-a-real-model)** — the `AparteDirectTransport` / BYOK path, for contrast. * **[The agent engine](/guides/engine)** — `runStreamAgent`, for a headless loop instead of the `AparteClient` event wiring shown here. # Use your own agent loop with a chat UI > Drive display-only from an agent loop you own (a backend, your own SSE) — appendMessage, injectTokenStream or segments (addSegment, appendToSegment, a tool_call per tool), and updateMessage to finish the turn; no AparteClient. Everything so far assumed `AparteClient` runs the agent loop in the page. But sometimes the loop lives **somewhere else**: a backend you fully own, a worker, or an Electron main process talking to a local model. The chat component then becomes **display-only** — your code pushes messages and tokens in, and aparté renders, streams, scrolls and brands them exactly as if the client were driving. No `AparteClient`, no provider, no transport. Two methods from the [imperative API](/frameworks/) (identical on all four wrappers) do all the work: * `appendMessage(message)` — add a message to the thread. * `injectTokenStream(messageId, tokens)` — stream an `AsyncIterable` into a message, token by token, with the live-streaming UI (cursor, auto-scroll). Resolves when the iterable completes; `stopTokenStream()` cancels. ## The pattern [Section titled “The pattern”](#the-pattern) 1. Listen to `onMessageSent` for the user’s message and forward it to your loop. The user bubble is appended **automatically** on send — don’t add it yourself. 2. When your loop starts answering, `appendMessage` an **empty assistant message** with a fresh id. 3. `injectTokenStream(id, tokens)` with your token source. * Vanilla `injectTokenStream` is something the **wrappers** add: it is built on the chat host, not on any element. Driving the raw elements, the three steps are three viewport calls — which is the same loop, written out. ```ts const viewport = document.querySelector('aparte-chat-viewport')!; document.addEventListener('aparte-send', async (event) => { const id = crypto.randomUUID(); // Explicit append BEFORE streaming — see the caveat below. 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); // stops the streaming caret }); ``` * React ```tsx import { useCallback } from 'react'; import { AparteChat, useAparteChat } from '@aparte/react'; import '@aparte/core/styles.css'; export function Chat() { const chat = useAparteChat(); const onMessageSent = useCallback(async (event: { content: string }) => { const id = crypto.randomUUID(); // Explicit append BEFORE injecting — see the caveat below. 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(e.detail)} on:messageSent={onMessageSent} /> ``` * Angular The imperative API lives on the component instance, so it is reached with a `viewChild` rather than through a helper. `injectTokenStream` also accepts an RxJS `Observable` here — the same call, in the shape Angular already speaks. ```ts import { Component, viewChild } from '@angular/core'; import { AparteChatComponent } from '@aparte/angular'; 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 = crypto.randomUUID(); // Explicit append BEFORE injecting — see the caveat below. 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 SDK, whatever produces tokens. That empty assistant message from step 2 needs no `status`: an assistant message with no status and nothing in it *is* a reply on its way, so the bubble shows the [waiting indicator](/guides/customization/#the-waiting-state) and keeps its action bar away until the first token. As the stream runs, your framework’s message list is kept in sync (once per frame), so `getMessages()`, persistence and a [custom bubble](/guides/customization/#custom-bubbles) all see the text — not just the DOM. Append before injecting If the `messageId` doesn’t exist yet, the viewport auto-creates an empty assistant message — but only in its **internal** repo, not in your framework’s message state. In a wrapper, always `appendMessage` explicitly first (step 2 above) so both stay in sync. Attachments: only enable them if your loop consumes them The default composer has **no** file picker; `attachments` adds it (see [Attachments](/guides/attachments/)). Leave it off unless your loop reads the files from the send event — `detail.files`, or `event.files` in the wrappers. Without an `AparteClient` nothing consumes them for you, so a loop that forwards only `content` **discards them silently** while the UI suggests they were sent. When you do handle them, `filesToAttachments(files)` turns them into the `attachments` your user bubble renders. Your own controls go **in** the composer, not in a bar below the chat: the toolbar row takes a mode picker, a model selector or a token counter. Driving the loop yourself is exactly the case where you have such controls — see [The composer toolbar](/guides/customization/#the-composer-toolbar). ## Richer replies: segments instead of plain text [Section titled “Richer replies: segments instead of plain text”](#richer-replies-segments-instead-of-plain-text) `injectTokenStream` writes plain text into a message’s `content`. For a thinking block, a tool call or anything the bubble renders as a typed block, stream **segments** instead: ```ts chat.ref.current?.addSegment({ id: 'think-1', type: 'thinking', content: '' }); for await (const chunk of reasoning) chat.ref.current?.appendToSegment('think-1', chunk); ``` `appendToSegment` writes each chunk straight into the bubble and syncs the framework’s message list once per frame, so a fast local model costs roughly one render per frame rather than one per token — you don’t need your own batching layer. A segments-driven turn has to be finished `injectTokenStream` marks the message complete when its iterable ends. Segments do not: a message you fill with `addSegment` / `appendToSegment` stays “a reply on its way” until you say otherwise — and while it does, the viewport carries `data-busy` and keeps the transcript read-only (branch pickers, retry and edit disabled), for good. End the turn yourself: ```ts chat.updateMessage(id, { status: 'completed' }); // or 'error' ``` That one call also closes the segments still open (`isStreaming: false`, their `endedAt`). Two levels of the same call, not a contradiction: a wrapper’s `chat.addSegment(segment)`, `updateSegment(id, patch)` and `appendToSegment(id, chunk)` always target the **last** message, while the raw viewport’s `viewport.addSegment(messageId, segment)` and `viewport.updateSegment(messageId, segmentId, patch)` take the message id. Driving a wrapper, use the first — and don’t append another message while a turn is still being written, or the next segment lands on the newcomer. ## Push-based sources: the queue adapter [Section titled “Push-based sources: the queue adapter”](#push-based-sources-the-queue-adapter) `injectTokenStream` *pulls* from an iterable, but IPC-style sources *push* events at you (Electron `ipcRenderer`, WebSocket, `postMessage`). Bridge with a small async queue: ```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((r) => { notify = r; }); notify = null; } }, }; } ``` Wire it to the pushing side, hand `queue.stream()` to `injectTokenStream`: ```ts // Your own bridge — an Electron preload, a WebSocket wrapper, whatever pushes tokens. declare const myBridge: { onToken(cb: (token: string) => void): void; onDone(cb: () => void): void; }; const queue = createTokenQueue(); myBridge.onToken((t) => queue.push(t)); myBridge.onDone(() => queue.end()); chat.ref.current?.appendMessage({ id, role: 'assistant', content: '', timestamp: Date.now() }); await chat.ref.current?.injectTokenStream(id, queue.stream()); ``` Starting a new `injectTokenStream` cancels the previous one, and `stopTokenStream()` cancels explicitly (a stop button) — the source iterable is `return()`ed, so a generator’s `finally` runs and can tear down the underlying request. ## Running aparté’s own loop out of process [Section titled “Running aparté’s own loop out of process”](#running-apartés-own-loop-out-of-process) If the external loop is yours to write, you don’t have to reinvent it: [`runStreamAgent` from `@aparte/engine`](/guides/engine/) is the exact agent loop core runs inline — headless, zero dependencies, no DOM. It runs fine in Node, a worker, or an Electron main process; forward its emitted text over your bridge and inject it here. ## The pieces core exports for this [Section titled “The pieces core exports for this”](#the-pieces-core-exports-for-this) Driving your own loop means doing by hand what `AparteClient` does for you. These are exported so you do not have to reimplement them: | Export | What it does | When you want it | | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | `AparteStreamParser` | Incrementally splits a model’s text into segments — text, code fences, thinking blocks, and the tagged blocks you registered (`blocks` option / `registerStreamBlock`) | You are feeding raw deltas and want the same rendering the client produces | | `parseMarkdownToSegments` | The one-shot version of the above, for a complete reply | You already have the whole answer (a non-streaming call, or replaying history) | | `contentToText` | Flattens `string \| AparteContentPart[]` to its text | Your transport or logs need the text of a multimodal message | | `readableToAsyncIterable` | Wraps a `ReadableStream` so `for await` works, honouring an `AbortSignal` | You are consuming a provider’s `parseStream` directly — Chromium does not async-iterate streams | | `uuid` | An id that works on plain `http://` — `crypto.randomUUID` first, a cheap fallback where it does not exist | You generate message or host ids and your app also runs on a LAN address | | `copyText` | Copies to the clipboard on plain `http://` too — `navigator.clipboard` first, `execCommand('copy')` where it does not exist | You add a copy button of your own; core’s three use it | | `registerAllComponents` | References all 24 element classes so a bundler cannot tree-shake their `customElements.define` away, then warns naming any tag that is still not in the registry | Your build is aggressive, or you load `@aparte/core` through a dynamic `import()` | | `AparteChatHost` | The streaming / branch / host-method orchestration the four wrappers all bind to — everything `AparteClient` does minus the transport | You are writing a fifth framework binding, or driving core from a framework we do not ship | | `populateBubbleFromMessage` | Fills an [``](/components/conversation/aparte-chat-bubble/) from an `AparteMessage` — segments, attachments, sibling nav, action bar | You render bubbles yourself instead of letting the viewport own them | | `parseAparteEventStream` | Reads the NDJSON wire format `createAparteChatHandler` emits back into `AparteStreamEvent`s | You wrote your own client against an aparté backend endpoint | Wiring your own binding starts here — no `AparteClient`, so nothing is hostage to it: ```ts import { registerAllComponents, AparteChatHost, populateBubbleFromMessage, parseAparteEventStream, readableToAsyncIterable, uuid, type AparteMessage, } from '@aparte/core'; registerAllComponents(); // safe to call more than once // The host takes its binding up front: you own the message list, it drives the DOM. const chat = document.querySelector('aparte-chat')!; let messages: AparteMessage[] = []; const host = new AparteChatHost({ hostId: uuid(), host: chat, viewport: chat.viewport, getMessages: () => messages, setMessages: (next) => { messages = next; }, // Required: run `cb` once your framework has painted. With no framework, the // next frame is the honest answer — the host uses it to measure, not to poll. afterRender: (cb) => void requestAnimationFrame(() => cb()), onMessagesChange: (next) => void next, // your framework's re-render }); const release = host.bind(); // returns its own unbind // Rendering a bubble yourself instead of letting the viewport own it: const bubble = document.createElement('aparte-chat-bubble'); populateBubbleFromMessage(bubble, { id: 'a1', role: 'assistant', content: 'hi', timestamp: Date.now() }); // Reading an aparté backend's NDJSON stream without AparteClient. Note the wrapper: // parseAparteEventStream returns a ReadableStream, and Chromium does not // async-iterate those — the signal is how a user's "stop" cuts the read. async function consume(body: ReadableStream, signal: AbortSignal) { for await (const event of readableToAsyncIterable(parseAparteEventStream(body), signal)) void event; } void consume; release(); ``` ````ts import { AparteStreamParser, contentToText } from '@aparte/core'; const parser = new AparteStreamParser(); for (const delta of ['Here: ', '```', 'ts\n', 'const x = 1;\n', '```']) { const { segments } = parser.parse(delta); for (const segment of segments) void segment; // render as they complete } const trailing = parser.finalize(); // flush whatever is still buffered void trailing; void contentToText([{ type: 'text', text: 'hello' }]); // 'hello' ```` ### Reasoning models: `thinkingDelimiters` [Section titled “Reasoning models: thinkingDelimiters”](#reasoning-models-thinkingdelimiters) By default the parser recognises `` **and** ``. Models that mark their reasoning differently need the delimiters spelled out — pass one pair, or several. Passing any **replaces** the defaults, so re-list the ones you still want: ```ts import { AparteStreamParser } from '@aparte/core'; import type { AparteThinkingDelimiterPair } from '@aparte/core'; const pairs: AparteThinkingDelimiterPair[] = [ { start: '', end: '' }, { start: '<|begin_of_thought|>', end: '<|end_of_thought|>' }, ]; const parser = new AparteStreamParser({ thinkingDelimiters: pairs }); void parser; ``` Matched content becomes a `thinking` segment, which renders collapsed instead of as part of the reply. ## Make the composer follow your turn [Section titled “Make the composer follow your turn”](#make-the-composer-follow-your-turn) The send button turns into **Stop** while `composer.streaming` is true, and that flag is not something you set: it follows four lifecycle events, filtered by `detail.targetId`. `AparteClient` dispatches them for you; a host with its own loop dispatches two of them itself — that is the whole contract: | Event | What the composer does | | ----------------------------------------------------------------------- | -------------------------------------------------------------- | | `aparte-message-start` | `streaming = true` — the button becomes Stop | | `aparte-message-done`, `aparte-message-error`, `aparte-message-aborted` | `streaming = false` — and an open elicitation panel is evicted | Dispatch them **on the chat host element, bubbling** — the `` (the Angular wrapper’s tag too), or the `[data-aparte-chat]` element the React, Vue and Svelte wrappers render. That is what `AparteClient` does (`bubbles: true, composed: true`), and it is the one dispatch that reaches every reader: the composer and `` listen on `window`, which a bubbling event reaches; the [conversation controller](/components/conversation/aparte-conversation-list/) and `AparteChatHost` (the wrappers’ binding) listen on the host itself, which a `window` dispatch never reaches — so a host with its own loop that dispatched on `window` saw the button follow and the conversation list not. `targetId` is the host’s `id`, which is also what the composer’s `target` names; leave it out only on a page with one chat. Setting `status: 'streaming'` on the assistant bubble does nothing for the composer: the bubble and the button are two readers of the same turn, and the turn is the events. ```ts import type { AparteAbortEventDetail, AparteMessageAbortedEventDetail, AparteMessageDoneEventDetail, AparteMessageStartEventDetail, } from '@aparte/core'; const host = document.querySelector('aparte-chat, [data-aparte-chat]')!; const targetId = host.id; const lifecycle = (name: string, detail: T) => host.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail })); document.addEventListener('aparte-send', async (event) => { const id = crypto.randomUUID(); lifecycle('aparte-message-start', { targetId, messageId: id, role: 'assistant' }); 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); lifecycle('aparte-message-done', { targetId, messageId: id, role: 'assistant' }); }); // The other direction. Stop dispatches `aparte-abort` on window — a command, not a // notification — scoped by `targetId`, so a page with two chats checks it. The composer // resets itself; the host-bound readers still need the turn closed on the host. window.addEventListener('aparte-abort', (e: CustomEvent) => { if (e.detail?.targetId && e.detail.targetId !== targetId) return; cancelMyLoop(); lifecycle('aparte-message-aborted', { targetId }); }); ``` All four payloads are typed in the [events reference](/reference/events/), and the readers are the same ones `AparteClient` drives — dispatching on the host makes each of them follow your turn, not only the button. ## What you give up [Section titled “What you give up”](#what-you-give-up) Display-only means the pieces `AparteClient` orchestrates don’t run in the page: no built-in tool-approval flow, no retry/edit re-sending, no request building. Your loop owns those. Concretely, for retry and edit: the **buttons exist**, and clicking one emits `aparte-retry` / `aparte-edit` and nothing else. Nobody re-sends, and on edit the editor closes and the original text comes back. That is why core ships both **off** — so a display-only integration shows no button it can’t honour. Either handle those two events in your loop and switch them on: ```ts aparteGlobalConfig.setBubbleActions({ retry: true, edit: true }); ``` …or leave them off, which is the default and costs you nothing. Same story for the ⓘ details popover and the image-tile preview — see [What ships enabled](/guides/customization/#what-ships-enabled). For tool-call rows, thinking sections and other rich segments, `addSegment` / `appendToSegment` / `updateSegment` (same imperative API) stream structured segments the same way `injectTokenStream` streams plain text. # Persist chat conversations (IndexedDB, SQLite, REST) > Implement an AparteStorageAdapter, register a AparteConversationManager, and drive a multi-conversation sidebar that loads and saves threads. Everything so far lives in memory — reload the page and the thread is gone. aparté’s persistence subsystem closes that gap. A **`AparteConversationManager`** holds the list of conversations and notifies listeners on every change, but never touches storage directly — that’s the job of an **`AparteStorageAdapter`** you implement against any backend (`localStorage`, IndexedDB, SQLite WASM, your own REST API). A `conversationId` binding loads/persists the *active* thread; [``](/components/conversation/aparte-conversation-list/) renders a sidebar to switch between them. ## 1. Implement an `AparteStorageAdapter` [Section titled “1. Implement an AparteStorageAdapter”](#1-implement-an-apartestorageadapter) The contract has three **required** methods — together they’re the minimum viable adapter: ```ts interface AparteStorageAdapter { loadAll(): Promise; // all conversations, full payload, newest first save(conv: AparteConversation): Promise; // upsert (create or update) delete(id: string): Promise; // permanent delete // + optional split-storage extensions: archive?/unarchive?, loadMeta?/loadFull?, // pin?/unpin?, rename?, and loadAttachments? for persisted attachment blobs. } ``` An `AparteConversation` carries `id`, `title`, `createdAt`, `updatedAt`, a flat `messages` array (the active path — always kept for sidebar previews and compat), an optional `tree` (full branch topology, see [Conversations & branching](/guides/conversations-branching/)), and optional `archivedAt` / `pinnedAt` / `folderId` / `schemaVersion` (current version is `2`; treat `undefined` as legacy data). The current value is exported as `APARTE_CONVERSATION_SCHEMA_VERSION`, so an adapter can compare it against what it stored and migrate instead of guessing. Here’s a complete adapter over `localStorage`, implementing the three required methods plus the optional `archive` / `unarchive` pair: ```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(convs: AparteConversation[]): void { localStorage.setItem(KEY, JSON.stringify(convs)); } export class LocalStorageAdapter implements AparteStorageAdapter { async loadAll(): Promise { return readAll().sort((a, b) => b.updatedAt - a.updatedAt); } async save(conv: AparteConversation): Promise { const all = readAll(); const i = all.findIndex((c) => c.id === conv.id); if (i >= 0) all[i] = conv; else all.push(conv); writeAll(all); } async delete(id: string): Promise { writeAll(readAll().filter((c) => c.id !== id)); } // Optional — without them, archiving falls back to a plain save(). async archive(id: string): Promise { this._setArchived(id, Date.now()); } async unarchive(id: string): Promise { this._setArchived(id, undefined); } private _setArchived(id: string, archivedAt: number | undefined): void { const all = readAll(); const conv = all.find((c) => c.id === id); if (conv) { conv.archivedAt = archivedAt; writeAll(all); } } } ``` An IndexedDB (or SQLite WASM) adapter follows the same shape — every method is `async` precisely so any backend fits. Richer backends can additionally implement `loadMeta()` / `loadFull(id)` (fast sidebar listing vs. lazy full payload), `pin` / `unpin` / `rename`, and `loadAttachments(msgId)` for attachment blobs — all optional, consulted only when present. Anything beyond the conversation — a memory of user facts, a settings store, a gallery of every artifact ever produced — is your app’s own table: extend the interface in your code rather than expecting the library to carry your schema. ## 2. Register a `AparteConversationManager` [Section titled “2. Register a AparteConversationManager”](#2-register-a-aparteconversationmanager) `AparteConversationManager` owns the in-memory list, mutates it, and calls your adapter — your app never calls the adapter directly. ```ts import { aparteGlobalConfig, AparteConversationManager } from '@aparte/core'; import { LocalStorageAdapter } from './local-storage-adapter'; const manager = new AparteConversationManager(new LocalStorageAdapter()); await manager.init(); // hydrates the list from the adapter aparteGlobalConfig.setConversationManager(manager); // registers it for every component ``` Running several independently-configured chats on one page? Call `setConversationManager` on each chat’s own `AparteConfig` instance (passed as `config`) instead of the global singleton. Useful reads once registered: `manager.conversations`, `manager.activeConversations` / `manager.archivedConversations` (newest first), `manager.activeId`, `manager.active`, and `manager.subscribe(listener)` (returns an unsubscribe fn; fires after every mutation). Mutations: `createNew(title?)`, `delete(id)`, `archive(id)`, `unarchive(id)`. Optional bounded history: `new AparteConversationManager(adapter, { retention: { maxMessages: 200 } })` trims a persisted conversation to its last N messages on every write — **storage only**, the live session in the DOM is never truncated. ## 3. Load and persist the active thread — `conversationId` [Section titled “3. Load and persist the active thread — conversationId”](#3-load-and-persist-the-active-thread--conversationid) Each wrapper’s `` exposes a **`conversationId`** binding. Setting it loads that conversation’s messages (and branch tree, if any) via the registered manager; setting it to `null` deselects. Sending the first message while `conversationId` is unset lazily creates a conversation and reports the new id back, so you can sync a URL/router. * Vanilla There is no binding without a wrapper — the pair is a wrapper convenience over `AparteConversationController`, which is the next section on this page. ```ts controller.bind(); void controller.setConversationId('abc-123'); // or null to deselect ``` * React ```tsx const [conversationId, setConversationId] = useState(null); ``` * Vue ```vue ``` * Svelte ```svelte (conversationId = e.detail)} /> ``` * Angular ```html ``` Without a wrapper, drive the same lifecycle yourself with `AparteConversationController` — it’s exactly what the wrappers use internally: ```ts import { AparteConversationController } from '@aparte/core'; const chat = document.querySelector('aparte-chat')!; const viewport = (chat as any).viewport; const controller = new AparteConversationController({ hostId: 'main-chat', host: chat, getMessages: () => viewport.getMessages(), setMessages: (m) => viewport.setMessages(m), appendMessage: (m) => viewport.appendMessage(m), clearMessages: () => viewport.clearMessages(), exportTree: () => viewport.exportTree(), // optional — enables branch persistence importTree: (t) => viewport.importTree(t), // optional }); controller.bind(); void controller.setConversationId('abc-123'); // or null to deselect ``` ## 4. Render a sidebar [Section titled “4. Render a sidebar”](#4-render-a-sidebar) `` is a display primitive: set its `conversations` property and `active-id` attribute, and handle the events it emits (all bubble, `detail: { id }` — rename adds `title`). Each row is its title plus a `⋯` menu: rename, pin/unpin, archive/unarchive, delete (which asks first). The rows are grouped by date as soon as any item carries `updatedAt`; set `no-groups` to render them flat. ```ts const list = document.querySelector('aparte-conversation-list')!; function render() { (list as any).conversations = manager.activeConversations; list.setAttribute('active-id', manager.activeId ?? ''); } manager.subscribe(render); render(); list.addEventListener('aparte-rename-conversation', (e) => manager.updateTitle(e.detail.id, e.detail.title)); list.addEventListener('aparte-pin-conversation', (e) => manager.pin(e.detail.id)); list.addEventListener('aparte-unpin-conversation', (e) => manager.unpin(e.detail.id)); list.addEventListener('aparte-archive-conversation', (e) => manager.archive(e.detail.id)); list.addEventListener('aparte-unarchive-conversation', (e) => manager.unarchive(e.detail.id)); list.addEventListener('aparte-delete-conversation', (e) => manager.delete(e.detail.id)); // Selecting a conversation is owned by the `conversationId` binding (or a window-level // `aparte-select-conversation` event that every bound controller listens for), not the list. ``` Each wrapper ships a reactive helper around the same manager — call its `init(adapter)` once instead of constructing `AparteConversationManager` by hand (step 2 is done for you), then bind `conversations` / `activeConversations` / `archivedConversations` to the list and `createNew` / `addMessage` / `updateMessages` / `updateTitle` / `pin` / `unpin` / `delete` / `archive` / `unarchive` to actions: | Wrapper | Helper | | ------- | ---------------------------------------------------------------- | | React | `useConversationManager()` — hook, plain state | | Vue | `useConversationManager()` — composable, refs/computed | | Svelte | `createConversationManager()` — stores | | Angular | `ConversationManagerService` — injectable (`providedIn: 'root'`) | Switching the active conversation stays owned by the `conversationId` binding — the helpers deliberately don’t expose a `select()`. ## 5. What survives a round trip [Section titled “5. What survives a round trip”](#5-what-survives-a-round-trip) Your adapter stores `AparteMessage` objects verbatim, segments included, and hands them back. Core then **adopts** them rather than treating them as new — and the difference is worth knowing, because two of the fields on a segment are facts and two are measurements. | on a restored segment | what happens | why | | ----------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `id`, `type`, `content`, … | yours, untouched | it is your data | | `messageId`, `index` | **recomputed** from the array being joined | derivable facts. A stored value can only contradict the list it lands in — and no protocol persists either; Anthropic’s block `index` exists solely inside the streaming envelope, as a position | | `meta.aparte.startedAt` / `endedAt` | **not restored, and not invented** | a span is something the client measured while the turn ran. A measurement nobody took is absent | | `isStreaming` | forced to `false` | a persisted stream is dead. Restored as streaming it would render a caret for ever, and the next completed turn would stamp it a brand-new end | | `meta.*` (yours) | round-trips as stored | your half of the bag | **Nothing is lost that was ever there.** Most backends store messages and have never heard of a segment — content parts carry no timestamp in any wire format — so there is usually nothing to lose. What changed is that core no longer *fills the gap with now*: a conversation from three weeks ago used to come back claiming every segment had started that second, and only on some of the load paths, so the same stored thread produced different numbers depending on whether you were in native or framework-managed mode and on whether a `tree` had been saved. **A request that outlived its page is closed for you.** A [`tool_call`](/segments/tool-call/) persisted as `awaiting-approval` comes back as `aborted`. The loop that awaited the decision went with the page, so nothing can answer it — and `aborted` rather than `rejected` because nobody refused anything. Every load path shares one normalisation, so this holds whichever of them your adapter uses. **One thing core still cannot fix for you.** A `tool_call` persisted as `pending` comes back with a spinner and an open span, because the handler that was running is likewise gone. If your adapter can be interrupted mid-turn, normalise that status on save. *** See its [element page](/components/conversation/aparte-conversation-list/) for the exact property and attribute signatures, and [Conversations & branching](/guides/conversations-branching/) for `exportTree()` / `importTree()`, which this subsystem persists as the `tree` field. # Edit, regenerate and branch a chat conversation > Retrying an answer forks the conversation into a branch instead of overwriting it — with a built-in ‹ 1 / 2 › picker to move between versions. Editing a user message updates it in place and regenerates what follows. A conversation in aparté isn’t a flat list — it’s a **tree**. When you **retry** an answer, aparté doesn’t overwrite the old one: it adds a **sibling branch**, so every version is kept and navigable. The **active path** (root → leaf) is what’s rendered; a built-in **‹ 1 / 2 ›** picker moves between siblings. ```plaintext user: "Explain closures" ├─ assistant: "First answer…" ‹ 1 / 2 › └─ assistant: "Regenerated…" ← active ``` **Editing** a user message is different — it updates the message in place and regenerates what follows (it does *not* keep the old version as a branch). See [Editing a user message](#editing-a-user-message) below. Retry and edit are opt-in Both buttons need a host to mean anything, so core ships them **off**. One line turns them on, next to wherever you start your client: ```ts aparteGlobalConfig.setBubbleActions({ retry: true, edit: true }); ``` Details — and the full list of what ships enabled — in [Customization](/guides/customization/#what-ships-enabled). ## Retry creates branches [Section titled “Retry creates branches”](#retry-creates-branches) Width 375 768 Full [Open in a tab](/preview/demo/branch-picker/) [A reply with siblings, and the branch picker](/preview/demo/branch-picker/) One reply among two. The picker appears on its own as soon as a message has siblings. ```html ``` The built-in **retry** bubble action emits the public event `aparte-retry`. Handling it creates a new sibling of the answer — a fresh branch under the same parent, with the old answer kept. The **branch picker and its navigation are built in**: as soon as a message has more than one sibling, the bubble renders `‹ 1 / 2 ›`, and its prev/next buttons switch the active branch for you (via `aparte-branch-navigate`, which the viewport handles). You never wire navigation yourself. ## Editing a user message [Section titled “Editing a user message”](#editing-a-user-message) The **edit** bubble action opens an inline editor in place of the message text. It’s the **same input as the composer** ([``](/components/input/aparte-composer/)), so it behaves identically — autosize, IME, paste, and the same keys: * **Enter** saves · **Shift+Enter** inserts a newline · **Esc** cancels. Saving emits `aparte-edit` with `{ messageId, content, targetId }` — and that is *all* the bubble does. **The replacement is the handler’s job**: the editor closes, and if nobody writes the new text back, the original text reappears. With [`AparteClient`](/guides/getting-started) it is automatic — the client replaces the message in place and regenerates the answer(s) below it (the previous response is cleared, not kept as a sibling). To wire it yourself, see [the manual way](#the-manual-way) below. Unlike retry, the edit **does not branch**. ## The automatic way — `AparteClient` [Section titled “The automatic way — AparteClient”](#the-automatic-way--aparteclient) If you drive the chat with [`AparteClient`](/guides/getting-started), retry and edit are **handled out of the box**: the client listens for `aparte-retry` / `aparte-edit`. On **retry** it creates the sibling branch and re-streams the new answer into it; on **edit** it updates the user message in place, clears the old answer, and re-streams a fresh one. Nothing to write. ## The manual way [Section titled “The manual way”](#the-manual-way) Without the client (e.g. a custom loop), handle `aparte-retry` yourself. Create the sibling with **`viewport.addSiblingOf(messageId, newMessage)`** — it returns the new message’s id — then stream into it: ```ts const viewport = document.querySelector('aparte-chat-viewport')!; // or chat.viewport declare const yourModelStream: AsyncIterable; // `async`, because of the `for await` below. document.addEventListener('aparte-retry', async (e) => { const id = viewport.addSiblingOf(e.detail.messageId, { id: crypto.randomUUID(), role: 'assistant', content: '', timestamp: Date.now(), }); if (!id) return; // Stream your model's new answer into the branch: for await (const token of yourModelStream) viewport.appendToken(id, token); viewport.completeMessage(id); }); ``` The new branch becomes active and the `‹ 1 / 2 ›` picker appears automatically. The old answer isn’t lost — it’s the other sibling, one click away. For **edit**, handle `aparte-edit`: overwrite the user message, drop its now-stale answer, and stream a fresh one. This mirrors what `AparteClient` does — an in-place update, not a branch: ```ts document.addEventListener('aparte-edit', async (e) => { const { messageId, content } = e.detail; viewport.updateMessage(messageId, { content }); // replace the user text in place viewport.truncateResponsesAfter(messageId); // drop the previous answer(s) const id = viewport.addSiblingOf(messageId, { // a fresh answer under the edited turn id: crypto.randomUUID(), role: 'assistant', content: '', timestamp: Date.now(), }); if (!id) return; for await (const token of yourModelStream) viewport.appendToken(id, token); viewport.completeMessage(id); }); ``` ## Turning it off [Section titled “Turning it off”](#turning-it-off) Both actions are off until you ask for them, so “turning it off” is usually just *not* opting in. To take one back after the fact: ```ts aparteGlobalConfig.setBubbleActions({ retry: false }); // keep edit, drop retry aparteGlobalConfig.setBubbleActions({ user: ['copy'] }); // user bubbles: copy only, no editor ``` It applies live — already-rendered bubbles rebuild their action bar — and a bar left with nothing in it is not rendered at all, so no empty row remains. The **branch picker needs no switch**: it appears only when a message actually has a sibling, so a chat that never retries never shows it. If you want it styled away or replaced, that’s [`setSiblingNavRenderer`](#customizing-the-picker). While a reply streams, the picker’s arrows — and retry and edit on every message — are disabled: the transcript is read-only except for Stop, because a swap or a retry mid-stream would change the path under the reply being written. The viewport carries `data-busy` while it streams, if you want to reflect the same state in controls of your own. ## Persistence [Section titled “Persistence”](#persistence) The whole tree — every branch, not just the active path — round-trips through the viewport as a plain, serializable object. **`exportTree()` just hands you the data; where it lives is up to you** — the browser in a front-only app, or your own backend otherwise: ```ts const tree = viewport.exportTree(); // plain object — persist it however you like // front-only (local-first): localStorage.setItem('chat', JSON.stringify(tree)); // or with a backend: await fetch('/api/chats/42', { method: 'PUT', body: JSON.stringify(tree) }); // later, to restore: viewport.importTree(tree); ``` For multi-conversation storage (list, switch, delete — against localStorage, IndexedDB, or your API), core also ships a `AparteConversationManager` + a storage-adapter contract — a topic of its own. ## Customizing the picker [Section titled “Customizing the picker”](#customizing-the-picker) The `‹ 1 / 2 ›` control is a render hook: swap it for your own markup with `aparteGlobalConfig.setSiblingNavRenderer(({ count, index }) => …)`. See [Customization](/guides/customization#render-hooks). *** See the [`` page](/components/conversation/aparte-chat-viewport/) for the exact signatures of `addSiblingOf`, `navigateBranch`, `exportTree` and `importTree`. # Customization > Replace any region's markup, add action-bar buttons, render your own segment types (custom message content — generative UI), and swap icons — all without forking the components. [Theming](/guides/theming) covers the *look* through CSS variables. This page is the *structure and behaviour*: when a colour isn’t enough — a custom typing indicator, your own attachment chip, an avatar, extra buttons, a brand-new block type — you reach for **render hooks** and the **action registry**. Everything here goes through `aparteGlobalConfig` (or a scoped instance — see [Per-instance config](#per-instance-config)). Nothing requires forking a component. ```ts import { aparteGlobalConfig } from '@aparte/core'; ``` ## Render hooks [Section titled “Render hooks”](#render-hooks) A render hook replaces one region’s markup. Each returns **`string | HTMLElement`** — return a string for simple markup (inserted as `innerHTML`), or an `HTMLElement` to attach your own listeners/framework nodes with no `innerHTML` XSS surface. | Hook | Replaces | Receives | | ------------------------ | --------------------------- | -------------------- | | `setStatusRenderer` | the typing indicator | `(text)` | | `setErrorRenderer` | an error segment | `({ message, … })` | | `setAttachmentRenderer` | an attachment chip | `(attachment)` | | `setSiblingNavRenderer` | the branch picker `‹ 1/2 ›` | `({ count, index })` | | `setBubbleShellRenderer` | the whole bubble shell | `(ctx)` | ```ts aparteGlobalConfig.setStatusRenderer((text) => `
${text}…
`); aparteGlobalConfig.setErrorRenderer(({ message }) => { const el = document.createElement('div'); el.className = 'my-error'; el.textContent = message; // textContent → no interpolation XSS return el; }); aparteGlobalConfig.setAttachmentRenderer((att) => { const el = document.createElement('span'); el.className = 'my-chip'; el.textContent = att.name; // textContent → the filename can't inject HTML return el; }); ``` Pass `null` to any setter to restore the default. Caution A render hook that returns a **string** is inserted as HTML. Never interpolate user- or model-supplied values into that string (`` `${att.name}` ``) — a crafted filename or message becomes an XSS vector. Return a **DOM element** and set `textContent` (as above), or escape every interpolated value. Core’s built-in renderers already do this; the trust boundary is yours the moment you return a string. ### If you do return a string: `escapeHtml` and `escapeAttr` [Section titled “If you do return a string: escapeHtml and escapeAttr”](#if-you-do-return-a-string-escapehtml-and-escapeattr) Core exports the two escapers its own renderers use, so you don’t have to write them. **The position decides which one** — and the difference is not cosmetic: ```ts import { aparteGlobalConfig, escapeHtml, escapeAttr } from '@aparte/core'; import type { AparteAttachment } from '@aparte/core'; aparteGlobalConfig.setAttachmentRenderer((att: AparteAttachment) => ` ${escapeHtml(att.name)} `); ``` * **`escapeHtml`** — for text *between* tags. * **`escapeAttr`** — for anything *inside* an attribute value. It also encodes the apostrophe, which `escapeHtml` alone is not required to: a single quote is enough to break out of `title='…'` and add an `onerror` of its own. Building a **CSS selector** rather than markup is a third case: use `cssEscape`, also exported, because `querySelector` needs backslash escaping and will otherwise throw or match the wrong node. A gate script (`pnpm check:attr-escaping`) enforces this across the library’s own source, which is how a raw `data-role="${role}"` was found sitting one line from an escaped sibling. ## Avatars [Section titled “Avatars”](#avatars) There’s no message avatar by default — the slot only appears once you provide one. The avatar provider is **imperative**: you get the already-sized `.aparte-avatar` host and fill it. Return an optional cleanup function for live components. ```ts aparteGlobalConfig.setAvatarProvider({ render(role, host) { host.textContent = role === 'assistant' ? '✦' : '🙂'; // return () => { /* dispose a mounted component */ }; }, }); ``` ## Action-bar buttons [Section titled “Action-bar buttons”](#action-bar-buttons) Buttons on the message bubble **and** the composer come from **one registry**, keyed by zone. Add your own with `registerAction`: ```ts aparteGlobalConfig.registerAction({ id: 'share', icon: '', // raw HTML if it starts with '<', else an icon key label: 'Share', zones: ['bubble'], // 'bubble' | 'composer' | both bubble: { roles: ['assistant'] }, }); ``` Clicks are **declarative** — they emit `aparte-action`, so you handle them in one place: ```ts document.addEventListener('aparte-action', (e) => { const { actionId, zone, messageId, role } = e.detail; if (actionId === 'share') {/* … */} }); ``` * `zones` decides where it shows; `composer: { position: 'left' | 'right' }` and `bubble: { roles: [...] }` refine placement; `order` sorts custom actions. * An `onClick(event)` callback is optional and fires alongside the event. * Hide/show at runtime with `aparteGlobalConfig.setActionHidden(id, hidden)`. * The **built-in** bubble actions (copy / retry / edit / feedback / info) are toggled per role with `aparteGlobalConfig.setBubbleActions({ … })` — see [What ships enabled](#what-ships-enabled) just below, because most of them are **off** until you ask. ## What ships enabled [Section titled “What ships enabled”](#what-ships-enabled) The rule aparté follows: **a visible control that core cannot honour end-to-end is not rendered by default.** A button that answers to nobody is worse than a missing feature — the user clicks it and concludes the app is broken. Core can copy text to the clipboard by itself, so `copy` ships on. Everything else needs someone outside core — `AparteClient`, or your own event listener — so it waits for you to say you’re there: | Control | Needs | Default | | ------------------ | ---------------------------------------------- | ------- | | `copy` | nothing (core does it) | **on** | | `retry` | a host that re-sends (`aparte-retry`) | off | | `edit` | a host that keeps the new text (`aparte-edit`) | off | | `feedback` (👍/👎) | your listener (`aparte-feedback`) | off | | `info` (ⓘ details) | your popover (`aparte-message-info`) | off | | image-tile preview | your lightbox (`aparte-attachment-preview`) | off | Two levers, one for the action bar and one for everything else: ```ts // You run an AparteClient, so retry and edit do something: aparteGlobalConfig.setBubbleActions({ retry: true, edit: true }); // You handle this event yourself: aparteGlobalConfig.setHostHandlers({ attachmentPreview: true }); ``` The download on a *binary* artifact used to be a third row here; it is [`@aparte/plugin-artifacts`](/plugins/artifacts/)’s now, and it follows the same rule in the plugin’s own terms — the button exists only when the app gave `setupArtifacts` an `onBinary` that can produce the bytes. The **branch picker** `‹ 1/2 ›`, the waiting indicator, the stop button and the model selector are all in the first category — core honours them itself, so they need no declaration (and the picker hides itself as soon as a message has no sibling left). Nothing is removed by these flags: the events are unchanged and always public. An undeclared affordance simply isn’t offered — and an undeclared image tile isn’t even signalled as clickable (no `role="button"`, no tab stop, no pointer cursor), because half-signalling is the same lie in a quieter voice. `aparte-send` is the exception that proves the rule: with no host, nothing answers a send either — but that’s the primary function, the failure is immediate and it’s the developer, not the user, who sees it. The **tool-approval gate** needs no declaration either, for a different reason: the [`tool_call`](/segments/tool-call/) row shows only that a tool is waiting, and the choices are raised at the composer by a loop that is actually waiting for the verdict. Nothing in the transcript is clickable, so there is no affordance there to declare — and a segment restored from storage cannot offer a button whose listener went with the page, which is what the old inline Approve / Reject did. Defaults are readable at runtime — `APARTE_DEFAULT_BUBBLE_ACTIONS` and `APARTE_DEFAULT_HOST_HANDLERS` are exported from `@aparte/core`, so you never hard-code them. ## The composer toolbar [Section titled “The composer toolbar”](#the-composer-toolbar) The composer has a **bottom row** — the strip a mode picker, a model selector or a token counter belongs in, rather than a bar of your own floating below the chat. It is an element, [``](/components/input/aparte-composer/), and it is the same name in vanilla and in every wrapper. **Placement is the DOM order.** There is no left/center/right slot: put your controls in the order you want them, and push one — with everything after it — to the end with `margin-inline-start: auto`. That is a *logical* property, so the row reads correctly in a right-to-left locale without you doing anything: the composer mirrors the locale’s reading direction onto itself, and the push follows. The row is **not** part of the default [``](/components/conversation/aparte-chat/) shell. Nothing is drawn until you put something in it, and an empty row draws no separator either. ```html
``` (`` comes from [`@aparte/plugin-model-selector`](/plugins/model-selector/); anything of yours works just as well.) The wrappers render the element for you as soon as you fill their **one** `toolbar` slot: * Vanilla No slot to fill: core has no shadow root, so the toolbar is an ordinary child you write where you want it. ```html ``` * React ```tsx } /> ``` * Vue ```vue ``` * Svelte A fragment, so several nodes project without a wrapper element around them. ```svelte ``` * Angular `slot="toolbar"` on each projected node — Angular projects by selector, so the attribute goes on the node itself rather than on a wrapper. ```html ``` A second slot sits **between the transcript and the composer** — `aboveComposer` in React, `above-composer` elsewhere — for a banner, a suggestion row or a disclaimer. Same rule: nothing is rendered until you fill it. The full list, with every framework’s syntax side by side, is generated from the wrapper source: [Wrapper surface](/reference/wrappers/). ## The waiting state [Section titled “The waiting state”](#the-waiting-state) While a reply is in flight and the bubble has nothing in it yet, the bubble shows a **built-in indicator** — animated dots plus a screen-reader label taken from `locale.typing`, alongside the `aria-busy` the streaming state already sets. No wiring: it behaves the same in a plain ``, in the four wrappers, and when you drive your own loop. It retires itself as soon as there is content or a segment. A bubble is considered in flight when `isAwaitingReply(message)` says so: either the message states it (`status: 'streaming' | 'pending'`), or it is an **assistant message with no `status` at all and nothing in it** — the empty shell a token stream is about to fill. Only silence is interpreted; an explicit status is believed, so a deliberately empty *finished* message needs `status: 'completed'` (otherwise it waits forever). Restyle it with `--aparte-waiting-height` / `--aparte-waiting-dot-gap` and the shared `--aparte-status-color` / `--aparte-status-dot-size` (see [Theming](/guides/theming/)); it already honours `prefers-reduced-motion`. ## Custom bubbles [Section titled “Custom bubbles”](#custom-bubbles) Replacing the whole bubble has two levels: * **`setBubbleShellRenderer`** (above) keeps the native [``](/components/conversation/aparte-chat-bubble/) and swaps its inner structure. The machinery — action bar, streaming pushes, segments — keeps working, as long as your shell carries the region hooks. * **Your own element per message** (`renderBubble` in React, the `bubble` slot in Vue/Svelte, `[bubbleTemplate]` in Angular) replaces the element itself. In a wrapper that node is driven by the **reactive message list**: re-render from `message.content` and `message.segments` — during streaming they update live, no imperative interface to implement. In **raw core** there is no reactive list, so a replacement element opts into the imperative pushes instead: give it `data-aparte-bubble` and `message-id="…"`, and the viewport will call `appendToken` / `appendToSegment` / `updateMessage` on it exactly as it does on the native bubble. ```html {/* raw core: a custom element that still receives live streaming */} ``` ## Custom segment types [Section titled “Custom segment types”](#custom-segment-types) Streamed replies are split into typed **segments** (text, code, thinking, tool\_call, …). Register a renderer to add your own type — a chart, a map, a form: ```ts import { registerSegmentRenderer } from '@aparte/core'; registerSegmentRenderer({ type: 'chart', render(segment) { const el = document.createElement('div'); el.className = 'my-chart'; // build from segment data… return el; // string or HTMLElement }, }); ``` ### Teach the parser a block [Section titled “Teach the parser a block”](#teach-the-parser-a-block) A renderer draws a segment; something has to *produce* it. Models write conventions into their prose — `` for reasoning, `` for a document, `` for a patch, `` for a source — and every one is the same shape: an opening tag with attributes, a body that streams, a closing tag. Register the grammar and the stream parser does the rest: ```ts import { aparteGlobalConfig, 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 || '#'; a.textContent = segment.content; return a; }, }); ``` `toSegment` is called once, at the opening tag, with an `AparteStreamBlockMatch`: the attributes as written (quoted or bare — ``, ``) and the id the parser generated. It returns the segment with an empty `content`, and the parser streams the body into it delta by delta, marks it `isStreaming` while the block is open and closes it at `` or at the end of the stream. Everything that makes this hard is done once for every grammar: the earliest tag wins against a code fence and a reasoning delimiter, a tag cut at a chunk boundary is held back, a closing tag split across two chunks never leaks as content, and `` does not open a `cite` block. What the segment *is* stays yours — a `type` with a renderer, or a built-in one if the block is only another spelling of it. The blocks are read when a turn starts, so a grammar registered mid-turn applies from the next one. ### Driving the whole registry [Section titled “Driving the whole registry”](#driving-the-whole-registry) `registerSegmentRenderer` adds a type. Three more functions drive the registry itself, and none of them needs an `AparteClient`: ```ts import { declineDefaultRenderers, installDefaultRenderersOnce, getAllRenderers, } from '@aparte/core'; // Draw every segment yourself: refuse the built-ins BEFORE the first bubble renders. declineDefaultRenderers(); // …or install them lazily — this is what does on first render. installDefaultRenderersOnce(); // Introspect the wiring. Runs in Node, with no DOM, so a test can assert your setup. const types: readonly string[] = getAllRenderers().map((r) => r.type); ``` `declineDefaultRenderers()` is what `new AparteClient({ autoRegister: false })` calls internally — calling it directly is the only way to say the same thing without constructing a client, which [Bring your own loop](/guides/bring-your-own-loop/) tells you not to do. All three take an optional trailing config, so each can be scoped to one chat rather than the page. ### What a segment knows about itself [Section titled “What a segment knows about itself”](#what-a-segment-knows-about-itself) Every segment carries where it sits and when it happened, so you can build the chrome the market has taught users to expect — a collapsed reasoning line with its duration, a tool row with how long the call took — without replacing a renderer: | Field | | | ----------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `messageId` | the message the segment belongs to | | `index` | its position in that message’s `segments[]` | | `meta.aparte.startedAt` | epoch ms when it entered a **live** transcript | | `meta.aparte.endedAt` | epoch ms when content **last arrived** — advances while streaming, frozen once settled | | `meta.*` | yours: token counts, cost, anything. `updateSegment(id, { meta })` merges, so writing your own key never erases core’s | The two measurements live in `meta.aparte` rather than on the segment because **no protocol carries a timestamp on a content block** — not Anthropic’s, not OpenAI’s, not the AI SDK’s parts. A span is something the client measured, so its shape says so. It is still typed (`AparteSegmentTiming`), and namespaced under `aparte` because the rest of `meta` is yours. Everything here is optional, and that is not hedging: it describes a lifecycle. A segment you built by hand, or one straight out of the parser, has not been inserted yet — so it has no start; an open segment has no end; and a segment restored from storage has neither, because a measurement nobody took is absent rather than invented. Read the span with **`segmentDuration(segment)`** rather than subtracting anything yourself, ask **`isSegmentSettled(segment)`** whether the number is final, and reach for **`segmentTiming(segment)`** when you want the two numbers themselves — all three are exported, and all three are the rules core uses internally rather than a copy of them. **Core renders none of it.** It measures a duration honestly (it owns the stream) and leaves the display to you, because the line you want says “Thought for 8s” in one product and “8.2s · 1.2k tokens” in another: ```ts import { getSegmentRenderer, registerSegmentRenderer, isSegmentSettled, segmentDuration } from '@aparte/core'; import type { AparteThinkingSegment } from '@aparte/core'; const builtIn = getSegmentRenderer('thinking')!; /** A markup string back to the single root element it describes. */ function parseRoot(html: string): HTMLElement { const t = document.createElement('template'); t.innerHTML = html; return t.content.firstElementChild as HTMLElement; } registerSegmentRenderer({ type: 'thinking', render(segment) { const out = builtIn.render(segment); // Keep the ROOT the built-in produced — do not wrap it in a div of your own. // The bubble finds a segment to update with `:scope > [data-segment-id="…"]`, // so an extra wrapper hides that attribute and every delta falls back to a // full re-render of the transcript instead of an in-place write. 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); // ALSO here, and this is the part that is easy to miss: a block is created // open and settles LATER, and a settle reaches a renderer through `update` — // never through a second `render`. Writing the label only in `render` leaves // it reading "Thinking" forever. writeDuration(el, segment); }, // And here, for the same reason one step further out: a language switch or a new // icon set reaches a rendered segment through `relabel`, never through a second // `render`. Forward to the built-in so its own label follows, then re-apply yours. relabel: (el, segment) => { builtIn.relabel?.(el, segment); writeDuration(el, segment); }, }); function writeDuration(host: HTMLElement, segment: AparteThinkingSegment): void { // Two rules, both core's own, both imported rather than re-derived. // `isSegmentSettled` because a tool call settles by `status` and never by // `isStreaming`; `segmentDuration` because the bounds are optional and hand-rolled // truthiness checks on them are wrong at epoch 0. if (!isSegmentSettled(segment)) return; const ms = segmentDuration(segment); if (ms === undefined) return; const label = host.querySelector('.aparte-thinking-label'); // A sub-second span is "<1s", not "0.0s" — a zero reads as "did not think". if (label) label.textContent = ms < 1000 ? 'Thought for <1s' : `Thought for ${(ms / 1000).toFixed(1)}s`; } ``` Nothing writes `meta` — it is yours, and the imperative API already carries it. A provider that knows what a segment cost is where those numbers come from; core has no per-segment channel to a provider, so the write happens in your app: ```ts import type { AparteChatImperativeApi } from '@aparte/core'; declare const chat: AparteChatImperativeApi; // After a turn: attach what only you measured. chat.updateSegment('seg-7', { meta: { outputTokens: 1_240, device: 'webgpu' } }); // And read it back — `getMessages()` returns the segments with everything on them. const last = chat.getMessages().at(-1); for (const segment of last?.segments ?? []) { console.info(segment.index, segment.type, segment.meta); } ``` Two notes worth knowing before you rely on the numbers. **`endedAt` is the last delta, not the moment someone noticed.** That is why `isSegmentSettled` exists: during a turn the difference is a live duration that grows, and after it, it is final. The two simpler rules are both wrong and were both tried — closing at the end of the turn makes a reasoning block span the whole answer that followed it (2s of thinking before a 20s reply reads ”22s”), and closing when the next segment opens counts a ten-second gap as thinking. Note also that only payload counts: collapsing a block or writing `meta` is presentation, and does not move the end. **A segment is finished as soon as the stream says so, not when the turn ends.** The end of a delimited segment is in band — ``, a closing fence, ``, or the opening of the next block — and reasoning delivered on its own channel ends when the first answer token arrives. So `isSegmentSettled` flips *during* the turn, and your duration line is readable while the answer is still streaming. The one case with no in-band end is a text run that stops only because the stream stopped; there, the end of the turn really is its end. **A segment rehydrated from your storage keeps what it was persisted with.** Core stamps on insertion and never overwrites, so a reloaded conversation still shows the durations it originally had. ## Defaults for a segment type [Section titled “Defaults for a segment type”](#defaults-for-a-segment-type) A reasoning block is **closed** unless the segment says `collapsed: false`. If your app wants them all open, you cannot set that field: when a reply streams, *you* do not build its segments — the parser does. There is nothing to set it on. So the default is registered by type: ```ts import { aparteGlobalConfig } from '@aparte/core'; // Every reasoning block in this app arrives open. aparteGlobalConfig.setSegmentDefaults('thinking', { collapsed: false }); // Your own segment type, same call. aparteGlobalConfig.setSegmentDefaults('my-chart', { theme: 'dark' }); ``` One call keyed by type rather than one function per field — a `setThinkingOpen()` would need a sibling the next time any type wanted a default, and the type key is a string, so a type core has never heard of is covered by the same call. The second argument is an `AparteSegmentDefaults`: a read-only record, deliberately not a `Partial`, because your own segment type is as valid a key as a built-in one and its fields cannot be known in core. What it refuses is identity — `id`, `type`, `messageId`, `index` and the timing pair — since a default `id` would give every segment in a conversation the same one. What to expect: * **A field the producer set always wins.** A segment carrying `collapsed: true` keeps it, and so does one carrying `collapsed: undefined` — an explicit `undefined` is a statement, not a gap. * **Identity is never defaulted.** `id`, `type`, `messageId` and `index` are refused: a default `id` would hand every segment in a conversation the same one, and `index` is a fact about *this* insertion. A default may fill `meta`, but **not `meta.aparte`** — that would let an app hand itself a span it never measured. * **Every arrival path is covered**, because the defaults are applied where a segment’s identity is stamped: `addSegment`, the segments seeded on an `appendMessage`, and the framework host. Including the ones the parser produces, which is the case that needed it. * **Read at insertion, then baked in.** Changing a default later does not reach segments already on screen — deliberately: a block the reader opened has state the data does not, and a retroactive default would take it away. * **Per instance.** Each chat resolves its own config, so two chats on one page can default differently. `getSegmentDefaults(type)` reads them back and `clearSegmentDefaults(type)` drops them. ## Icons [Section titled “Icons”](#icons) Every icon ships as a zero-dependency inline SVG. Override any of them — with an SVG, an icon-font element, an emoji, or an `` (the value is treated as trusted markup): ```ts aparteGlobalConfig.setIconProvider({ copy: () => '', send: () => '', }); ``` You only override the keys you pass; the rest keep their defaults. The inline **message editor**’s save/cancel buttons use the `check` and `close` keys, so they follow your provider too; their colours are the `--aparte-success` (save) and `--aparte-error` (cancel) CSS variables. ### Keeping your own element in step [Section titled “Keeping your own element in step”](#keeping-your-own-element-in-step) A config change is meant to reach components that are already on screen — a language switch, a new icon set, different bubble actions. Core’s own elements listen for it; if you replace one of them, or write an element of your own that reads an icon or a locale string, subscribe with the same hook they use: ```ts import { subscribeConfigChange, aparteGlobalConfig } from '@aparte/core'; class MyComposerButton extends HTMLElement { private off: (() => void) | null = null; private button: HTMLButtonElement | null = null; connectedCallback(): void { this.innerHTML = ''; this.button = this.querySelector('button'); this.refreshChrome(); // A TARGETED refresh — reset the icon and the label on the button you already // have. A full re-render would throw away focus, listeners, a caret, or a // mounted preview. this.off = subscribeConfigChange(this, () => this.refreshChrome()); } disconnectedCallback(): void { this.off?.(); this.off = null; } private refreshChrome(): void { if (!this.button) return; const label = aparteGlobalConfig.t('sendButton'); this.button.innerHTML = aparteGlobalConfig.getIcon('send'); this.button.setAttribute('aria-label', label); this.button.title = label; } } ``` It resolves *your* element’s config on every event, so a change scoped to one chat on a page with two of them reaches only that one — and never latches a config captured before your element was mounted. `APARTE_CONFIG_CHANGE` is exported too, if you would rather listen on `window` yourself. ### Three moments, not one [Section titled “Three moments, not one”](#three-moments-not-one) A renderer can be called at four moments, and forgetting one is the most common way a custom renderer goes half-stale: | Hook | When | Rule | | --------- | --------------------------------------------- | ---------------------------------- | | `render` | the first time the segment appears | returns markup or an element | | `setup` | once, straight after `render` | wire listeners here | | `update` | a content delta, and when the segment settles | **no child node added or removed** | | `relabel` | the config changed — a language, an icon set | same rule as `update` | `relabel` exists because the obvious alternative does not work: re-rendering a segment to pick up a new locale throws away state the DOM owns and the segment data does not — a preview iframe that is running, a reasoning block the reader expanded by clicking ``, scroll position inside a long reasoning pane, the caret in a half-typed answer. Implement it only if your output contains text or icons that came from the config; a renderer whose chrome is all its own data correctly leaves it out. ## Content providers (opt-in) [Section titled “Content providers (opt-in)”](#content-providers-opt-in) Core carries no third-party dependency, so Markdown and syntax highlighting are **off** until you inject a renderer — keeping the bundle honest: ```ts import { aparteGlobalConfig } from '@aparte/core'; import { marked } from 'marked'; import { codeToHtml } from 'shiki'; aparteGlobalConfig.setMarkdownProvider((raw) => marked.parse(raw) as string); aparteGlobalConfig.setHighlightProvider((code, lang) => codeToHtml(code, { lang, theme: 'dracula' })); ``` ## Per-instance config [Section titled “Per-instance config”](#per-instance-config) `aparteGlobalConfig` is shared page-wide — right for the common one-chat-per-app case. To run several independently-customized chats on one page, attach an instance config to each chat’s root; every `` inside resolves the nearest boundary and falls back to global. ```ts import { AparteConfig, attachConfig } from '@aparte/core'; const support = new AparteConfig(); support.setStatusRenderer((t) => `${t}`); attachConfig(document.querySelector('#support-chat')!, support); ``` # Docs for AI agents — llms.txt and the MCP server > How a coding agent reads these docs — the llms.txt index and its per-topic files, and @aparte/docs-mcp, an MCP server that lets the agent search and read them. Two consumers’ coding assistants could not find things these docs already had — the approval elicitation (one rebuilt a modal it already had), the UI kit of classes, `systemPrompt: false`. A model that reads a site cold misses; a model that can ask finds. This page is the two ways a model reads aparté’s docs. ## `llms.txt` — the index a model can navigate [Section titled “llms.txt — the index a model can navigate”](#llmstxt--the-index-a-model-can-navigate) The site publishes [`/llms.txt`](/llms.txt): the library in one paragraph, where its three families live (components, segments, the UI kit), where asking-the-user and the display-only API are documented, and **one file per topic**, each with a sentence: | Set | Holds | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------ | | Getting started, frameworks and guides | Install, first render, every guide | | Tools, approval and asking the user | Tools, the tool-call row, human-in-the-loop approval, elicitation, the ask-user plugin — \~50 KB | | Bring your own loop (display-only) | Driving the transcript from a loop you own, the wrappers’ imperative API, the backend transport | | Theming and the UI kit | CSS variables, dark mode, the customization hooks, the kit of classes with their HTML | | Providers and transports | Every provider, local models, writing your own | | Reference: components, segments, events, config | The generated references | [`/llms-small.txt`](/llms-small.txt) is the guides without the generated references (for a small context window); [`/llms-full.txt`](/llms-full.txt) is everything. Paste a set’s URL into a model that can fetch, or give it the whole index. ## `@aparte/docs-mcp` — the docs as an MCP server [Section titled “@aparte/docs-mcp — the docs as an MCP server”](#apartedocs-mcp--the-docs-as-an-mcp-server) For a client that speaks the Model Context Protocol (Claude Code, Claude Desktop, Cursor and the rest), the same text is a server with four tools: ```bash claude mcp add aparte-docs -- npx -y @aparte/docs-mcp ``` ```json { "mcpServers": { "aparte-docs": { "command": "npx", "args": ["-y", "@aparte/docs-mcp"] } } } ``` | Tool | Ask it for | | ------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `list_sets` | The topics, one sentence each — start here | | `search_docs` | Pages ranked for plain words (`"approve a tool call"`, `"systemPrompt false"`, `"finish a turn in display-only"`), with a snippet | | `get_page` | One page’s markdown, by title | | `get_set` | A whole topic at once | It reads the site rather than bundling it: every `@aparte/*` package ships at one version and the site deploys from the same commit, so the docs online are the docs of the version you installed, and the package stays a few kilobytes. It needs the network; for an offline or staging setup, serve a docs build and point it there with `APARTE_DOCS_URL`. To embed the server in a host of your own, `createDocsMcpServer({ baseUrl })` returns the `McpServer`, and `DocsSource`, `parseIndex`, `splitPages` and `searchPages` give you the text without the protocol. # Asking the user a typed question > Pause a run and ask the user for typed input — a choice, a free-text answer, a small form — or for a decision (an approval, kind 'approval') — with requestUserInput and the built-in panel in the composer; replace the panel with your own presenter. Sometimes a tool cannot finish without something only the user knows. Which of these three files did you mean? Should I really delete the branch? What should the commit message say? **Elicitation** is the primitive for that: pause the run, render a typed input in the composer, and resolve with what the user chose. It is a generalisation of the `ask-user` plugin — there, the *kind* of question was baked into a dedicated tool; here it is carried by a schema, so one call covers all of them. The shape follows [MCP elicitation](https://modelcontextprotocol.io/) (a message plus a requested schema, answered with accept or decline), but the mechanism is transport-agnostic: a presenter registered per config instance, never window events. ## Asking for one thing [Section titled “Asking for one thing”](#asking-for-one-thing) `requestUserInput` is a plain function — no `AparteClient` needed, no DOM wiring. Call it from a tool handler and `await` the answer. ```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' | whatever "Other…" produced void env; } ``` Three fields cover most questions: | `schema.type` | Rendered as | `content` on accept | | ------------- | ------------------------------------------------ | ----------------------- | | `enum` | radios, or checkboxes with `multiple: true` | `string`, or `string[]` | | `boolean` | two choices, labelled `trueLabel` / `falseLabel` | `boolean` | | `string` | one line, or a textarea with `multiline: true` | `string` | An `enum` also offers a free-text **Other…** entry by default; pass `allowOther: false` to close it. A single choice asked on its own — an `enum` without `multiple` or a `default`, a `boolean` without a `default` — renders its options as **buttons, and the click is the answer**: one decision, one gesture. That is the host’s policy, and it has a switch: ```ts import { aparteGlobalConfig } from '@aparte/core'; aparteGlobalConfig.setElicitationOptions({ answerOnClick: false }); ``` With it off, the same question keeps its radios and the composer’s button — select, then send — so a person can change their mind before committing, and every question on the page answers the same way. A form of several questions always collects and submits, whatever the switch says. ## Asking for several things at once [Section titled “Asking for several things at once”](#asking-for-several-things-at-once) Wrap the fields in an `object` schema and you get a small form — one labelled input per property, and `content` comes back keyed the same way. ```ts import { requestUserInput } from '@aparte/core'; const answer = await requestUserInput({ message: 'Open a pull request?', schema: { type: 'object', properties: { title: { type: 'string', title: 'Title', placeholder: 'Fix the abort path' }, body: { type: 'string', title: 'Description', multiline: true, required: false }, draft: { type: 'boolean', title: 'Open as draft', default: true }, }, required: ['title'], }, }); if (answer.action === 'accept') { const { title, body, draft } = answer.content as { title: string; body?: string; draft: boolean }; void [title, body, draft]; } ``` ### Several questions are asked one at a time [Section titled “Several questions are asked one at a time”](#several-questions-are-asked-one-at-a-time) A form of two or more questions is presented **one question at a time**, with a chip per question above it. The chips are also how you go back: an answer you have already given is the thing you most want to revisit, and hunting for a “Back” button to do it is the frustrating half of every stepped form. Give each question a short `header` — two or three words — because that is what the chip holds. Without one the chip falls back to the question’s position, which is honest and never truncates a sentence into nonsense. ```ts import { requestUserInput } from '@aparte/core'; const answer = await requestUserInput({ message: '', // each question carries its own title schema: { type: 'object', properties: { engine: { type: 'enum', header: 'Engine', title: 'Which engine?', options: [{ value: 'chromium' }, { value: 'webkit' }] }, theme: { type: 'enum', header: 'Theme', title: 'Which theme?', options: [{ value: 'light' }, { value: 'dark' }] }, }, }, }); void answer; ``` The protocol does not change: the answer is still one object with every key, and the composer’s send button means *submit* throughout, enabled only once every required question has an answer. Moving between questions is the chips’ affordance — a click on a choice selects, a chip switches question, one button submits the lot — and an answered chip wears a check mark, so what is left is visible at a glance. If what you actually want is a **form** — several fields filled in one go, which is what structured data collection looks like — ask for it: ```ts aparteGlobalConfig.setElicitationOptions({ layout: 'stacked' }); ``` That was the only shape until now, inherited from MCP elicitation without being examined. MCP describes a form for collecting structured data; asking a person two different questions in the middle of a conversation is not that, and no product does it by stacking them in one box. The form case is real, so it stays — it was just never the right default. ## Two answers, and a rejection [Section titled “Two answers, and a rejection”](#two-answers-and-a-rejection) ```ts import { requestUserInput, AparteElicitationAbortError } from '@aparte/core'; try { const answer = await requestUserInput({ message: 'Delete the branch?', schema: { type: 'boolean' } }); if (answer.action === 'accept') { // The user answered — `answer.content` is the value. } else { // `decline`: the user said no to being asked at all. } } catch (err) { if (err instanceof AparteElicitationAbortError) { // No answer: the turn was stopped, a signal fired, the question was taken away, // or nothing was mounted to ask it. `err.reason` is 'aborted' or 'no-presenter'. } } ``` Declining is an answer. Ending without one is not, and that is why it **rejects** rather than resolving a third `action`. It used to resolve `{ action: 'cancel' }`, and a value is easy to handle as though it were an answer — which is exactly what happened one level up: the tool-approval gate read that `cancel` as a refusal and told the model the user had rejected a tool they had merely stopped. A rejection cannot be mistaken for a decision by a caller that forgot a branch. `name` is `'AbortError'`, so a handler that already tests `err.name === 'AbortError'` needs no change. One consequence worth knowing: a request you start and never `await` will surface an **unhandled rejection** when it ends without an answer, because that is what an ignored failed promise is. Attach a `.catch()` if you genuinely do not care about the outcome — the noise is the point, and it is the price of an ending that cannot be mistaken for a decision. ## Asking for a decision instead of a value [Section titled “Asking for a decision instead of a value”](#asking-for-a-decision-instead-of-a-value) A `kind: 'approval'` request asks the user to pick one of the options **you** supply, and resolves with which one — plus anything they typed instead. It is the same mechanism, the same panel slot and the same queue; only what is on screen differs, because a decision is not a value and a schema there would be a form with nothing in it. ```ts import { requestUserInput } from '@aparte/core'; const answer = await requestUserInput({ kind: 'approval', message: 'Run delete_files?', // What is being approved, under the question. Rendered as text, never as markup — // the panel is the surface where the user clicks, so the arguments belong on it. details: JSON.stringify({ paths: ['src/legacy/old-client.ts'] }, null, 2), options: [ { value: 'allow', label: 'Approve', tone: 'affirm' }, // Two options may share a `value` and differ only in reach: this is what // "Yes" and "Yes, and always" are. YOU write the label, because only you can // honour it — core has nowhere to remember a grant. Say what the reach IS in // `description`: it is drawn under the label, so a user sees the difference // between "this command" and "any git command" before clicking. { value: 'allow', label: 'Approve, and always for this command', description: 'git status', tone: 'affirm' }, { value: 'allow', 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 }; // `instruction` is the free-text arm — "no, do this instead". It is a refusal that // carries words, which is only useful because a refusal hands the model a turn. void [option, instruction]; } ``` An option is answered by its own click — approving is the most frequent act in the feature, and spending two gestures on it to reuse the composer’s send button would be the tail wagging the dog. Written text goes through that button instead, which is exactly the act it already means. The built-in tool-approval gate is one caller of this, with two options and a question built from the tool’s name. Anything richer — a scope option, a third choice — is the host’s to supply, and `buildApprovalPanel` is exported for a presenter of your own. ## Wiring it to a tool’s lifetime [Section titled “Wiring it to a tool’s lifetime”](#wiring-it-to-a-tools-lifetime) Two options matter when the request comes from a tool handler: * **`signal`** — pass the handler’s own `AbortSignal`. When the turn is stopped or the per-tool timeout fires, the panel is torn down and the promise rejects with an `AbortError`, instead of leaving an orphan form in the composer. * **`target`** — any element inside the chat that should present the request. It resolves *which* instance answers (its config, its composer). Omit it for a single-chat page; pass it when several chats share a page. ```ts import { requestUserInput } from '@aparte/core'; import type { AparteTool, AparteToolHandler } from '@aparte/core'; export const deleteBranchTool: AparteTool = { name: 'delete_branch', description: 'Delete a git branch after confirming with the user.', inputSchema: { type: 'object', properties: { branch: { type: 'string' } } }, }; export const deleteBranchHandler: AparteToolHandler = async (call, signal) => { const { branch } = call.input as { branch: string }; const answer = await requestUserInput({ message: `Delete \`${branch}\`? This cannot be undone.`, schema: { type: 'boolean', trueLabel: 'Delete it', falseLabel: 'Keep it' }, signal, }); 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}.` }; }; ``` Returning a *result* rather than throwing on a refusal matters: the model needs to read what happened so it can say so, and a thrown error would surface as a failed turn. ## Who renders it [Section titled “Who renders it”](#who-renders-it) [``](/components/input/aparte-elicitation/) is the default presenter, and it **has to be in your markup**. It registers itself the moment it connects — but nothing creates it for you, so put it inside your [``](/components/conversation/aparte-chat/): ```html ``` It then mounts its panel inside the composer of the resolved chat, so the question appears where the user is already typing. Leave it out and nothing can ask With no presenter registered, `requestUserInput()` rejects with `AparteElicitationAbortError` and `reason: 'no-presenter'`, and core warns once on the console. It used to resolve `{ action: 'cancel' }`, which meant your tool reported a refusal the user was never asked for and the model answered as though they had declined. A question nobody can render still cannot be waited on — but failing is not the same as answering, and only one of the two can be told apart from a real refusal. To render it yourself, register a presenter on the config: ```ts import { aparteGlobalConfig } from '@aparte/core'; import type { AparteElicitationRequest, AparteElicitationResult } from '@aparte/core'; aparteGlobalConfig.setElicitationPresenter( async (request: AparteElicitationRequest): Promise => { // Your modal, your form, your framework. Resolve with what the user did. void request; return { action: 'decline' }; }, ); ``` `buildElicitationPanel` is also exported if you want the built-in panel’s DOM without its placement. It returns a `BuiltElicitationPanel` — the element plus everything a presenter needs to drive it: | Member | What it is for | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `el` | the panel’s root, for you to place | | `dismiss` | the corner control that declines the **whole** request, not the current question | | `getContent()` | the current response, shaped to the schema | | `isComplete()` | every required field has a usable value | | `focus()` | focus the first input, after you mount it | | `mode()` | what the composer’s one button means here: `'submit'`, or `'none'` when the panel has nothing for it to do | | `onSettle(cb)` | the answer arrived without the button — a single-choice question settles on the click, and this is the only path by which that reaches you | | `canProceed()` | whether that button is enabled | | `proceed()` | kept for the contract; a form does nothing here — submitting is yours | | `relabel()` | re-apply the locale’s strings in place, without rebuilding a half-filled form | The last four are what make “one question at a time” work: the panel has no Next button of its own, and the composer’s button is no “Next” either — the chips are the navigation, the button submits, and `canProceed()` is simply “every required question answered”. There is **no promise**: settling is the presenter’s job, which is why the built-in one wires `getContent()` to the composer’s send button and `canProceed()` to whether that button is enabled. ## Replacing one field, not the whole panel [Section titled “Replacing one field, not the whole panel”](#replacing-one-field-not-the-whole-panel) Writing a presenter means owning placement, accept/decline and the rejection, the send-button gating, focus and the teardown when a turn is stopped. Most of the time what you want is a different-looking *choice*, so there is a hook for exactly that: ```ts import { aparteGlobalConfig } from '@aparte/core'; aparteGlobalConfig.setElicitationFieldRenderer((field, ctx) => { if (field.type !== 'enum') return null; // the built-in renders the rest 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; ctx.notifyChange(); // re-gates the send button }); el.appendChild(chip); } return { el, getValue: () => picked, isComplete: () => picked !== '' }; }); ``` The types, if you are pulling the callback out into its own function: ```ts import type { AparteElicitationFieldRenderer, AparteElicitationFieldContext, AparteElicitationFieldControl, } from '@aparte/core'; const renderChoice: AparteElicitationFieldRenderer = ( field, ctx: AparteElicitationFieldContext, ): AparteElicitationFieldControl | null => { if (field.type !== 'enum') return null; const el = document.createElement('div'); let picked = ''; el.addEventListener('click', () => { picked = field.options[0]?.value ?? ''; ctx.notifyChange(); }); return { el, getValue: () => picked, isComplete: () => picked !== '' }; }; ``` Returning `null` for a field lets the built-in render it, which is what makes overriding a single kind practical. `ctx.notifyChange()` is not optional: the panel re-reads `isComplete()` on every change, so a field that never notifies is a field whose answer can never be submitted. `ctx.key` is the form key in a multi-question schema, so one renderer can vary per question. This hook returns a control rather than `string | HTMLElement` like the render hooks elsewhere in this library, and deliberately: a field has to hand back a **value**. A hook that must also read the user’s input is a control, not a decoration — the alternative is the panel scraping your markup for inputs by convention, a contract that breaks the first time someone styles it differently. ## Who offers “Other…” [Section titled “Who offers “Other…””](#who-offers-other) A choice offers a free-text escape by default. That is the **host’s** decision, not the model’s: ```ts aparteGlobalConfig.setElicitationOptions({ allowOther: false }); ``` `@aparte/plugin-ask-user` used to expose `allow_other` in the schema it hands the model, which meant the model decided your UX — and a small model fills a field it does not understand: one sent two questions with `allow_other: true` and no options at all, so the panel rendered a radio list whose only entry was “Other…”. A field of a schema you build yourself can still set `allowOther`, and it wins: that is your app talking. ## Elicitation or the ask-user plugin? [Section titled “Elicitation or the ask-user plugin?”](#elicitation-or-the-ask-user-plugin) `@aparte/plugin-ask-user` is still there and still useful: it gives the *model* a tool it can call to ask a question, with a rendered receipt of what was asked and answered. Reach for it when the model should decide to ask. Reach for elicitation when **your code** decides to ask — a confirmation, a disambiguation, a missing parameter — which is most of the time. # The agent engine > @aparte/engine is the headless, zero-dependency agent loop — runStreamAgent, the loop AparteClient runs, plus the seam a host uses to wrap or replace it. `@aparte/core` runs a full **agent loop** when you drive a chat with `AparteClient`: it streams the model, splits the reply into segments (text, thinking, tool calls), runs the tool-calling loop, and reports usage. **That loop is this package’s** — core depends on it. `@aparte/engine` is that loop as a **headless, framework-agnostic** package: zero runtime dependencies, no DOM, runs in the browser or Node. Its headline export, **`runStreamAgent`**, is the loop `AparteClient` runs — and the same function a backend runs server-side, so the two behave identically by construction rather than by parity. It is deliberately **just the loop core drives**. Opt-in *tools* (ask-user, RAG, skills, code execution) belong in `plugins/*`; product behaviour (memory, intent orchestration) and the not-yet-wired text agent loop live elsewhere. None of that ships here. ## Install [Section titled “Install”](#install) ```bash npm install @aparte/engine ``` `@aparte/core` is **not a dependency** of the engine — it is the other way round, core depends on it — and `runStreamAgent` and the parsers need nothing from it, so you can install `@aparte/engine` alone. If you wire it into core’s client (below) you already have `@aparte/core`; otherwise `npm install @aparte/core @aparte/engine`. ESM-only (like the rest of `@aparte/*`); CJS consumers use `await import()`. ## What’s in it [Section titled “What’s in it”](#whats-in-it) | Area | Exports | Status | | -------------------------- | ---------------------------------- | ---------------------- | | **Structured-stream loop** | `runStreamAgent`, `StreamRunEvent` | Ready — the seam below | Everything is a plain function or class — no globals, no side effects (`sideEffects: false`), fully tree-shakeable, so you pull in only what you use. ## The context gauge, and where compaction lives [Section titled “The context gauge, and where compaction lives”](#the-context-gauge-and-where-compaction-lives) `` in core is a gauge of the model’s window: it reads the usage each turn reports and the window the current model declares (a provider’s `/models` fetch fills it in, or set the `window` attribute), turns `warn` and `danger` at 75 % and 90 % (the `warn` / `danger` attributes), fires `aparte-context-threshold` when the level changes — and with `auto-compact` dispatches `aparte-compact` for its chat on reaching danger, once, until the level drops. Before the first turn, or without a window, it shows nothing. `variant="ring"` draws it as a ring with the percentage, for a toolbar with no width to give. What answers that command is not the engine: the loop reports usage and lets the caller decide, and compaction — the budget, the selector, the summariser — is [`@aparte/plugin-compaction`](/plugins/compaction/). The budget and selector lived here until 0.16.0; they moved with their one consumer, same names, same signatures: ```ts import { setupCompaction } from '@aparte/plugin-compaction'; setupCompaction(); // the gauge's auto-compact is answered; the recent turns stay verbatim ``` ```html ``` The gauge, the selector and the model speak the same numbers: the window is the model’s, the budget is the plugin’s, and the reading is what the provider reported — nothing is estimated twice. ## The `streamRunner` seam [Section titled “The streamRunner seam”](#the-streamrunner-seam) `AparteClient` runs `runStreamAgent` by itself — there is nothing to inject to get the engine’s loop. `streamRunner` is the seam for the two things that go beyond that: wrapping the loop’s options (the prefix-cache case below) or replacing the loop with one of your own that emits the same events, which core renders exactly the same way — same messages, same events, same DOM output: ```ts import { AparteClient } from '@aparte/core'; import { runStreamAgent } from '@aparte/engine'; const client = new AparteClient({ // …your transport / config… streamRunner: (opts) => runStreamAgent({ ...opts, maxTurns: 4 }), // the engine's loop, your options }); ``` ## Owning the history yourself (prefix-cache hosts) [Section titled “Owning the history yourself (prefix-cache hosts)”](#owning-the-history-yourself-prefix-cache-hosts) By default the loop holds the message list and re-sends it each turn, enriched with the `tool_call` / `tool_result` turns it produced. That is what a stateless message API wants, and the opposite of what a **prefix cache** wants — llama.cpp slots, vLLM — where turn N+1 has to *extend* turn N byte for byte or the cache is thrown away. Such a host owns its own transcript. Half of that already worked: `transportCall` receives the request the loop built and may ignore its `messages` entirely. The other half is **`onHistoryAppend`** — it reports each turn the loop appends, in order, before the call that would carry it, so you don’t reimplement the loop’s tool bookkeeping: ```ts const log = new PromptLog(); // your append-only transcript await runStreamAgent({ // …messageId, emitter, signal, toolLookup baseRequest: { messages: [], modelId: 'my-model' }, // your transport may ignore both onHistoryAppend: (turn) => log.append(turn), // tool_call · tool_result · phase reply transportCall: () => myCompletion(log.render()), // your own bytes, extended not rebuilt }); ``` Through the `streamRunner` seam it needs no change in core — augment the options at injection: ```ts new AparteClient({ streamRunner: (opts) => runStreamAgent({ ...opts, onHistoryAppend }) }); ``` Serializing a tool inventory, its calls and their results into a raw prompt is still yours to write: the providers that do it target message-based APIs. ## Pinned call sequences [Section titled “Pinned call sequences”](#pinned-call-sequences) The loop’s behaviour isn’t “meant” to be stable — it’s recorded. Core’s **`stream-parity`** suite was born in this package as a parity test between core’s former inline loop and `runStreamAgent` through the real `createStreamAdapter`. Its scenarios in nine groups — the happy paths (plain text, thinking, human-in-the-loop approve and reject, a streamed tagged block, forced tool calls), and then the ones that matter more: the paths that STOP (a provider error, a tool with no handler, both turn ceilings, a tool that never resolves), walking away from a live stream mid-token, a withheld prefix never reaching `content`, events and tags neither loop used to handle, a non-streaming response, and three block-framing edge cases — were snapshotted while both loops ran and were equal, so the snapshots are the inline loop’s behaviour, pinned. The suite now lives in core, where it also holds `AparteClient`’s own wiring to a direct engine run; a change that alters a sequence fails there, with the diff. # AI chat in Web Components — getting started > Install @aparte/core and render your first streaming chat — vanilla web components, no framework, no backend required. `@aparte/core` is a set of **framework-agnostic web components** for AI chat. You drop `` elements onto a page, stream tokens into them, and style everything with CSS variables — no framework, and zero third-party dependencies. By the end of this page you’ll have a working chat that streams a reply, running entirely in the browser — any browser from 2023 on, or Node 18+ on the server side; the exact floors are in the [support matrix](/reference/support/). Alpha Every `@aparte/*` package is a plain `0.x` and the API can still change before the first stable cut. A rename lands as a rename rather than behind a deprecated alias, so a minor can ask you to change a line — pin an exact version if that matters to you, and read [where it is in its life](/why/#where-it-is-in-its-life) before you commit to it. ## Install [Section titled “Install”](#install) ```bash npm install @aparte/core ``` ## Register the components [Section titled “Register the components”](#register-the-components) Import the package once (it registers the `` custom elements), pull in the stylesheet, and call `registerDefaultRenderers()`. ```ts import '@aparte/core'; // registers the custom elements import '@aparte/core/styles.css'; // theme variables + component styles import { registerDefaultRenderers } from '@aparte/core'; registerDefaultRenderers(); // turns raw text/markdown into rendered bubbles ``` ## Add the markup [Section titled “Add the markup”](#add-the-markup) **[``](/components/conversation/aparte-chat/)** is the container. It lays out a message viewport and a composer as a flex column, and — with `center-empty` — keeps the composer centered as a welcome state until the first message, then slides to the normal layout. Size it (a height, or let it fill a parent) and you’re done. Left empty, it fills in a default composition — a viewport, and a composer with an input and a send button. Add `attachments` for the file picker too (opt-in, see [Attachments](/guides/attachments/)): ```html ``` Need full control — a custom composer, extra buttons? Put your own primitives **inside** the same element. It still lays them out and runs `center-empty`, so you keep the behaviour without a hand-written container: ```html
``` `center-empty` is opt-in — drop it and the composer sits at the bottom from the start. The `.aparte-composer-shell` / `.aparte-composer-row` helpers give the bordered input-with-a-send-button look; the composer itself is headless. Add an [``](/components/input/aparte-composer/) inside that shell and you get the bottom strip a model selector or a mode picker belongs in — the wrappers expose it as one `toolbar` slot. See [The composer toolbar](/guides/customization/#the-composer-toolbar). ## Make it stream [Section titled “Make it stream”](#make-it-stream) The composer fires an **`aparte-send`** event when the user submits — it bubbles, so you can listen on ``. Reach the message list via `chat.viewport`, add the user’s message, then stream an assistant reply in. Three viewport methods are all you need: * `appendMessage({ id, role, content, timestamp })` — create a bubble * `appendToken(id, chunk)` — stream text into it, token by token * `completeMessage(id)` — mark it done (stops the streaming caret) ```ts // `!` because the import above has already upgraded the element, so both lookups // succeed — but TypeScript cannot know that, and neither can a reader who moves // this code above the markup. If in doubt, use `?.` instead of asserting. const chat = document.querySelector('aparte-chat')!; const viewport = chat.viewport!; let n = 0; // Stream a string into a fresh assistant bubble, a few characters at a time. function streamReply(text: string) { const id = 'a' + ++n; viewport.appendMessage({ id, role: 'assistant', content: '', timestamp: Date.now() }); const tokens = text.split(/(\s+)/); // keep the whitespace as its own tokens let i = 0; const timer = setInterval(() => { if (i >= tokens.length) { clearInterval(timer); viewport.completeMessage(id); return; } // `?? ''` because indexing an array yields `string | undefined` under // `noUncheckedIndexedAccess`, which this project — and this snippet — compile // with. The loop above already guarantees the index is in range; the compiler // cannot see that, and neither can a reader who moves the code. viewport.appendToken(id, tokens[i++] ?? ''); }, 40); } chat.addEventListener('aparte-send', (e) => { const text = e.detail.content; // 1. Echo the user's message into the conversation. viewport.appendMessage({ id: 'u' + ++n, role: 'user', content: text, timestamp: Date.now() }); // 2. Reply. Here we fake it; next you'll wire a real model. streamReply(`You said: “${text}”. Now wire a transport to get a real answer.`); }); ``` *(Using the primitives instead? Same code — just `document.querySelector('aparte-chat-viewport')` for the viewport and listen on the composer.)* That’s a complete, working chat — no backend, no keys. Type a message and watch the reply stream in. ## Wire a real model [Section titled “Wire a real model”](#wire-a-real-model) Faking the reply is fine for a first look. To talk to a real LLM, you configure core with two things and let **`AparteClient`** drive the streaming loop for you: 1. **A provider** — the wire-format adapter for your model (an opt-in `@aparte/provider-*` package), registered with `aparteGlobalConfig.registerAIProvider(…)`. 2. **A transport** — *where* the request goes and *how* the key is handled: * **`AparteDirectTransport`** — the browser talks to the provider directly (bring-your-own-key or a local model): `aparteGlobalConfig.setTransport(new AparteDirectTransport({ byok: true }))`. * **`AparteBackendTransport`** — the browser calls *your* endpoint, and the key stays server-side. Once a provider and transport are set, **construct an `AparteClient` and call `.start()`** — it then listens for `aparte-send` (and `aparte-retry`, `aparte-edit`) globally and streams the assistant’s typed segments into your bubbles for you (no manual `appendToken`): ```ts new AparteClient().start(); // .start() attaches the listeners — without it nothing streams ``` The client owns the **whole** exchange: it echoes your **user** message too, files included — so once it runs, drop the `aparte-send` handler from [above](#make-it-stream) (that handler is the bring-your-own-loop path’s job). A host that keeps its own transcript passes `echoUserMessage: false`. Now that something can honour them, switch the retry and edit buttons on — core ships them off precisely because without a client they would do nothing: ```ts aparteGlobalConfig.setBubbleActions({ retry: true, edit: true }); ``` The full list of what ships enabled and why is in [Customization](/guides/customization/#what-ships-enabled). Provider adapters ship as opt-in `@aparte/provider-*` packages — see the **[Providers](/providers/)** section for the OpenAI-compatible adapter (OpenAI, Mistral, OpenRouter, Groq, LM Studio, Ollama…), the Vercel AI SDK bridge (Anthropic, Google, 25+ vendors), and the in-browser Transformers.js provider. You can also register any object implementing the `AparteAIProvider` interface yourself. ## Next steps [Section titled “Next steps”](#next-steps) * **[Theming](/guides/theming)** — restyle everything through CSS variables, no forking. * **[Customization](/guides/customization)** — icons, render hooks, action registries. * **[Conversations & branching](/guides/conversations-branching)** — retry / edit, branches, persistence. * **[Layout](/guides/layout)** and **[the app shell](/guides/app-shell)** — the five shapes a chat takes, and the sidebar, header and grid of a whole page. * **[Providers](/providers/)** — connect a real model: OpenAI-compatible, the AI SDK bridge, or in-browser Transformers.js. * **[Local models](/guides/local-models)** — the chat on a model running on your machine: Ollama, LM Studio, llama.cpp. * **[The agent engine](/guides/engine)** — the headless `runStreamAgent` loop and the `streamRunner` seam. # Chat layouts: resizable split, full-width feed, side panel > How to size an aparté chat — a fixed box, a full page like ChatGPT with the scrollbar at the window's edge, a full-width feed, a chat beside a preview pane — and why the transcript, not the page, is what scrolls. An `` is a flex column — the transcript above, the composer below — and the **transcript is the element that scrolls**. That one fact decides every layout on this page: the chat needs a height to scroll inside, and the scrollbar sits wherever the chat’s right edge is. ## Give it a height [Section titled “Give it a height”](#give-it-a-height) `` is `height: 100%` by default, so it fills whatever you size. With nothing sized above it, it grows with its content and never scrolls — the most common first-minute question. Three ways to size it: ```html ``` ```css /* Inside a flex column with a header — the shape every example app uses. */ .app { display: flex; flex-direction: column; height: 100dvh; } .app aparte-chat { flex: 1; min-height: 0; /* lets the flex item shrink below its content, so it scrolls */ height: auto; /* the default 100% ignores the header and overflows the page */ } ``` ```css /* The whole page, no header. */ html, body { height: 100%; margin: 0; } aparte-chat { height: 100dvh; } ``` `100dvh` rather than `100vh`: on a phone the dynamic unit follows the browser chrome as it hides and shows, so the composer stays on screen. ## Fill the page — the scrollbar at the window’s edge [Section titled “Fill the page — the scrollbar at the window’s edge”](#fill-the-page--the-scrollbar-at-the-windows-edge) ChatGPT and Claude look like the *page* scrolls: the scrollbar runs down the window’s right edge and the messages sit in a centred column. They are not scrolling the page — their transcript is a full-width element, and the column is a `max-width` on the content inside it. aparté already does the second half: bubbles and the composer share one column — centred at `--aparte-message-max-width` (800px) on a container wider than that, and inset by the same `--aparte-viewport-padding` gutter on one narrower. So the recipe is only the first half — **do not box the chat**: ```css /* Not this — the chat is a 820px box, so its scrollbar sits 200px in from the edge of a wide screen. */ .app { max-width: 820px; margin: 0 auto; } /* This — the chat spans the page, the content centres itself, the scrollbar lands on the window's edge. */ .app { display: flex; flex-direction: column; height: 100dvh; } :root { --aparte-message-max-width: 48rem; } /* the column, if 800px is not yours */ ``` Measured on the vanilla example at 1200px wide: the scroll surface’s right edge moved from 998px to 1200px and the bubbles stayed at 200–1000px. Nothing about scrolling changed — the transcript still owns it, so following a streaming reply, the scroll-to- bottom button and the “you scrolled up, we stop following” rule all keep working. The scroll surface reserves its scrollbar gutter on both edges (`scrollbar-gutter: stable both-edges`), so the column does not shift by half a scrollbar the moment the first reply overflows. Width 375 768 Full [Open in a tab](/preview/demo/layout-reading-column/) [The reading column: an unboxed chat, the column from a token](/preview/demo/layout-reading-column/) The column is a token on the content, not a box around the chat — so the transcript still spans the frame and the scrollbar stays on its edge. ```html
``` ## The composer over the transcript — the scrollbar runs the full height [Section titled “The composer over the transcript — the scrollbar runs the full height”](#the-composer-over-the-transcript--the-scrollbar-runs-the-full-height) The recipe above puts the scrollbar on the window’s edge; its **height** still stops at the composer’s top, because the composer is a row below the transcript. ChatGPT and Claude do the other half too: their scroll surface owns the whole column and the composer **floats over it**, so the bar runs edge to edge vertically. That half is one attribute: ```html ``` ```jsx // React — the same switch on every wrapper (Vue, Svelte, Angular alike). ``` The viewport leaves the flow and spans the shell; the composer — and whatever else shares the bottom, an elicitation panel, an `above-composer` row — keeps flowing, bottom-anchored, painted over it. The viewport measures that stack and publishes `--aparte-bottom-inset`, and three readers keep everything clear of it: the transcript’s own bottom clearance, the spacer that tops your last message, and the scroll-to-bottom button, which floats just above the composer instead of behind it. When the draft grows a few lines under a reader pinned at the bottom, the inset is re-measured and the reader re-anchored in the same pass — the view never jumps. Width 375 768 Full [Open in a tab](/preview/demo/layout-overlay/) [overlay-composer: the scroll surface spans the column, the composer floats over it](/preview/demo/layout-overlay/) One attribute: the scrollbar runs past the composer to the chat's bottom edge, and the content clears the floating stack. ```html
``` Deliberately **opt-in, never the default**: in an embedded 320px box a composer laid over the transcript eats reading space; overlay earns its keep on a chat that owns its column. Set it in the initial markup — it is read when the viewport mounts. Two edges of the same recipe, for hosts that go further. A header laid over the top works with one rule of your own, since nothing scrolls under a `padding-top`: ```css [overlay-composer] .aparte-viewport-container { padding-top: 3.5rem; } ``` And a composer of your **own**, floated without the attribute, can write the variable by hand — the readers are unconditional (`0px` unset): ```css aparte-chat-viewport { --aparte-bottom-inset: 96px; } ``` ## The five layouts, one element [Section titled “The five layouts, one element”](#the-five-layouts-one-element) Every chat product on the market is one of five shapes, and each is a way of sizing the host around the same `` — no attribute, no second component. The column width is the one token that changes between them. | Layout | Who | The recipe | | ------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Reading column** | ChatGPT, Claude, Gemini, Perplexity | the section above: an unboxed chat, `--aparte-message-max-width` for the column | | **Full-width feed** | Slack, Discord, a “wide mode” toggle | `--aparte-message-max-width: none` | | **Builder split** | Lovable, bolt, v0, Canvas, Artifacts | ``: the chat in one pane, yours in the other, a seam you can drag between them | | **Side panel** | Cursor, Copilot Chat, a browser sidebar | the host gives the width; `--aparte-message-max-width: none`, `height: 100%` | | **Floating widget** | Intercom, Crisp | a launcher and a fixed card — the one shape that needs an element of its own, and it is not this one: a launcher, a fixed card, a full-screen overlay under a breakpoint and a focus trap. On the roadmap; ask for it and it moves up | ### Full-width feed [Section titled “Full-width feed”](#full-width-feed) Lift the column and bubbles run edge to edge, with only the transcript’s own padding on each side. Set the token on the chat rather than on `:root` to keep it to one instance. Width 375 768 Full [Open in a tab](/preview/demo/layout-feed/) [The full-width feed: no reading column](/preview/demo/layout-feed/) No reading column: \`--aparte-message-max-width: none\` on this one chat. ```html
``` ### Builder split [Section titled “Builder split”](#builder-split) The chat in one pane, what it is building in the other, and a seam the reader can move. `` is that seam. Two panes, one handle, and a pane **contains** a chat — a chat never contains a split. Width 375 768 Full [Open in a tab](/preview/demo/layout-split/) [The builder split: a chat beside a pane, on a seam you can drag](/preview/demo/layout-split/) Drag the seam, or tab to it and use the arrows. The frame itself resizes too: its handle is at the bottom right and Full widens it to the page. ```html
``` The grid is the whole mechanism. `--aparte-split-position` is the primary pane’s size and `--aparte-split-min` / `--aparte-split-max` are CSS `clamp()` bounds, so the browser does the clamping and nothing in JavaScript parses a unit — px, `%`, `rem` and `ch` all work. The element adds only what CSS cannot: the drag, the arrow keys, the ARIA of an APG window splitter, and one pane at a time under a breakpoint. Used **without** the element, `.aparte-split` is still a split; it just does not move. `position` sizes the primary pane, `primary="end"` makes that the second one instead (the shape you want when the chat is the narrow strip on the right), `orientation="vertical"` stacks the panes and puts the seam between them, `collapsed` folds the primary pane to its minimum, and `disabled` keeps the seam drawn but inert — no drag, no keys, no tab stop. The `AparteSplit` class exposes `position`, `orientation`, `primary`, `collapsed`, `pane`, `disabled` and a read-only `stacked`, plus `collapse()`, `expand()`, `toggleCollapse()`, `showPane('start' | 'end')` and `reset()`. Keys on the seam: the arrows step 1%, Shift steps 10%, Home and End reach the extremes, Enter collapses and a second Enter restores the size it had, and a double-click resets it to the position it was connected with. **It stores nothing.** `position` goes in; one `aparte-split-resize` comes out when the position *settles* — a release, a key up, a double-click, a property set — and never during a drag, so a framework’s reconciler is not in the drag loop. Persistence is four lines and it is yours: ```html
your pane
``` **On a narrow screen, one pane.** Below `breakpoint` — a length, `48rem` by default, or `none` to never stack — the split shows a single pane and hides the seam along with its tab stop. Any control on the page carrying `data-aparte-split-pane` switches which one, with no script. The attribute picks the split first and the pane second: the value `start` or `end` shows that pane of the split the control sits inside — or, for a control outside every split, of the first `` on the page; an empty value toggles that same split; and any other value names a split’s `id` and toggles that one. So to reach a specific pane of a specific split, put the control inside it. Above the breakpoint the buttons change nothing on screen. The attribute still moves, so hide them with the same media query rather than leaving two controls that look dead: ```html
``` If your host already owns its breakpoints, set `breakpoint="none"` and put the state on the element yourself — or set `single` (with `pane="start"` or `pane="end"`) when the element should show one pane whatever the width, a preview with nothing to preview yet, say: ``. The CSS route is the same state for a host that owns its breakpoints: `.aparte-split--only-start` and `.aparte-split--only-end` carry rules byte-identical to the ones `data-stacked` selects, and the element reads them the same way — the seam loses its tab stop, and nothing measures the single track it is now showing. The same two classes are what stack a split that has no element at all: ```html
your pane
the chat
your pane
``` **Right to left.** The seam follows the writing direction on its own: a grid container in `rtl` already reverses its columns, every rule is written in logical properties, and the element reads the *computed* direction — so ArrowLeft and ArrowRight swap and the pointer delta is negated, while ArrowUp and ArrowDown never do. Nothing to configure, and a subtree flipped with `dir="rtl"` is enough; `document.dir` is never consulted. Below 520px of *chat* width the transcript switches to its narrow spacing on its own — a container query on the chat, not a media query on the page — so the narrow pane of a split and the phone share the same CSS. Every attribute, event and knob is on the [element page](/components/shell/aparte-split/); the recipe on its own, with its markup, is in [the UI kit](/kit/split/). #### With an artifact in the pane [Section titled “With an artifact in the pane”](#with-an-artifact-in-the-pane) The split’s reason to exist: the model returns a document, and the reader wants it beside the conversation rather than scrolled away inside it. [`@aparte/plugin-artifacts`](/plugins/artifacts/) renders the document as a Code / Preview card with Copy and Download, and the pane is where your app mounts the result. Width 375 768 Full [Open in a tab](/preview/demo/layout-artifact/) [The builder split with an artifact: the card in the chat, the result in the pane](/preview/demo/layout-artifact/) One setupArtifacts() call, and the card is the reply. The pane beside it is the same document, mounted — that part is your app's, not the library's. ```html
``` ### Side panel [Section titled “Side panel”](#side-panel) A docked strip in a host window — an editor, a browser sidebar — gives the chat its width; there is no room for a column inside it, so it is the feed recipe in a band: ```css .panel aparte-chat { height: 100%; --aparte-message-max-width: none; } ``` Width 375 768 Full [Open in a tab](/preview/demo/layout-side-panel/) [The side panel: a chat docked in a host window](/preview/demo/layout-side-panel/) A 20rem strip beside the file. Nothing sets the narrow spacing: below 520px of chat width a container query on the chat does it. ```html
export function createOrder(cart, customer) {
  const lines = cart.items.map(toLine);
  const total = lines.reduce((sum, line) => sum + line.amount, 0);
  return { id: nextId(), customer, lines, total };
}


function toLine(item) {
  return { sku: item.sku, qty: item.qty, amount: item.qty * item.price };
}
``` ## The empty state [Section titled “The empty state”](#the-empty-state) A conversation starts with nothing in it, and a composer pinned to the bottom of an empty box reads as a page that failed to load. `center-empty` on `` keeps the composer in the middle until the first message lands, then slides it down — the element watches its own viewport, so nothing of yours runs. While it is centred the chat carries `data-empty`, which is the hook for your own welcome copy. ```html ``` ```css /* Your greeting, above the centred composer, gone the moment a reply arrives. */ .welcome { display: none; text-align: center; } aparte-chat[data-empty] .welcome { display: block; } ``` Width 375 768 Full [Open in a tab](/preview/demo/layout-empty-state/) [The welcome state: a centred composer until the first message](/preview/demo/layout-empty-state/) Press Send one: the composer slides from the middle to the bottom, and data-empty comes off the chat. ```html

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

``` The attribute needs a viewport somewhere inside, which hand-written markup always has. The one path where it does nothing is `framework-managed` — React, Vue and Svelte own that subtree and ship their own centred layout. Full details on the [element page](/components/conversation/aparte-chat/). ## Where you are in a long thread [Section titled “Where you are in a long thread”](#where-you-are-in-a-long-thread) The scrollbar says how far down you are, not which question you are under. Put an `` inside the chat and it draws one tick per user turn on the transcript’s end edge, marks the one under the reader, and jumps back to any of them on a click — the minimap of a code editor reduced to its ticks: ```html ``` It reads the transcript and never owns it; the viewport keeps its scroll. A click fires a cancelable `aparte-scroll-rail-jump` (`AparteScrollRailJumpDetail`, `{ messageId }`) before the `scrollIntoView`, so a host that pages history in can load it first. `every` takes an `AparteScrollRailEvery` — `user` (the default) or `message`. The `AparteScrollRail` class exposes `jumpTo(messageId)` and `currentMessageId` for a control of your own — a “back to my question” button, say. It hides under a coarse pointer, where a two-pixel tick is no target, and renders nothing below two ticks. The [element page](/components/conversation/aparte-scroll-rail/) has the live demo and the knobs. ## A pane beside a sidebar [Section titled “A pane beside a sidebar”](#a-pane-beside-a-sidebar) The chat is one flex item like any other. Give the row a height and the chat `min-width: 0` so a long code line cannot widen the pane: ```css .workspace { display: flex; height: 100dvh; } .workspace nav { width: 260px; flex: none; } .workspace aparte-chat { flex: 1; min-width: 0; } ``` ## Several chats on one page [Section titled “Several chats on one page”](#several-chats-on-one-page) Each `` scrolls on its own, so two side by side or one above another need nothing more than a height each. Theming per instance is on the [theming guide](/guides/theming/#per-instance-themes). ## The knobs [Section titled “The knobs”](#the-knobs) | Token | Default | Moves | | ------------------------------------- | ---------------------------------- | -------------------------------------------------------------------------------------- | | `--aparte-message-max-width` | `800px` | the centred column — bubbles and the composer; `none` lifts it | | `--aparte-viewport-padding` | `--aparte-space-8` | the transcript’s inset from the chat’s edges | | `--aparte-chat-bottom-gap` | `--aparte-space-8` | the space under the composer | | `--aparte-scrollbar-width` | `6px` | the WebKit scrollbar (Firefox uses `scrollbar-width: thin`) | | `--aparte-scrollbar-thumb` / `-track` | `--aparte-neutral` / `transparent` | its colours | | `--aparte-split-position` | `38%` | the primary pane’s size — and the live value while the seam is being dragged | | `--aparte-split-min` | `20rem` | the primary pane’s floor. Any length or percentage; the browser clamps | | `--aparte-split-max` | `60%` | its ceiling | | `--aparte-split-handle-size` | `4px` | the visible seam | | `--aparte-split-hit-area` | `12px` | the invisible grab zone around it; it grows to the touch target under a coarse pointer | The complete list, with what reads each one, is the generated [CSS variables](/reference/css-variables/) reference. Two of these layouts are driven by attributes rather than by tokens, because what they set is a state and not a size: | Attribute | On | What it does | | -------------- | ---------------- | ------------------------------------------------------------------------------------------------- | | `center-empty` | `` | centres the composer until the first message, and carries `data-empty` while it does | | `position` | `` | the primary pane’s size, as a percentage. Reflected back on every settle | | `orientation` | `` | `horizontal` (side by side, the default) or `vertical` (stacked). It names the *container’s* axis | | `primary` | `` | `start` (default) or `end` — which pane `position` sizes | | `collapsed` | `` | the primary pane folded to its minimum; Enter on the seam toggles it | | `breakpoint` | `` | the width below which one pane is shown. A length (`48rem`), or `none` | | `pane` | `` | `start` (default) or `end` — which pane is shown while stacked | | `disabled` | `` | the seam stays drawn, and does nothing: no drag, no keys, no tab stop | # Chat UI for a local LLM (Ollama, LM Studio, llama.cpp) > Put a chat UI on a model running on your machine — Ollama, LM Studio or llama.cpp through their OpenAI-compatible endpoint, browser-direct, no key and no backend. A model on your own machine has an HTTP endpoint; aparté is the chat in front of it. This page is the shortest path for the three servers people run: **Ollama**, **LM Studio** and **llama.cpp**. All three speak the OpenAI `/chat/completions` format, so one adapter — [`@aparte/provider-openai-compat`](/providers/ai/openai-compat/) — covers them, and the browser calls the server directly: there is no key to hold and no backend to write. ```bash npm install @aparte/core @aparte/provider-openai-compat ``` ## A first reply, with Ollama [Section titled “A first reply, with Ollama”](#a-first-reply-with-ollama) Start Ollama with the browser allowed as an origin (the one setup step people miss — see [CORS on local BYOK](/guides/troubleshooting/#cors-on-local-byok-lm-studio--ollama)), pull a model, and wire the chat: ```bash OLLAMA_ORIGINS=* ollama serve ollama pull llama3.2 ``` ```ts import { aparteGlobalConfig, AparteClient, AparteDirectTransport, registerDefaultRenderers } from '@aparte/core'; import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat'; import '@aparte/core/styles.css'; registerDefaultRenderers(); aparteGlobalConfig.registerAIProvider(createOpenAICompatProvider(presets.OLLAMA)); // http://localhost:11434/v1 aparteGlobalConfig.setTransport(new AparteDirectTransport({ byok: true })); // the browser calls it, no key aparteGlobalConfig.setModelConfig({ defaultProvider: 'ollama', defaultModel: 'llama3.2' }); new AparteClient().start(); document.body.innerHTML = ''; ``` That is the whole setup. `presets.OLLAMA` is Ollama’s OpenAI-compatible `/v1` endpoint with `isLocal: true`, which relaxes the key requirement; `{ byok: true }` on the transport says the key — there is none here — is the user’s own, so the browser-key warning stays quiet. Streaming, markdown and code blocks (with the [plugins](/plugins/)), retry and edit, [branching](/guides/conversations-branching/): all of it works the same as against a cloud vendor, because the server speaks the same format. ## Pick the model from what the server has [Section titled “Pick the model from what the server has”](#pick-the-model-from-what-the-server-has) A local server knows which models it holds — `GET /v1/models` lists them, and the local presets fetch that list without a key. Rather than hardcoding `defaultModel`, let the user pick from the list with the [model selector](/plugins/model-selector/), and gate the composer until a model is chosen: ```ts import { aparteGlobalConfig } from '@aparte/core'; import '@aparte/plugin-model-selector'; aparteGlobalConfig.setRequireModelSelection(true); // the composer waits for a pick ``` ```html ``` `auto-select` takes the first model when nothing is persisted; `persist` remembers the choice across reloads. Where the toolbar goes is in [the plugin’s page](/plugins/model-selector/#where-to-put-it). ## LM Studio [Section titled “LM Studio”](#lm-studio) Same code, other preset — `presets.LMSTUDIO` points at `http://localhost:1234/v1`. Turn on **Enable CORS** in LM Studio’s server settings (Developer tab) first, or the browser will refuse the reply even though the server logs show it answered. ```ts import { aparteGlobalConfig, AparteDirectTransport } from '@aparte/core'; import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat'; aparteGlobalConfig.registerAIProvider(createOpenAICompatProvider(presets.LMSTUDIO)); aparteGlobalConfig.setTransport(new AparteDirectTransport({ byok: true })); ``` ## llama.cpp [Section titled “llama.cpp”](#llamacpp) `llama-server` exposes the same `/v1` surface, on port 8080 by default. There is no preset — an id and a base URL are all a preset is — so declare it inline: ```ts import { aparteGlobalConfig, AparteDirectTransport } from '@aparte/core'; import { createOpenAICompatProvider } from '@aparte/provider-openai-compat'; aparteGlobalConfig.registerAIProvider(createOpenAICompatProvider({ id: 'llamacpp', name: 'llama.cpp', baseURL: 'http://localhost:8080/v1', isLocal: true, })); aparteGlobalConfig.setTransport(new AparteDirectTransport({ byok: true })); ``` `isLocal` makes the keyless `/v1/models` fetch run, and `llama-server` lists the model it was started with — so the selector above works here too. A server that cannot list its models takes a static list instead: `models: [{ id: 'default', name: 'Loaded model' }]` in the same options, and the id is what goes into the request’s `model` field. ## Two things a local model changes [Section titled “Two things a local model changes”](#two-things-a-local-model-changes) **Tools are the model’s call.** aparté sends the tools you register with every request, because the wire format carries them; whether the model *uses* them depends on the model. The [tool guide](/guides/tools/) — approval gate included — works unchanged with a model that supports function calling, and does nothing visible with one that does not. **The context window is yours to declare.** A cloud vendor’s model list carries the context length; a local server’s usually does not. Give the model a `contextWindow` in a static list and the [context gauge](/components/conversation/aparte-context/) can show how full the conversation is: ```ts import { aparteGlobalConfig } from '@aparte/core'; import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat'; aparteGlobalConfig.registerAIProvider(createOpenAICompatProvider({ ...presets.OLLAMA, models: [{ id: 'llama3.2', name: 'Llama 3.2', contextWindow: 8192 }], })); ``` ## Other shapes of “local” [Section titled “Other shapes of “local””](#other-shapes-of-local) * **The key stays on a server you own** — a model behind your own `/api/chat`, local or not: [the backend transport](/guides/backend-transport/). Same UI, other transport. * **No server at all** — the model runs *inside the page*, through Transformers.js: [the transformers provider](/providers/ai/transformers/). * **A prefix cache** (llama.cpp slots, vLLM) that needs turn N+1 to extend turn N byte for byte: the engine’s [`onHistoryAppend`](/guides/engine/) lets the caller own the history. # Localization > Translate aparté's built-in UI strings — English ships in core, and @aparte/locale-fr adds French. Pass any AparteLocale to switch. aparté’s built-in UI strings — the composer placeholder, the *Copy* / *Retry* buttons, the *thinking…* label, and so on — are translatable. English ships **inside core** as `APARTE_DEFAULT_LOCALE`, so an untranslated app is already in English with nothing to install. ## Switching language [Section titled “Switching language”](#switching-language) Pass an `AparteLocale` to `aparteGlobalConfig.setLocale`. French is available as a package: ```bash npm install @aparte/locale-fr ``` ```ts import { aparteGlobalConfig } from '@aparte/core'; import { fr } from '@aparte/locale-fr'; aparteGlobalConfig.setLocale(fr); ``` Set it once at startup, before the chat mounts. `aparteGlobalConfig.getLocale()` returns the active locale, and `APARTE_DEFAULT_LOCALE` (exported from `@aparte/core`) is the English baseline. A locale switch is live: mounted components re-render immediately. To go back to English — say, in a language toggle — call `aparteGlobalConfig.resetLocale()`: ```ts function setLanguage(lang: 'fr' | 'en') { if (lang === 'fr') aparteGlobalConfig.setLocale(fr); else aparteGlobalConfig.resetLocale(); } ``` ## Writing your own locale [Section titled “Writing your own locale”](#writing-your-own-locale) An `AparteLocale` is a flat record of string keys. The simplest custom locale starts from the English default and overrides what you need: ```ts import { aparteGlobalConfig, APARTE_DEFAULT_LOCALE, type AparteLocale } from '@aparte/core'; const es: AparteLocale = { ...APARTE_DEFAULT_LOCALE, inputPlaceholder: 'Escribe un mensaje...', sendButton: 'Enviar', copy: 'Copiar', // …override the rest }; aparteGlobalConfig.setLocale(es); ``` Spreading `APARTE_DEFAULT_LOCALE` guarantees every key is present even if aparté adds new strings in a later release — your translation overrides what it covers and inherits English for the rest. The `AparteLocale` type keeps the keys honest at compile time. ## Localising a plugin [Section titled “Localising a plugin”](#localising-a-plugin) A plugin’s strings live in the **same flat object** as core’s: `setLocale` and `extendLocale` accept keys of your own alongside core’s, and `getLocale()` hands them back. Read it off `getLocale()`, not through `t()`. `AparteLocale` is the **closed** list of the strings core itself renders, and `t(key)` is deliberately narrow against it — that is what makes a misspelt core key a compile error instead of an empty label nobody notices. Your key is not on that list, so it is read directly and defaulted at the call site, which is where its English belongs anyway: ```ts import { aparteGlobalConfig, subscribeConfigChange, APARTE_DEFAULT_LOCALE } from '@aparte/core'; // Your plugin owns both halves: the key AND its English. An absent key reads // `undefined`, so the plugin's own default belongs at the call site. const label = (): string => aparteGlobalConfig.getLocale().myPluginRetry || 'Try again'; // Relabel on a live language switch — the same seam core's own components use. // It returns an unsubscribe; call it when your element disconnects. function mount(el: HTMLElement): () => void { const paint = (): void => { el.textContent = label(); }; paint(); return subscribeConfigChange(el, paint); } aparteGlobalConfig.setLocale({ ...APARTE_DEFAULT_LOCALE, myPluginRetry: 'Réessayer' }); ``` Two things that are not obvious, and that a plugin author will otherwise meet as a bug report: **`setLocale()` replaces, it does not merge.** So handing core a locale package drops your plugin’s keys — `setLocale(fr)` leaves `myPluginRetry` undefined, because `@aparte/locale-fr` has never heard of it. The consumer merges: ```ts import { aparteGlobalConfig } from '@aparte/core'; import { fr } from '@aparte/locale-fr'; aparteGlobalConfig.setLocale({ ...fr, myPluginRetry: 'Réessayer' }); ``` **A segment renderer gets this for free.** If your plugin renders a segment type, implement `relabel(element, segment)` instead of subscribing by hand: core calls it on every config change, and the rule is attributes and text only — no child node added or removed — so a mounted preview keeps running and an expanded reasoning block stays expanded. See [Customization](/guides/customization). One thing worth *not* translating: strings the **model** reads. `@aparte/plugin-ask-user` keeps its JSON-schema descriptions and its decline note in English on purpose — those are wire format, not interface copy. Its visible surface renders the question and the answer, which are data, so the plugin has nothing to localise at all. # Theming an AI chat with CSS variables, light and dark > aparté is 100% CSS-driven — restyle every part of the chat by overriding CSS variables, with no JS theme logic and no forking. aparté is **100% CSS-driven**. There is no JavaScript theme logic — you restyle the whole chat by overriding **CSS custom properties**. Every visual value the components render (colours, spacing, font sizes, weights, line-heights, radii, border widths) flows through a variable, so a well-made theme never has to touch the component internals. ## How it works [Section titled “How it works”](#how-it-works) The default theme lives on `:root`. Override any variable wherever you like — globally, scoped to a subtree, or per chat instance: ```css /* Global: retheme every aparté chat on the page. */ :root { --aparte-primary: #7c3aed; /* your brand accent */ } /* Scoped: only chats inside .support-widget. */ .support-widget { --aparte-primary: #0ea5e9; } ``` Because they are plain CSS variables, they cascade and inherit like any other — no build step, no theme provider, no re-render. ## Light and dark [Section titled “Light and dark”](#light-and-dark) The chat follows the system: with no attribute, `prefers-color-scheme` decides, so a dark OS gets a dark chat with nothing to write. `data-aparte-theme` is the override, in **both** directions — on the chat itself or on any ancestor (``, ``): ```html … ``` A themed island is the same gesture one level down: a `data-aparte-theme="light"` subtree inside a dark page stays light, and vice versa. ## Rebrand in a handful of variables [Section titled “Rebrand in a handful of variables”](#rebrand-in-a-handful-of-variables) Most of the palette derives from a few base tokens, so a rebrand is short: ```css :root { --aparte-primary: #b45309; /* accent — send button, links, focus, caret */ --aparte-primary-hover: #92400e; --aparte-bg: #fbf7f0; /* YOUR page background — see the note below */ --aparte-surface-1: #ffffff; /* cards, code blocks */ --aparte-surface-2: #f4ece0; /* headers, inline code */ --aparte-text: #241a12; --aparte-text-muted: #7c6f60; --aparte-border: #e7dccb; } ``` The part worth knowing: it works **wherever you set the base**, not only on `:root` — a themed subtree, a `[data-aparte-host]` boundary, or one element: ```html ``` That single attribute moves this chat’s send button, its user avatar, its focus ring, its input’s focus border and its progress fill, and leaves every other chat on the page alone. A derived variable is still yours to set on its own when you want one to break ranks — a value you declare always wins over the one it would have derived. ::: ## The scales [Section titled “The scales”](#the-scales) Structural values aren’t magic numbers — they route through **scales**. Adjust a scale and the whole UI re-spaces or re-sizes coherently. | Scale | Tokens | Controls | | ----------- | --------------------------------------------------- | ---------------------- | | Spacing | `--aparte-space-1` … `--aparte-space-8` (2 → 16px) | gaps, padding, margins | | Font size | `--aparte-font-size-2xs` … `--aparte-font-size-2xl` | component text sizes | | Font weight | `--aparte-font-weight-normal` … `-bold` | text weights | | Line height | `--aparte-line-height-none` … `-loose` | line heights | | Radius | `--aparte-radius-xs` … `--aparte-radius-full` | corner rounding | ```css /* A denser, squarer chat. */ :root { --aparte-space-6: 8px; /* pull the default 12px paddings/gaps in */ --aparte-radius-lg: 4px; /* squarer bubbles, inputs, cards */ } ``` The defaults sit where the kits people compare a chat against sit — radii of 3 to 18px, controls of 24/32/40px, 14px body text, a visible focus ring — since 0.16.0; before that they were one step denser on every axis, and the difference read as “plain”. The scales are what moves the whole kit at once, so the older, denser look is four lines away: ```css /* The "compact" preset: tighter, squarer, a size down. */ :root { --aparte-radius-unit: 2px; /* radii become 2/4/6/8/12px */ --aparte-font-scale: 1; /* 14px body text becomes 13px */ --aparte-btn-size-md: 28px; /* the medium button, and every control on its scale */ --aparte-btn-size-lg: 36px; } ``` Compare the two on any [UI kit](/kit/button/) page: every family reads the same tokens. ## Token groups [Section titled “Token groups”](#token-groups) Variables are grouped by region. The most-reached-for ones: **Core palette** — `--aparte-primary`, `--aparte-primary-hover`, `--aparte-bg`, `--aparte-surface-1` / `-2` / `-3`, `--aparte-text`, `--aparte-text-muted`, `--aparte-border`, and the status colours `--aparte-info` / `-success` / `-warning` / `-error`. **Message** — `--aparte-message-gap`, `--aparte-message-padding-block` / `-inline`, `--aparte-message-max-width`, and the message surface: `--aparte-message-content-bg-user` / `-assistant`, `--aparte-message-content-text-user` / `-assistant`, `--aparte-message-content-padding`, `--aparte-message-content-radius`. **Avatar** — `--aparte-avatar-size`, `--aparte-avatar-radius-ratio` (a *fraction* of `--aparte-avatar-size`, not a length: `0.28` is the default, `0.5` is a circle), `--aparte-avatar-bg-user` / `-assistant`, `--aparte-avatar-image-user` / `-assistant`. **Action bar** — `--aparte-action-bar-btn-size`, `--aparte-action-bar-btn-color`, `--aparte-action-bar-btn-hover-bg` / `-hover-color`. **Composer / input** — `--aparte-input-bg`, `--aparte-input-border`, `--aparte-input-text`, `--aparte-input-placeholder`, `--aparte-composer-control-size` (sizes the whole composer control row at once). **Segments** — the rich blocks with a group of their own: `--aparte-code-*`, `--aparte-thinking-*`, `--aparte-error-*`. The tool row has a single knob, `--aparte-tool-row-radius`; everything else about it comes from the surface, border and text tokens above. ## Recipes [Section titled “Recipes”](#recipes) ### Give the assistant a bubble too [Section titled “Give the assistant a bubble too”](#give-the-assistant-a-bubble-too) By convention the assistant is plain full-width prose (like ChatGPT / Claude) and only the user message is a bubble. To make both sides bubbles: ```css :root { --aparte-message-content-bg-assistant: var(--aparte-surface-2); --aparte-message-content-text-assistant: var(--aparte-text); } ``` ### Resize the composer in one line [Section titled “Resize the composer in one line”](#resize-the-composer-in-one-line) Every composer control (input height + buttons) derives from a single token: ```css :root { --aparte-composer-control-size: 52px; } /* a chunkier composer */ ``` ### Swap the code font [Section titled “Swap the code font”](#swap-the-code-font) ```css :root { --aparte-code-font-family: 'JetBrains Mono', ui-monospace, monospace; } ``` ### Mark the chosen row [Section titled “Mark the chosen row”](#mark-the-chosen-row) “This one” is one recipe everywhere: the select’s chosen option, a checked choice in the elicitation panel and an answered question wear the **mark** — an intent tint on the ground and a bar on the row’s start edge, drawn in the intent’s ink — and the conversation you are in wears its bar (its ground stays the list’s own). Two tokens move every mark at once, and the same class marks a row of your own: ```css :root { --aparte-mark-tint: 12%; /* quieter ground (18% by default) */ --aparte-mark-bar: 3px; /* a heavier bar (2px by default) */ } ``` ```html
  • Chosen
  • Accepted
  • Declined
  • ``` `--success`, `--danger` and `--neutral` change the intent; `--quiet` is the outcome that did not happen — no tint, no bar, the muted text. Red is for what went wrong, not for “no”: a declined request is quiet, a rejected tool call keeps the muted voice with a cross for a glyph, and a stopped one a stop square. ### Check that every token you read exists [Section titled “Check that every token you read exists”](#check-that-every-token-you-read-exists) A `var(--aparte-text-primary)` that names a token this library does not declare fails in **silence**: the declaration is invalid at computed-value time, the property is inherited, and the page looks almost right. A consumer read one such token in ten places for months, through four visual reviews. The stylesheet you already import declares every token, so the check is a script, not an eye — run it in your app, on your own CSS: ```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 declared = new Set(sheet.match(/--aparte-[\w-]+(?=\s*:)/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 (!declared.has(name)) { bad++; console.log(`${file}: ${name} is not declared by @aparte/core`); } } } process.exit(bad ? 1 : 0); ``` Put it in your test script and a typo cannot ship again. A name it reports that you declared yourself is fine — the `--aparte-` prefix is the library’s, so a token of your own is better off under a prefix of your own. ## Per-instance themes [Section titled “Per-instance themes”](#per-instance-themes) Overriding on `:root` rethemes every chat. To run several differently-themed chats on one page, set the variables (and `data-aparte-theme`) straight on each [``](/components/conversation/aparte-chat/) — they inherit down to its viewport, composer and bubbles: ```html ``` ## Beyond CSS [Section titled “Beyond CSS”](#beyond-css) Some changes need markup or behaviour, not just colours — a custom typing indicator, your own attachment chip, an avatar, extra action-bar buttons. Those are **render hooks** and the **action registry**, covered in **[Customization](/guides/customization)**. # What a tool-call UI has to show > The five things a chat has to render for a tool call — the call, its arguments, its status, its result and the approval — why each one matters, and what aparté draws by default. A model that can call tools stops being a text box and starts being an agent, and the transcript has to keep up: a reader must be able to tell *what the model did*, not only what it said. This page is the checklist — the five things a tool call has to show, why each exists, and what aparté renders for it out of the box. The how-to is in [Tool calls with human approval](/guides/tools/); this is the why. ## 1. That a call happened, and to what [Section titled “1. That a call happened, and to what”](#1-that-a-call-happened-and-to-what) The minimum is a line in the flow of the answer: *the model called `get_weather`*. It belongs **where it happened** — between the sentence before and the sentence after — not in a side panel, because the order is the story: the model reasoned, called, read the result, then wrote. A transcript that hides calls reads as if the model knew things it had to look up. aparté renders each call as a segment of the assistant’s message, in sequence with its text: one quiet row with the tool’s name, so five calls are five quiet lines rather than a wall of badges. ## 2. The arguments the model chose [Section titled “2. The arguments the model chose”](#2-the-arguments-the-model-chose) The name says *which* tool; the arguments say *what the model decided*. `get_weather` with `{ "city": "Lyon" }` is a different act from `{ "city": "Lyons, NY" }`, and the only place a user can catch the difference is the arguments. They are also the first thing a developer looks at when a tool misbehaves — a wrong argument is a prompt problem, a right argument with a wrong result is a tool problem. Every comparable kit shows them, behind a disclosure: the row stays quiet, and opening it shows the input. aparté’s row opens on the arguments as formatted, highlighted JSON. ## 3. The status, live [Section titled “3. The status, live”](#3-the-status-live) A call takes time. While it runs the reader needs to know the model is *waiting on something*, not stuck; when it ends, how it ended. The transition is what a streaming transcript is for: the row appears the moment the model emits the call, and changes in place when the result lands. aparté’s row has six states — waiting for approval, running, completed, rejected (by the user, or by an approval policy), stopped, failed — with a spinner while running and a glyph once settled. The change is patched into the existing element, so a row a user has opened stays open across the update. A tool that wants the model to see a failure returns it *as its result*, and the row completes; a handler that **throws** settles its row on *failed* with the crash’s one line, and the run ends on that error — the message’s error card follows. (The row used to keep spinning “Running” in that case.) ## 4. The result — and how much of it [Section titled “4. The result — and how much of it”](#4-the-result--and-how-much-of-it) The result is what the model read before it answered. Showing it lets the reader check the answer against its source, and lets a developer see what the model was *given* when the answer is wrong. It is also the one part that can be large — a file listing, an API payload — so it belongs behind the same disclosure as the arguments, not inline. There is a second kind of result: the one that *is* the answer. A tool that generates a document, a chart, a form the user filled in — its result should be rendered as a thing, not as JSON. That is what a **tool renderer** is for: a renderer registered for a tool name replaces the default row with a surface of its own. aparté’s [`ask_user` plugin](/plugins/ask-user/) is the in-repo example — its result is drawn as a receipt card of the question and the answer — and [`@aparte/plugin-artifacts`](/plugins/artifacts/)’ `create_artifact` is the same shape: a call whose result is rendered richly, as a Code/Preview card. Register yours with `registerToolRenderer` (see [Customization](/guides/customization/)). ## 5. The approval, before the fact [Section titled “5. The approval, before the fact”](#5-the-approval-before-the-fact) Some calls must not run without a human saying so: deleting, paying, sending. The UI has to show the *proposed* call — name and arguments, since the arguments are what is being approved — and offer accept or reject **before the handler runs**, with the run paused meanwhile. Two things decide whether it is real: the gate has to be on the loop (a button that is drawn but does not block the call is decoration), and the answer has to be somewhere the user is already looking. aparté puts the approval where the user’s attention already is: the composer. The panel asks the question — *Run `delete_file`?* — and shows the call’s arguments under it, as pretty-printed JSON, because the arguments are the thing being approved. The block is capped and scrolls, so a long input cannot push the buttons off a panel that is already capped at half the viewport. The row in the transcript stays the anchor, saying which call is waiting, and holds nothing clickable. The loop does not proceed until the panel is answered. The same panel serves the general case — a tool that needs a choice, a yes/no or a small form from the user before it can finish — which is [elicitation](/guides/elicitation/). ## What is deliberately not there (yet) [Section titled “What is deliberately not there (yet)”](#what-is-deliberately-not-there-yet) * **Grouping.** Some kits fold consecutive calls into one “3 tools used” line. aparté does not, on purpose: grouping spans segments, so it would touch the most load-bearing composition path in core for a presentational nicety — and a row that is one quiet line removes most of the noise grouping exists to tame. The trigger is a real transcript that becomes unreadable, measured on a page. * **Timing and cost per call.** The engine reports usage per turn; per-call timing is a product’s display decision, and the segment carries the timestamps a product needs. * **A terminal, a file tree, test results.** These are tool *results* rendered richly, not message types — the same seam as point 4. The tool’s name (`bash`, `run_tests`) belongs to the app, so the renderer does too. ## The checklist [Section titled “The checklist”](#the-checklist) | Show | Because | aparté, by default | | ------------------- | ------------------------------------------------ | ----------------------------------- | | The call, in place | The order is the story | A segment row in the message flow | | The arguments | They are what the model decided | Disclosure, highlighted JSON | | The status, live | Waiting is not stuck; the end is a state | Spinner → glyph, patched in place | | The result | The reader checks the answer against its source | Same disclosure; or a tool renderer | | The approval, first | A drawn button that does not block is decoration | Composer panel, loop paused | # Tool calls with human approval — human-in-the-loop UI > Register a tool the model can call, render its status, and gate it behind a built-in human approval step — approve or reject before it runs. A **tool** is a function the model can ask to run — read a file, hit an API, delete something. Register a definition plus a handler and `AparteClient` does the rest: it offers the tool to the model, runs your handler when it’s called, feeds the result back, and renders the call as a row you can open. For anything sensitive, one flag makes the model wait for a human to click **Approve** before your handler ever runs. Why the row shows what it shows is its own page: [What a tool-call UI has to show](/guides/tool-call-ui/). ## Define and register a tool [Section titled “Define and register a tool”](#define-and-register-a-tool) A tool is a plain `AparteTool` object plus an `AparteToolHandler`, registered together with `aparteGlobalConfig.registerTool`: ```ts import { aparteGlobalConfig } from '@aparte/core'; import type { AparteTool, AparteToolHandler } from '@aparte/core'; const getTimeTool: AparteTool = { name: 'get_time', description: 'Return the current time in a given IANA timezone.', inputSchema: { type: 'object', properties: { timezone: { type: 'string' } }, required: ['timezone'], }, }; const getTimeHandler: AparteToolHandler = async (call) => ({ toolCallId: call.id, content: new Date().toLocaleString('en-US', { timeZone: call.input.timezone as string }), }); aparteGlobalConfig.registerTool(getTimeTool, getTimeHandler); ``` * **`inputSchema`** is a plain JSON Schema object, sent to the model as-is. * The handler receives an `AparteToolCall` (`{ id, name, input }`) and an `AbortSignal` (fires on a timeout or a stream abort), and must resolve an `AparteToolResult` (`{ toolCallId, content }`). * **`systemPrompt?`** on the tool is injected automatically once registered — tell the model *when* to use it without touching your main prompt. **`maxTurns?`** overrides the client’s global `maxTurns` for this tool only. ## The model → tool\_call → result loop [Section titled “The model → tool\_call → result loop”](#the-model--tool_call--result-loop) Register the tool, register the default renderers, and start a client: ```ts import { registerDefaultRenderers, AparteClient } from '@aparte/core'; registerDefaultRenderers(); new AparteClient().start(); ``` `AparteClient` sends every registered tool with the request. The one case where it does not is a model that declares its `capabilities` and leaves `function_calling` out — a statement the client respects. A model that says nothing (which is what a `GET /models` listing usually amounts to) gets the tools: registering one is an explicit act, and dropping it silently because a listing is terse would turn your registration into a no-op with nothing to read anywhere. When the model calls one: 1. A **[`tool_call`](/segments/tool-call/)** segment is added (`status: 'pending'`) — the built-in renderer shows a row with the tool name and a spinner. 2. The client resolves the handler via `aparteGlobalConfig.getToolHandler(name)`, runs it, and on resolve flips the segment to `status: 'resolved'`. 3. The `tool_call` and its result are appended to history and the provider is re-called automatically, so the model sees the outcome and continues. 4. If `maxTurns` (per-tool or global) is hit first, the segment becomes `'aborted'`. `AparteToolCallSegment.status` is one of `'pending' | 'resolved' | 'aborted' | 'awaiting-approval' | 'rejected'` — the last two only apply to approval-gated tools. ### What the row shows [Section titled “What the row shows”](#what-the-row-shows) One line per call: the tool’s name, a spinner while it runs, and the state as a word at the far end — `Running`, `Done`, `Rejected`, `Stopped`. When the call has arguments or a result, that line becomes a disclosure, and opening it shows both — the arguments the model chose under **Input**, pretty-printed, and whatever your handler returned under **Output**. A registered [highlight provider](/plugins/shiki/) colours them; without one they are escaped text, because a tool’s arguments are model-authored and are never injected as HTML. It opens on a click and never on its own, including while a decision is pending. The reasoning block stays closed while it is being produced, which is the most live moment there is, so a tool call has no stronger claim to unroll itself. Every word is a locale key: ```ts import { aparteGlobalConfig } from '@aparte/core'; aparteGlobalConfig.extendLocale({ toolInput: 'Arguments', toolOutput: 'Result', toolRunning: 'Working…', toolCompleted: 'Done', toolRejected: 'Refused', toolStopped: 'Stopped', }); ``` And every part is a class, so restyling needs no renderer: | Class | The part | | ---------------------- | ------------------------------------------------------------------------------------ | | `.aparte-tool-summary` | the clickable line | | `.aparte-tool-toggle` | the chevron | | `.aparte-tool-label` | the call’s identity — holds `.aparte-tool-icon` and `.aparte-tool-name` | | `.aparte-tool-spinner` | shown only while `pending` | | `.aparte-tool-state` | the state word, pushed to the far end | | `.aparte-tool-detail` | the opened body | | `.aparte-tool-part` | one of Input / Output — holds `.aparte-tool-part-label` and `.aparte-tool-part-body` | ```css :root { --aparte-tool-row-radius: 0; } /* the row's corner */ .aparte-tool-summary:hover { background: none; } .aparte-tool-state { font-variant: small-caps; } ``` Replacing the markup outright is [a custom tool renderer](#custom-tool-renderer) instead. ## Require approval (human-in-the-loop) [Section titled “Require approval (human-in-the-loop)”](#require-approval-human-in-the-loop) What the reader sees when the loop stops. The row is the anchor — it opens onto the arguments the model sent — and the decision itself is asked at the composer. Width 375 768 Full [Open in a tab](/preview/demo/tool-approval/) [A tool call paused for a person](/preview/demo/tool-approval/) A tool call in \`awaiting-approval\`: the turn is paused and nothing has run yet. ```html ``` Set `needsApproval: true` on the tool: ```ts const deleteFilesTool: AparteTool = { name: 'delete_files', description: 'Delete a file from the workspace. Destructive — always ask first.', inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'], }, needsApproval: true, }; aparteGlobalConfig.registerTool(deleteFilesTool, async (call) => { // ... actually delete call.input.path ... return { toolCallId: call.id, content: `Deleted ${call.input.path}` }; }); ``` Before running the handler, `AparteClient` flips the segment to `status: 'awaiting-approval'` and **asks at the composer** — the same place every other request for the user is answered, through the same `requestUserInput` a tool handler calls. The panel carries the question, the call’s **arguments** under it, the choices and a free-text field; the row in the transcript is the **anchor**, saying which tool is waiting, and holds nothing clickable. The arguments are on the panel because they are what is being approved — the same text the row shows, from the same function, so the two surfaces cannot disagree about what is about to run. The panel is capped at half the viewport and the block inside it scrolls, so a diff or a plan does not push the buttons off the surface they are asked on; the full version stays in the thread, which is already scrollable, copyable and persisted. Set `details` on your own `requestUserInput({ kind: 'approval' })` call to put text there — it is rendered through `textContent`, never as markup, so a model-chosen path cannot become markup on the surface someone clicks Approve on: ```ts import { aparteGlobalConfig } from '@aparte/core'; const answer = await aparteGlobalConfig.requestUserInput({ kind: 'approval', message: 'Run delete_file?', // The call being approved, shown under the question. Text, never markup. details: JSON.stringify({ path: 'src/index.ts' }, null, 2), options: [ { value: 'allow', label: 'Approve', tone: 'affirm' }, { value: 'deny', label: 'Reject', tone: 'deny' }, ], }); ``` The [complete example](#complete-example-approvereject-with-no-backend) below passes it the same way. It also dispatches **`aparte-tool-approval-request`** on the target element (`detail: { toolCallId, toolName, input }`) — observation only, for an app that wants to raise an OS notification when a gate opens. * On **reject**, the handler never runs. A synthetic *“rejected by user”* result is fed back, the turn’s **remaining** tool calls are skipped — the model may have asked for several, and refusing one cannot license the others — and then the model is given another turn, so it actually reads the refusal and can answer it. It could not before: the turn simply ended there, and telling the assistant what you wanted instead meant retyping it as a new message it then read out of order. * A **stop** is not a reject. Pressing Stop while a tool waits for approval marks the segment `aborted` and appends nothing: there is nothing true to tell the model. The two used to be indistinguishable, so a stopped turn was reported as a refusal. * On **approve**, the handler runs with the original input, unless the decision carries a plain-object `payload`, which is merged onto the input first — so a custom approval surface can edit the arguments (fix a path, tighten a query) before the tool runs. The built-in panel sends no payload. * **Typing instead of choosing** is a refusal that carries your words: the instruction becomes the `tool_result` the model reads on the turn it gets back. That is only useful because a refusal hands the model a turn — before, whatever you wrote had nowhere to go. To drive approval from something with no DOM — a CLI, a webhook, an ops channel — or to decide without asking at all, pass an `approvalResolver` in `AparteClientOptions`. It replaces the panel entirely: ```ts new AparteClient({ // The whole CALL, not just its id: you cannot ask "run this?" without naming what. approvalResolver: async (call, signal) => ({ approved: await confirmWithOpsTeam(call.name, call.input, signal), // Optional, on a refusal: the words the model reads back. instruction: 'use the staging bucket instead', }), }).start(); ``` An “auto” mode is this and nothing more: a resolver that answers without asking anybody. ### A policy, per call — modes [Section titled “A policy, per call — modes”](#a-policy-per-call--modes) `needsApproval` is a declaration about a **tool**; a mode — *plan* (read-only), *ask*, *auto-edit*, *auto* — is a decision about each **call**, and the same `run_command` can be a read or an execution. That decision has a seam of its own: an `AparteApprovalPolicy` registered with `setApprovalPolicy()` rules on every call, before the panel is involved. ```ts aparteGlobalConfig.setApprovalPolicy((call, tool) => { 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; // no opinion: the tool's own flag decides }); ``` The ruling is an `AparteApprovalRuling`: `allow` runs without asking and never pauses the row; `ask` puts the call to the person exactly as a `needsApproval` tool is; `deny` refuses it, and its `reason` is what the model reads — verbatim, not “the user rejected this”, because nobody did. `getApprovalPolicy()` reads it back and `ruleOnToolCall(call)` is the one place the policy and the flag are combined. A host’s own `approvalResolver` is not affected: it already owns the decision. The four modes, a read/write/exec classification of your tool names and a switch for the composer’s toolbar are [`@aparte/plugin-approval`](/plugins/approval/). Approval is UX, not authorization `needsApproval` runs **in the browser** — it protects the user from surprising tool runs, not your system from a malicious client. Anything a tool actually does against your backend must be re-validated server-side as if the approval never happened: is the caller authenticated, allowed to use this tool, allowed on *this* resource, and are the arguments valid? If you proxy through `createAparteChatHandler`, its [`authorize()` hook](/guides/backend-transport/) is the place for that check. ## Custom tool renderer [Section titled “Custom tool renderer”](#custom-tool-renderer) Replace the generic row for a specific tool name with `registerToolRenderer`. `render` returns either an HTML string or a ready DOM element (`''` renders nothing — e.g. a UI-only tool); `setup` runs once after injection for listeners; `getStyles` is injected into `document.head` once per tool. For a `needsApproval` tool this only takes over *after* approval: ```ts import { aparteGlobalConfig } from '@aparte/core'; import type { AparteToolRenderer } from '@aparte/core'; const webSearchRenderer: AparteToolRenderer = { render: (segment) => `
    Searching the web…
    `, setup: (element, segment) => { /* wire listeners after injection, if any */ }, }; aparteGlobalConfig.registerToolRenderer('web_search', webSearchRenderer); ``` A call changes several times in one turn — its result lands, a decision is made, it fails — and on each change core rebuilds your markup from `render()` unless you declare **`update`**, which patches the element in place instead. Rebuilding is right for a receipt and wrong for anything with state: a mounted preview, an opened disclosure or a focused control is lost in a rebuild. **`relabel`** is called on every config change (`setLocale`, `setIconProvider`, `reset()`) so the strings you drew follow the locale; replace text and glyphs, add or remove no node. Both are the contracts a segment renderer already has, so one object can serve as both: ```ts import { aparteGlobalConfig } from '@aparte/core'; import type { AparteToolRenderer } from '@aparte/core'; const reportRenderer: AparteToolRenderer = { render: (segment) => { const el = document.createElement('div'); el.className = 'my-report'; el.dataset['status'] = segment.status ?? 'pending'; return el; }, // The result landed: mark it, keep the element. update: (element, segment) => { element.dataset['status'] = segment.status ?? 'pending'; if (segment.result) element.textContent = segment.result; }, relabel: (element) => { element.setAttribute('aria-label', aparteGlobalConfig.t('toolOutput')); }, }; aparteGlobalConfig.registerToolRenderer('build_report', reportRenderer); ``` The segment carries model-chosen data `segment.toolCall.input` is whatever the **model** decided to pass, and `segment.result` is whatever the tool returned. Both are untrusted. The obvious first thing to write is the one that breaks: ```ts // DON'T — a direct model-to-DOM XSS in your page's origin. render: (segment) => `
    Searching for ${segment.toolCall?.input?.['query']}
    `, ``` Two ways out. **Return an element** and there is no `innerHTML` surface at all: ```ts import { aparteGlobalConfig } from '@aparte/core'; import type { AparteToolRenderer } from '@aparte/core'; const searchRenderer: AparteToolRenderer = { render: (segment) => { const el = document.createElement('div'); el.className = 'aparte-tool-label'; // textContent, so the value is text no matter what it contains. el.textContent = `Searching for ${String(segment.toolCall?.input?.['query'] ?? '')}`; return el; }, }; aparteGlobalConfig.registerToolRenderer('web_search', searchRenderer); ``` Or **keep the string and escape every interpolation** — `escapeHtml` in text position, `escapeAttr` inside an attribute, exactly as in [Customization](/guides/customization/): ```ts import { aparteGlobalConfig, escapeHtml, escapeAttr } from '@aparte/core'; import type { AparteToolRenderer } from '@aparte/core'; const searchRenderer: AparteToolRenderer = { render: (segment) => { const query = String(segment.toolCall?.input?.['query'] ?? ''); return `
    Searching for ${escapeHtml(query)}
    `; }, }; aparteGlobalConfig.registerToolRenderer('web_search', searchRenderer); ``` ## Complete example: approve/reject with no backend [Section titled “Complete example: approve/reject with no backend”](#complete-example-approvereject-with-no-backend) This runs with no model and no API key — it drives the viewport the same way `AparteClient` would, so you can see the whole mechanic. Adapted from `apps/examples/vanilla-dist`: ```ts import '@aparte/core'; import '@aparte/core/styles.css'; import { registerDefaultRenderers, aparteGlobalConfig } from '@aparte/core'; registerDefaultRenderers(); const chat = document.querySelector('aparte-chat')!; const vp = () => (chat as any).viewport; let n = 0; function reply(text: string) { // `status` is part of the shape, not decoration: a finished assistant turn says so. vp().appendMessage({ id: `a-${++n}`, role: 'assistant', content: text, timestamp: Date.now(), status: 'completed' }); } // Human-in-the-loop with no client and no loop: the row is the anchor in the // transcript, and `requestUserInput` asks at the composer. This is the same function // the built-in gate calls, so a page and a real agent loop ask identically. async function askApproval() { const id = `a-${++n}`; const segId = `seg-${n}`; vp().appendMessage({ id, role: 'assistant', content: '', timestamp: Date.now(), status: 'streaming' }); vp().addSegment(id, { id: segId, type: 'tool_call', status: 'awaiting-approval', toolCall: { id: `tc-${n}`, name: 'delete_files', input: { path: '~/notes/todo.md' } }, }); try { const answer = await aparteGlobalConfig.requestUserInput({ kind: 'approval', message: 'Run delete_files?', // What is being approved, under the question. Rendered through `textContent`, // never as markup — a model-chosen path is not trusted here either. details: JSON.stringify({ path: '~/notes/todo.md' }, null, 2), // The options are YOURS. Core cannot write "and always for this tool" or know // that your app has somewhere to remember it. options: [ { value: 'allow', label: 'Approve', tone: 'affirm' }, { value: 'deny', label: 'Reject', tone: 'deny' }, ], }); const picked = answer.action === 'accept' ? (answer.content as { option?: string; instruction?: string }) : {}; const approved = !picked.instruction && picked.option === 'allow'; vp().updateSegment(id, segId, { status: approved ? 'resolved' : 'rejected' }); reply(approved ? 'Approved — the file would be deleted here.' : picked.instruction ? `Understood: ${picked.instruction}` : 'Rejected — nothing happened.'); } catch { // It ended without an answer: a stopped turn, or nothing mounted to ask it. vp().updateSegment(id, segId, { status: 'aborted' }); } } chat.addEventListener('aparte-send', (e) => { const text = (e as CustomEvent).detail.content as string; vp().appendMessage({ id: `u-${++n}`, role: 'user', content: text, timestamp: Date.now() }); if (text.trim().toLowerCase().includes('delete')) askApproval(); else reply(`You said: "${text}". Type "delete" to see a human-in-the-loop tool approval.`); }); ``` Type a message containing “delete” and the row appears in the transcript while the choices appear in the composer — the same panel `AparteClient` raises. Swap the manual `addSegment` call for a registered `delete_files` tool (`needsApproval: true`) plus a started `AparteClient`, and a real model drives the exact same segment and events. ## Next steps [Section titled “Next steps”](#next-steps) * **[Customization](/guides/customization)** — render hooks and the action registry for everything outside tool segments. * **[The agent engine](/guides/engine)** — the headless `runStreamAgent` loop, for running this same tool + approval flow off the main thread or on a server. # Troubleshooting > The real first-run failures — local-model CORS, "no provider registered", the key-exposed warning, and how vendor errors surface as AparteError. The failures below are the ones that actually happen on a first run, in the order you’re likely to hit them. ## `Failed to resolve module specifier "@aparte/core"` [Section titled “Failed to resolve module specifier "@aparte/core"”](#failed-to-resolve-module-specifier-apartecore) You opened an `index.html` directly, with no bundler. A browser cannot resolve a bare specifier on its own — that is a build-tool convention, not a web one. Two ways out: * **Use a bundler.** Vite, Next, Astro, Parcel, esbuild — any of them resolve it. This is what every snippet in these guides assumes, and what the examples do. * **Declare the mapping yourself** with an import map, and load the CSS by URL: ```html ``` “Framework-agnostic” means no React/Vue/Svelte/Angular. It does not mean no build step — and this page exists to say the first thing you actually hit, so it says it. ## CORS on local BYOK (LM Studio / Ollama) [Section titled “CORS on local BYOK (LM Studio / Ollama)”](#cors-on-local-byok-lm-studio--ollama) **This is the #1 first-run failure.** With `AparteDirectTransport` (the default), the *browser itself* calls `http://localhost:1234` (LM Studio) or `http://localhost:11434` (Ollama) directly — there is no server in between to add CORS headers for you. If the local server doesn’t send permissive CORS headers, the browser blocks the response and the request fails with a network/CORS error, even though the server logs show it received the request. The two local presets live in `packages/providers/ai/openai-compat/src/presets.ts` as `presets.LMSTUDIO` (`http://localhost:1234/v1`) and `presets.OLLAMA` (`http://localhost:11434/v1`) — see the [OpenAI-compatible provider](/providers/ai/openai-compat/) guide for how to register them. **Fix — LM Studio:** open the LM Studio server settings (Developer tab) and enable **“Enable CORS”** on the local server, then restart the server. **Fix — Ollama:** start it with the `OLLAMA_ORIGINS` environment variable set to allow your page’s origin (or `*` for local development): ```bash OLLAMA_ORIGINS=* ollama serve ``` On Windows (PowerShell), set it before launching: ```powershell $env:OLLAMA_ORIGINS = "*" ollama serve ``` Without this, Ollama only accepts requests from a small built-in allowlist of origins and rejects everything else with a CORS error. ## ”No provider registered” / no model available [Section titled “”No provider registered” / no model available”](#no-provider-registered--no-model-available) Two different symptoms, one root cause: nothing is wired up yet. **“Provider is not registered”** — you called `aparteGlobalConfig.registerAIProvider(...)` with a different id than the one selected (or never called it at all). Register the provider *and* select it before the client sends anything: ```ts import { aparteGlobalConfig, AparteDirectTransport, AparteClient } 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(); ``` **“No provider selected”** — no `defaultProvider`/`defaultModel` is set on `aparteGlobalConfig.setModelConfig(...)` (and no `` has picked one yet). Check `aparteGlobalConfig.hasSelectedModel()` — it’s `false` until both are set. If you want the composer to block sending until a model is chosen (instead of erroring on send), opt into `aparteGlobalConfig.setRequireModelSelection(true)`. Either way, forgetting `new AparteClient().start()` looks identical to a broken provider from the outside: nothing streams, because nothing is listening for `aparte-send`. See [Wire a real model](/guides/getting-started/#wire-a-real-model). ## ”Key exposed” console warning [Section titled “”Key exposed” console warning”](#key-exposed-console-warning) If you see: ```plaintext [Aparte] AparteDirectTransport is sending the "" API key straight from the browser — it is visible to anyone who opens devtools. ... ``` `AparteDirectTransport` just sent a real API key from the browser to the vendor, and you didn’t tell it that was intentional. It fires once per page load, the first time a key is attached to a request. * **Fine to ignore (or silence) when:** the key is the end-user’s own (BYOK) or the model runs locally (LM Studio, Ollama) — pass `{ byok: true }` so the warning doesn’t fire at all: ```ts aparteGlobalConfig.setTransport(new AparteDirectTransport({ byok: true })); ``` * **Not fine when:** the key is *your* server-held vendor key. Anyone with devtools open can read it and use it directly. Switch to `AparteBackendTransport` (paired with `createAparteChatHandler`) so the key never reaches the client — see the [Backend transport](/guides/backend-transport/) guide. Keyless local providers never trigger this warning — there’s no key to expose. ## `[Unknown segment type: …]` in the bubbles [Section titled “\[Unknown segment type: …\] in the bubbles”](#unknown-segment-type--in-the-bubbles) The bubble found no renderer for that segment type. * **A built-in type** (`text`, `thinking`, `code`, `tool_call`, `error`) should never show this: core installs its built-in renderers the first time a segment needs one. If you see it anyway, something declined them — a `new AparteClient({ autoRegister: false })` somewhere, which is remembered on purpose. Drop the option, or register what you need with `registerSegmentRenderer`. On **0.4.x and earlier** the built-ins only came with `new AparteClient()`, so a display-only app had to call `registerDefaultRenderers()` itself — that’s the fix there. * **Your own type** — that’s the expected fallback: register a renderer for it (see [Custom segment types](/guides/customization/#custom-segment-types)). The symptom is easy to misread, because everything else works: bubbles, streaming, auto-scroll, the composer. Only the content is missing. ## The retry / edit / ⓘ buttons aren’t there [Section titled “The retry / edit / ⓘ buttons aren’t there”](#the-retry--edit--ⓘ-buttons-arent-there) They ship **off**. Core can only render them; re-sending a message, keeping edited text and opening a stats popover all need someone outside core, so aparté waits for you to say you’re there rather than showing a button that answers to nobody: ```ts aparteGlobalConfig.setBubbleActions({ retry: true, edit: true }); // you run an AparteClient aparteGlobalConfig.setBubbleActions({ feedback: true, info: true }); // you handle these events ``` Same for the affordance outside the action bar — the clickable image tile: ```ts aparteGlobalConfig.setHostHandlers({ attachmentPreview: true }); ``` Two things that are *not* the cause, before you go looking: * **The bar hides itself while a reply streams** (and reappears when the turn ends) — by design, so copy/retry never sit on an empty bubble. * **An action bar with nothing in it isn’t rendered at all**, so if you disabled every action the whole row is gone rather than blank. Coming from 0.4.x and the buttons vanished? That’s this change — one line brings them back. The full table of what ships enabled and why is in [Customization](/guides/customization/#what-ships-enabled). ## The composer toolbar isn’t showing [Section titled “The composer toolbar isn’t showing”](#the-composer-toolbar-isnt-showing) Three causes, in the order they happen. **You are passing `footerLeft` / `footerCenter` / `footerRight`.** They were removed: the three positional slots became one `toolbar`. In React that is a type error; in Vue, Svelte and Angular an unknown slot name renders **nothing, silently**, which is why this entry exists. Pass one `toolbar` and order your controls yourself — see [The composer toolbar](/guides/customization/#the-composer-toolbar). **The row is empty.** [``](/components/input/aparte-composer/) reflects `data-empty` while it holds no content, and the stylesheet hides it then — an empty row must not draw its separator. Text counts as content, so a bare token count is fine; whitespace and comments do not. If you expected something in it and see nothing, inspect the element: `data-empty` present means your content never arrived (a mistyped slot name, or a framework that rendered nothing). **The row is there but unstyled, and your control sits at the start.** Then the stylesheet did not reach the page: an undefined-looking custom element falls back to `display: inline`, so there is no flex container and `margin-inline-start: auto` does nothing. Check that you import `@aparte/core/styles.css`, and — if you work in this repo — that `packages/core/dist/index.css` is current: it is a build output, so a stale one can be served after a build that reported success. ## Errors: `AparteError` / `AparteErrorCode` [Section titled “Errors: AparteError / AparteErrorCode”](#errors-aparteerror--aparteerrorcode) Every failure that reaches the UI — a bad request, a rate limit, a network drop, an unregistered provider — is normalized to an `AparteError` (`packages/core/src/types/errors.ts`): ```ts declare class AparteError extends Error { constructor( message: string, code: AparteErrorCode, data?: Record, originalError?: unknown, httpStatus?: number, ); // `defaultCode` defaults to AparteErrorCode.UNKNOWN_ERROR. static from(error: unknown, defaultCode?: AparteErrorCode, defaultStatus?: number): AparteError; } ``` `AparteErrorCode` is a flat enum grouped by who’s responsible: | Code | Meaning | | ------------------------ | --------------------------------------------------------------- | | `CONFIG_NO_PROVIDER` | No provider selected. | | `CONFIG_MISSING_KEY` | The selected provider isn’t registered (or its key is missing). | | `CONFIG_INVALID_KEY` | The vendor rejected the key (HTTP 401 / 403). | | `CONFIG_INVALID_MODEL` | The selected model id isn’t valid for the provider. | | `USAGE_RATE_LIMIT` | Vendor rate limit (HTTP 429). | | `USAGE_CONTEXT_EXCEEDED` | Context window exceeded (HTTP 400). | | `USAGE_BAD_REQUEST` | Malformed request (HTTP 400). | | `NET_OFFLINE` | The client is offline. | | `NET_TIMEOUT` | The request timed out. | | `NET_ERROR` | Generic network failure. | | `PROVIDER_ERROR` | Vendor-side error (HTTP 5xx). | | `PROVIDER_UNAVAILABLE` | Vendor service unavailable (HTTP 503). | | `PROVIDER_POLICY` | Rejected by the vendor’s moderation/policy. | | `UNKNOWN_ERROR` | Anything uncategorized — `AparteError.from`’s default. | **How it surfaces:** a non-ok response makes `AparteDirectTransport` and `AparteBackendTransport` throw an `AparteError` — the vendor’s message, the HTTP status in `httpStatus`, and the code that status stands for: `429` → `USAGE_RATE_LIMIT`, `401` / `403` → `CONFIG_INVALID_KEY`, `503` → `PROVIDER_UNAVAILABLE`, other `5xx` → `PROVIDER_ERROR`, `400` → `USAGE_BAD_REQUEST`, `408` → `NET_TIMEOUT` (`AparteError.codeForStatus()` is that table, for a provider that wants the same). Anything else thrown along the way is wrapped by `AparteError.from(error)`, which reads the same table off an error’s own `status` when it has one, reads `fetch`’s network failure as `NET_ERROR` (`NET_OFFLINE` when the browser says so) and a `TimeoutError` as `NET_TIMEOUT`, and settles on `UNKNOWN_ERROR` only when nothing says more. `AparteClient` renders it as the message’s [`error` segment](/segments/error/) (`content` = `error.message`, `details` = `error.code`), and dispatches an `aparte-message-error` `CustomEvent` on the target element with `{ messageId, error }` — `error` is the full `AparteError`, so `error.code`, `error.data`, `error.httpStatus` and `error.originalError` are all available to a listener: ```ts document.querySelector('aparte-chat')?.addEventListener('aparte-message-error', (e) => { const { error } = (e as CustomEvent).detail; console.error(error.code, error.message); }); ``` Customize what the error segment looks like with [`aparteGlobalConfig.setErrorRenderer`](/reference/config/#renderers--render-hooks) rather than registering a segment renderer for `error` yourself. # UI Kit for an AI Chat App — plain CSS classes, 25 families > 25 families of plain CSS classes for the chrome around a chat — buttons, fields, tabs, dialogs, avatars, the sidebar — themed by the same variables as the chat itself. Every recipe here is **plain classes on plain elements**: no component to import, no framework, no shadow DOM — copy the markup, restyle it with the same [CSS variables](/reference/css-variables/) the chat reads. Each family has one page, its live preview first; every class of the kit on one page is the [classes reference](/reference/classes/). ## Controls [Section titled “Controls”](#controls) * **[Button](/kit/button/)** — The button. Ready-made classes; nothing to configure. * **[Field](/kit/field/)** — Form fields. Ready-made classes; nothing to configure. ## Display [Section titled “Display”](#display) * **[Avatar](/kit/avatar/)** — Sizes, shape, image + initials fallback `--aparte-avatar-size` is NOT invented here: it already exists centrally for the message avatar (bubble.css), and this c * **[Icon](/kit/icon/)** — The icon. Every glyph the library draws carries this class. * **[Badge](/kit/badge/)** — Small count or status pill Two axes like the button’s: an intent says what it means, a fill says how loudly. * **[Tag](/kit/tag/)** — A removable chip * **[Thumbnail](/kit/thumbnail/)** — The thumbnail. A media tile: a fixed square that clips whatever is dropped into it, with room for a control in a corner. * **[Spinner](/kit/spinner/)** — A rotating indicator * **[Progress](/kit/progress/)** — Determinate bar + indeterminate variant The consumer owns the ARIA: role=“progressbar” plus aria-valuenow/min/max on the determinate form, and aria-valuetext (o * **[Skeleton](/kit/skeleton/)** — A loading placeholder Decorative: give the element aria-hidden=“true” (or wrap the group behind a single aria-busy region) so a screen reader does not read out an empty box. * **[Divider](/kit/divider/)** — Horizontal, vertical, optionally labelled The line is a BORDER, not a background — see the progress bar’s comment above for why: a background disappears under forced-colors, a border does not. * **[Alert](/kit/alert/)** — A callout * **[Card](/kit/card/)** — A surface with optional header/body/footer * **[Kbd](/kit/kbd/)** — A keyboard key The monospace stack is —aparte-code-font-family, reused rather than declared again: it is already the library’s one answer to “what font is code * **[Mark](/kit/mark/)** — The mark: a row a list has settled on. ## Surfaces [Section titled “Surfaces”](#surfaces) * **[Tabs](/kit/tabs/)** — One row of tabs, and the panel under it Two looks, one behaviour. * **[Accordion](/kit/accordion/)** — Stacked disclosures A column of items, each a header you press and a panel it reveals. * **[Menu](/kit/menu/)** — A floating list of commands The surface a dropdown, a context menu or an overflow menu is drawn on: a bordered, elevated column of rows. * **[Popover](/kit/popover/)** — A floating box, and nothing else Deliberately just a box. * **[Dialog](/kit/dialog/)** — A modal, drawn on the native \. * **[Tooltip](/kit/tooltip/)** — A short label, with an arrow A tooltip has to be legible against whatever it happens to sit on top of — a bright image, a dark code block, either app theme — so ## Shell [Section titled “Shell”](#shell) * **[Sidebar](/kit/sidebar/)** — The column beside the chat. * **[App Header](/kit/app-header/)** — The bar above the chat. * **[App Shell](/kit/app-shell/)** — Sidebar beside, header above, the chat in the rest. * **[Split](/kit/split/)** — Two panes and a draggable seam. # Accordion > Stacked disclosures A column of items, each a header you press and a panel it reveals. Width 375 768 Full [Open in a tab](/preview/class/accordion/) [Live preview of the Accordion classes](/preview/class/accordion/) The classes below, on plain markup, in their own document — so what you are looking at is core's stylesheet and nothing else. Stacked disclosures A column of items, each a header you press and a panel it reveals. The header works as a native `` inside `
    ` OR as a `
    ` — that choice decides whether the open state is the browser’s or yours, and the CSS does not care either way. It is the whole clickable row, so it takes the same reset a button does. The chevron turns 180° when the item is open, and there are two selectors for it because there are two ways to be open: `aria-expanded` on the header for the ARIA pattern, `details[open]` on the ancestor for the native one. Nothing here opens or closes anything — this is a stylesheet, and the disclosure is the element’s job or yours. ## Classes [Section titled “Classes”](#classes) `.aparte-accordion` · `.aparte-accordion__header` · `.aparte-accordion__icon` · `.aparte-accordion__item` · `.aparte-accordion__panel` · `.aparte-icon` The tokens these read are on the [CSS variables](/reference/css-variables/) reference; every class of the kit, on one page, is the [classes reference](/reference/classes/). # Alert > A callout Width 375 768 Full [Open in a tab](/preview/class/alert/) [Live preview of the Alert classes](/preview/class/alert/) The classes below, on plain markup, in their own document — so what you are looking at is core's stylesheet and nothing else. A callout ## Classes [Section titled “Classes”](#classes) `.aparte-alert` · `.aparte-alert--danger` · `.aparte-alert--info` · `.aparte-alert--neutral` · `.aparte-alert--primary` · `.aparte-alert--secondary` · `.aparte-alert--success` · `.aparte-alert--warning` · `.aparte-alert__body` · `.aparte-alert__dismiss` · `.aparte-alert__icon` · `.aparte-alert__message` · `.aparte-alert__title` The tokens these read are on the [CSS variables](/reference/css-variables/) reference; every class of the kit, on one page, is the [classes reference](/reference/classes/). # App Header > The bar above the chat. Width 375 768 Full [Open in a tab](/preview/class/app-header/) [Live preview of the App Header classes](/preview/class/app-header/) The classes below, on plain markup, in their own document — so what you are looking at is core's stylesheet and nothing else. The bar above the chat. A recipe and nothing more, because a header has no behaviour of its own: a toggle for the sidebar (which `` wires from the `data-aparte-sidebar-toggle` attribute), the conversation’s title, and an actions zone pushed to the end — a model selector, a share button, whatever the page needs. The toggle shows only under 48rem, where the sidebar is a drawer; above it the column is simply there. ## Classes [Section titled “Classes”](#classes) `.aparte-app-header` · `.aparte-app-header__actions` · `.aparte-app-header__title` · `.aparte-app-header__toggle` The tokens these read are on the [CSS variables](/reference/css-variables/) reference; every class of the kit, on one page, is the [classes reference](/reference/classes/). # App Shell > Sidebar beside, header above, the chat in the rest. Width 375 768 Full [Open in a tab](/preview/class/app-shell/) [Live preview of the App Shell classes](/preview/class/app-shell/) The classes below, on plain markup, in their own document — so what you are looking at is core's stylesheet and nothing else. Sidebar beside, header above, the chat in the rest. A recipe: two columns and two rows, the sidebar spanning both rows, the header and the main area stacked in the second column. The chat fills `__main`, which is what gives it the height it needs to scroll (see the layout guide). Size the shell — `height: 100dvh` for a page — and everything inside follows. Under 48rem the sidebar leaves the grid to become a drawer, and the header’s toggle appears. ## Classes [Section titled “Classes”](#classes) `.aparte-app-header` · `.aparte-app-shell` · `.aparte-app-shell__main` · `.aparte-sidebar` · `.aparte-split` The tokens these read are on the [CSS variables](/reference/css-variables/) reference; every class of the kit, on one page, is the [classes reference](/reference/classes/). # Avatar > Sizes, shape, image + initials fallback `--aparte-avatar-size` is NOT invented here: it already exists centrally for the message avatar (bubble.css), and this c Width 375 768 Full [Open in a tab](/preview/class/avatar/) [Live preview of the Avatar classes](/preview/class/avatar/) The classes below, on plain markup, in their own document — so what you are looking at is core's stylesheet and nothing else. Sizes, shape, image + initials fallback `--aparte-avatar-size` is NOT invented here: it already exists centrally for the message avatar (bubble.css), and this component reads the same one — a generic avatar and a chat-message avatar are the same object, and a size modifier applied here resizes a role-coloured message avatar too. The corner, the initials and the group’s overlap are FRACTIONS of that size (`--aparte-avatar-radius-ratio`, `--aparte-avatar-initials-ratio`, `--aparte-avatar-overlap-ratio`), computed on the element so a size modifier moves them: as absolute tokens the 40px avatar drew the same 11px initials as the 32px one, the corner drifted from squircle to square up the ramp, and a 6px overlap was a fifth of a small avatar and a tenth of a large one. ## Classes [Section titled “Classes”](#classes) `.aparte-avatar` · `.aparte-avatar--circle` · `.aparte-avatar--lg` · `.aparte-avatar--sm` · `.aparte-avatar--square` · `.aparte-avatar--xl` · `.aparte-avatar--xs` · `.aparte-avatar-group` · `.aparte-avatar__image` The tokens these read are on the [CSS variables](/reference/css-variables/) reference; every class of the kit, on one page, is the [classes reference](/reference/classes/). # Badge > Small count or status pill Two axes like the button's: an intent says what it means, a fill says how loudly. Width 375 768 Full [Open in a tab](/preview/class/badge/) [Live preview of the Badge classes](/preview/class/badge/) The classes below, on plain markup, in their own document — so what you are looking at is core's stylesheet and nothing else. Small count or status pill Two axes like the button’s: an intent says what it means, a fill says how loudly. `--dot` is the third form — no label, so it is always painted solid. ## Classes [Section titled “Classes”](#classes) `.aparte-badge` · `.aparte-badge--danger` · `.aparte-badge--dot` · `.aparte-badge--info` · `.aparte-badge--lg` · `.aparte-badge--neutral` · `.aparte-badge--outline` · `.aparte-badge--primary` · `.aparte-badge--secondary` · `.aparte-badge--sm` · `.aparte-badge--soft` · `.aparte-badge--solid` · `.aparte-badge--success` · `.aparte-badge--warning` The tokens these read are on the [CSS variables](/reference/css-variables/) reference; every class of the kit, on one page, is the [classes reference](/reference/classes/). # Button > The button. Ready-made classes; nothing to configure. Width 375 768 Full [Open in a tab](/preview/class/button/) [Live preview of the Button classes](/preview/class/button/) The classes below, on plain markup, in their own document — so what you are looking at is core's stylesheet and nothing else. The button. Ready-made classes; nothing to configure. TWO AXES, and keeping them apart is the whole design. An INTENT says which colour the button means; a FILL says what to do with that colour. Seven intents times five fills is thirty-five buttons out of twelve classes, and every combination works because neither axis knows about the other. An intent on its own is a ghost — the quietest form, and the one a chat uses most. Add a fill when the button has to carry more weight. `.aparte-btn-group` fuses adjacent buttons into ONE control: the inner corners drop, the shared borders overlap, and only the group’s ends keep the radius. Give the wrapper `role="group"` and a name — visually one control, it should read as one too. A SEGMENTED control is the same group with one segment selected: mark it `aria-pressed="true"` (or `aria-selected="true"` under `role="tablist"`) and it is painted solid in the group’s intent, at rest — not only under the pointer. This layer is NEUTRAL: it is what a UI library offers, not a summary of what this repo happens to use. A variant nothing wears still ships, because the plugin that needs it is not written yet, and its absence is what makes an author invent a seventh shade of orange. Your own class stays on the element, for events and for targeting. It just stops carrying the look. ## Classes [Section titled “Classes”](#classes) `.aparte-btn` · `.aparte-btn--block` · `.aparte-btn--circle` · `.aparte-btn--danger` · `.aparte-btn--ghost` · `.aparte-btn--icon` · `.aparte-btn--info` · `.aparte-btn--lg` · `.aparte-btn--neutral` · `.aparte-btn--outline` · `.aparte-btn--pill` · `.aparte-btn--primary` · `.aparte-btn--secondary` · `.aparte-btn--sm` · `.aparte-btn--soft` · `.aparte-btn--solid` · `.aparte-btn--success` · `.aparte-btn--surface` · `.aparte-btn--warning` · `.aparte-btn--xl` · `.aparte-btn-group` · `.aparte-btn__icon` · `.aparte-icon` The tokens these read are on the [CSS variables](/reference/css-variables/) reference; every class of the kit, on one page, is the [classes reference](/reference/classes/). # Card > A surface with optional header/body/footer Width 375 768 Full [Open in a tab](/preview/class/card/) [Live preview of the Card classes](/preview/class/card/) The classes below, on plain markup, in their own document — so what you are looking at is core's stylesheet and nothing else. A surface with optional header/body/footer ## Classes [Section titled “Classes”](#classes) `.aparte-card` · `.aparte-card__body` · `.aparte-card__footer` · `.aparte-card__header` The tokens these read are on the [CSS variables](/reference/css-variables/) reference; every class of the kit, on one page, is the [classes reference](/reference/classes/). # Dialog > A modal, drawn on the native . Width 375 768 Full [Open in a tab](/preview/class/dialog/) [Live preview of the Dialog classes](/preview/class/dialog/) The classes below, on plain markup, in their own document — so what you are looking at is core's stylesheet and nothing else. A modal, drawn on the native \. The kit said for a year that a dialog was “deliberately absent — a modal needs a portal and a stack manager”. The browser has had both since 2022: `` with `showModal()` puts the box in the top layer above everything, traps focus, closes on Escape, hands the focus back, and draws a `::backdrop`. So this recipe styles THAT element, and core’s one addition is the wiring — a `data-aparte-dialog-open` on any control opens the dialog it names, a `data-aparte-dialog-close` inside one closes it, and a click on the backdrop closes it unless the dialog carries `data-aparte-dialog-static`. Nothing here reaches for position:fixed on your behalf: the top layer is the browser’s. Sizes: `--sm` (24rem), the default (32rem), `--lg` (48rem); under 30rem of window every size becomes a full-screen sheet, which is what a settings hub is on a phone. The value passed to `data-aparte-dialog-close="…"` becomes the dialog’s `returnValue`, read in its native `close` event. ## Classes [Section titled “Classes”](#classes) `.aparte-dialog` · `.aparte-dialog--lg` · `.aparte-dialog--sm` · `.aparte-dialog__body` · `.aparte-dialog__close` · `.aparte-dialog__footer` · `.aparte-dialog__header` · `.aparte-dialog__title` The tokens these read are on the [CSS variables](/reference/css-variables/) reference; every class of the kit, on one page, is the [classes reference](/reference/classes/). # Divider > Horizontal, vertical, optionally labelled The line is a BORDER, not a background — see the progress bar's comment above for why: a background disappears under forced-colors, a border does not. Width 375 768 Full [Open in a tab](/preview/class/divider/) [Live preview of the Divider classes](/preview/class/divider/) The classes below, on plain markup, in their own document — so what you are looking at is core's stylesheet and nothing else. Horizontal, vertical, optionally labelled The line is a BORDER, not a background — see the progress bar’s comment above for why: a background disappears under forced-colors, a border does not. ## Classes [Section titled “Classes”](#classes) `.aparte-divider` · `.aparte-divider--label` · `.aparte-divider--vertical` · `.aparte-divider__label` The tokens these read are on the [CSS variables](/reference/css-variables/) reference; every class of the kit, on one page, is the [classes reference](/reference/classes/). # Field > Form fields. Ready-made classes; nothing to configure. Width 375 768 Full [Open in a tab](/preview/class/field/) [Live preview of the Field classes](/preview/class/field/) The classes below, on plain markup, in their own document — so what you are looking at is core's stylesheet and nothing else. Form fields. Ready-made classes; nothing to configure. Every input control a UI library offers: text entry, textarea, select, checkbox, radio, switch, range. The library renders into the LIGHT DOM — there is no shadow root and no ::part() — so a class is the only handle a consumer has onto a native form control, and `appearance: none` is how this file takes the browser’s own chrome off a checkbox/radio/switch/range so these classes can repaint it. `.aparte-field` is ONE shared recipe — border, background, radius, padding, focus ring, placeholder — applied to \, \