Skip to content

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.

Terminal window
npm install @aparte/angular @aparte/core @angular/core @angular/common rxjs
npm install @aparte/provider-scenario # the scripted model, for the first run below, with no key

@aparte/core, @angular/core, @angular/common and rxjs are peer dependencies.

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>

The component projects only those four slots. Anything else you put inside <aparte-chat> is discarded — Angular and Vue drop it at runtime, React and Svelte reject it at compile time. For full control of the input row, project into [slot='composer']; to compose the primitives yourself, build the container out of core’s own elements — each has a typed directive here, and Add the markup shows the shape.

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.

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.

Start with the scripted model: it needs no key and no network, and every line below is the same for a real one.

import { AparteDirectTransport, aparteGlobalConfig } from '@aparte/core';
import { createScenarioProvider } from '@aparte/provider-scenario';
import { showcase } from '@aparte/provider-scenario/showcase';
import { provideAparte } from '@aparte/angular';
aparteGlobalConfig.setTransport(new AparteDirectTransport({ byok: true }));
// Pass this to bootstrapApplication's `providers` — no key, no network.
export const aparte = provideAparte({ providers: [createScenarioProvider({ scenarios: showcase })] });

Swap the provider for a real one and nothing else changes:

main.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:

// 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 Angular’s DI idiom for that wiring, and it is fully optional — the components work without it, and aparteGlobalConfig.* called directly does the same job. React, Vue and Svelte have no equivalent because they need none: what this provider buys is an initializer that runs before the first component and a DestroyRef for the theme listener, and everywhere else the same handful of calls run at module scope — see provideAparte is Angular’s, and only Angular’s. 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.

The full surface is ProvideAparteOptions, and its plugins.actions slot is a code-splitting story the other three wrappers have no answer for: each entry is an ApartePluginLoader, a () => void | Promise<void> you write, so wrapping a dynamic import() puts the plugin in a chunk of its own and the app initializer awaits it before the client connects.

import { AparteDirectTransport, aparteGlobalConfig } from '@aparte/core';
import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat';
import { provideAparte } from '@aparte/angular';
import { fr } from '@aparte/locale-fr';
aparteGlobalConfig.setTransport(new AparteDirectTransport({ byok: true }));
export const aparte = provideAparte({
providers: [createOpenAICompatProvider(presets.OPENROUTER)],
plugins: {
// Loaders, not package names: each is a chunk of its own, awaited before connect.
// A loader resolves to nothing, so `await` the import instead of returning it.
actions: [
async () => {
await import('@aparte/plugin-model-selector');
},
async () => {
await import('@aparte/plugin-approval');
},
],
markdown: () => import('@aparte/plugin-marked').then((m) => m.setupMarkedProvider()),
},
modelConfig: { defaultProvider: 'openrouter', defaultModel: 'openai/gpt-4o-mini' },
locale: fr, // an AparteLocale OBJECT, never a package-name string
themeMode: 'auto', // follows prefers-color-scheme, and keeps following it
clientOptions: { maxTurns: 6 },
autoConnect: true, // the default; false hands you AparteAiService.connect()
});

The resolved options are available through APARTE_CONFIG_TOKEN, and the client’s own through APARTE_CLIENT_OPTIONS, for a service that needs to read what the app was bootstrapped with.

For a wait of your own — independent of what the client is doing, e.g. while your own store is still answering — bind [loading]:

<aparte-chat [loading]="isFetchingHistory"></aparte-chat>

See the wrapper reference for the same prop in React, Vue and Svelte.

[containerClass] and [containerStyle] land on the inner .aparte-chat-container — the div that also carries [overlay-composer] and [data-aparte-empty], the selectors core’s shell recipe keys on. Angular is the only wrapper with a host element above that div, so these two inputs are its spelling of React’s className / style and of Vue’s and Svelte’s class / style. Both are additive: the recipe’s own classes stay, and containerStyle takes a string or a { prop: value } map.

<aparte-chat
[containerClass]="'rounded-2xl border'"
[containerStyle]="{ 'max-inline-size': '48rem' }"
></aparte-chat>

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.

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.

The component behind the tag is AparteUiComponent; import it like AparteChatComponent.

  • ConversationManagerService — signal-based view over the core AparteConversationManager (list / create / archive), for a multi-conversation sidebar.

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.

A complete chat site in Angular — the conversation sidebar, the header, the settings dialog, markdown, highlighting, tools, and the scripted model — is in this repository: apps/examples/angular. It shares its setup with the vanilla, React, Vue and Svelte sites through apps/examples/_shared, so the framework-specific part is three files: src/app/app.component.ts, the main.ts that bootstraps it, and scenario-mode.ts, one injection token. Everything else is what an app writes in any framework.