Angular AI chat component, standalone — @aparte/angular
@aparte/angular wraps @aparte/core for Angular 19: an ergonomic <aparte-chat> standalone
component, services for the client and conversations, a typed directive for every element, and a
generic <aparte-ui> escape hatch.
npm install @aparte/angular @aparte/core @angular/core @angular/common rxjs@aparte/core, @angular/core, @angular/common and rxjs are peer dependencies.
<aparte-chat>
Section titled “<aparte-chat>”The components are standalone — import them directly, no NgModule:
import { Component } from '@angular/core';import { AparteChatComponent, type AparteMessage } from '@aparte/angular';import '@aparte/core/styles.css';
@Component({ standalone: true, imports: [AparteChatComponent], template: ` <aparte-chat centerWhenEmpty (messagesChange)="messages = $event"> <p slot="empty-state">Ask me anything…</p> </aparte-chat> `,})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. For a fully custom
bubble, pass a template instead:
<aparte-chat [messages]="messages" [bubbleTemplate]="tpl"></aparte-chat><ng-template #tpl let-message> <div class="my-bubble">{{ message.content }}</div></ng-template>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. The imperative API (streaming, branch/edit, scrollToBottom,
getViewport) is on the component instance — grab it with a @ViewChild. injectTokenStream
takes the cross-wrapper AsyncIterable<string> — the exact call that works on React/Vue/Svelte —
or an RxJS Observable<string> (the Angular-idiomatic shape); everything else mirrors the
other wrappers.
Wiring a real model
Section titled “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:
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:
// only with autoConnect: false — the manual escape hatchimport { 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 <aparte-chat> instead of aparteGlobalConfig.
Any aparté element: a typed directive
Section titled “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:
import { AparteSelectDirective } from '@aparte/angular';// then: @Component({ imports: [AparteSelectDirective], … })@if (showPicker) { <aparte-select [searchable]="true" placeholder="Pick a model" (selectChange)="use($event.value)" ></aparte-select>}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.
Any OTHER element: <aparte-ui>
Section titled “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:
<aparte-ui name="my-token-counter" [props]="{ 'data-budget': '8000', '--glow-speed': '4s' }" (elementEvent)="onEvent($event)"/>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”ConversationManagerService— signal-based view over the coreAparteConversationManager(list / create / archive), for a multi-conversation sidebar.
Testing it
Section titled “Testing it”Vitest, Karma — every runner — executes on Node, so @aparte/core resolves to its DOM-free
entry and no <aparte-*> 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, the
entry with the elements in it.