Skip to content
kitn AI/UI

Angular

Angular binds to DOM properties natively with [prop]="value" and listens to CustomEvents with (kai-event)="handler($event)". The kai-* web components slot into Angular templates directly — no wrapper library, no adapter.

Terminal window
npm install @kitn.ai/ui

The elements register asynchronously: @kitn.ai/ui/elements defers the actual customElements.define calls behind a browser check, so an import alone does not guarantee the tags exist yet. Angular writes array and object DOM properties the moment it stamps a <kai-*> tag; a write that lands before the element upgrades is discarded when the element applies its own empty defaults. Gate bootstrapApplication on the elementsReady promise the entry exports:

main.ts
import { elementsReady } from '@kitn.ai/ui/elements';
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
// Resolves once every kai-* element is defined. Angular then binds against
// upgraded elements, so [messages] / [conversations] survive.
elementsReady.then(() => bootstrapApplication(AppComponent, appConfig));

Then add CUSTOM_ELEMENTS_SCHEMA to every standalone component whose template uses kai-* tags. Without it, the Angular compiler rejects the unknown element names at build time.

app.component.ts
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
@Component({
selector: 'app-root',
standalone: true,
templateUrl: './app.component.html',
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppComponent {}

Each element styles itself inside Shadow DOM, so nothing is required to make the elements look right. Add the token sheet when you want to override the design tokens, or reuse the kit’s --color-* values for your own chrome around the chat.

Angular loads global CSS from the build config, not from a TS import './x.css', which @angular/build ignores. Add the sheet to architect.build.options.styles:

angular.json
"styles": [
"node_modules/@kitn.ai/ui/dist/theme.tokens.css",
"src/styles.css"
]

theme.tokens.css is the plain custom-property sheet. @kitn.ai/ui/theme.css is the Tailwind source layer, meant for apps that already compile Tailwind.

<kai-chat> is transport-agnostic: pass a messages array, handle (kai-submit), and stream the reply back into state. The component owns the UI; you own the request.

A message is an ordered parts array, not a string: text, reasoning, tool calls, generative-UI cards and file attachments, in the order the model produced them. ChatMessage is the kit’s own type, so import it rather than hand-rolling a text-only copy — the copy compiles and then hides every other part kind from you.

app.component.ts
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import type { ChatMessage } from '@kitn.ai/ui';
import { appendTextPart } from '@kitn.ai/ui/state';
@Component({
selector: 'app-root',
standalone: true,
templateUrl: './app.component.html',
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppComponent {
messages: ChatMessage[] = [
{ id: '1', role: 'assistant', parts: [{ type: 'text', text: 'Hello! How can I help?' }] },
];
async onSubmit(e: Event) {
const { value } = (e as CustomEvent<{ value: string }>).detail;
// Reassign a new array so Angular change detection picks it up.
const history: ChatMessage[] = [
...this.messages,
{ id: crypto.randomUUID(), role: 'user', parts: [{ type: 'text', text: value }] },
];
this.messages = history;
const aid = crypto.randomUUID();
this.messages = [...history, { id: aid, role: 'assistant', parts: [] }];
for await (const token of streamFromYourAPI(history)) {
// appendTextPart returns a NEW parts array and leaves any reasoning, tool
// or card parts alone. Replacing `parts` wholesale would drop them.
this.messages = this.messages.map((m) =>
m.id === aid ? { ...m, parts: appendTextPart(m.parts, token) } : m
);
}
}
}
app.component.html
<!-- kai-chat fills its container — use flex so it grows with flex:1
rather than a hard-coded height. -->
<div style="display: flex; flex-direction: column; height: 100dvh">
<kai-chat
[messages]="messages"
(kai-submit)="onSubmit($event)"
style="flex: 1; min-height: 0"
></kai-chat>
</div>

<kai-chat> is one option. You can assemble your own layout from individual elements. This example pairs a <kai-conversations> sidebar with a <kai-chat> thread:

workspace.component.ts
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
@Component({
selector: 'app-workspace',
standalone: true,
templateUrl: './workspace.component.html',
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class WorkspaceComponent {
conversations = myConversations;
activeId = this.conversations[0]?.id;
messages = loadMessages(this.activeId);
onConversationSelect(e: Event) {
this.activeId = (e as CustomEvent<{ id: string }>).detail.id;
this.messages = loadMessages(this.activeId);
}
onSubmit(e: Event) {
const { value } = (e as CustomEvent<{ value: string }>).detail;
sendMessage(value);
}
}
workspace.component.html
<div style="display: flex; height: 100dvh">
<kai-conversations
[conversations]="conversations"
[activeId]="activeId"
(kai-conversation-select)="onConversationSelect($event)"
(kai-new-chat)="startNewConversation()"
style="width: 300px; flex-shrink: 0"
></kai-conversations>
<kai-chat
[messages]="messages"
(kai-submit)="onSubmit($event)"
style="flex: 1; min-width: 0"
></kai-chat>
</div>

Wrap panels in <kai-resizable> with one <kai-resizable-item> each to add a draggable divider. Each item takes a size (px or %) and optional min/max. Listen for (kai-change) to persist the layout.

<div style="display: flex; flex-direction: column; height: 100dvh">
<kai-resizable
orientation="horizontal"
(kai-change)="onResize($event)"
style="flex: 1; min-height: 0"
>
<kai-resizable-item size="25%" min="200px">
<kai-conversations
[conversations]="conversations"
[activeId]="activeId"
(kai-conversation-select)="onConversationSelect($event)"
></kai-conversations>
</kai-resizable-item>
<kai-resizable-item>
<kai-chat
[messages]="messages"
(kai-submit)="onSubmit($event)"
></kai-chat>
</kai-resizable-item>
</kai-resizable>
</div>
onResize(e: Event) {
const { sizes } = (e as CustomEvent<{ sizes: number[] }>).detail;
// persist to localStorage, a service, etc.
localStorage.setItem('panel-sizes', JSON.stringify(sizes));
}

Rich data in as properties; interactions out as events. Angular’s [prop]="…" binding writes to the element’s DOM property directly, so arrays and objects pass through unstringified — no .prop modifier needed.

(kai-event)="handler($event)" listens for a DOM CustomEvent. The payload is on $event.detail:

ElementProperty bindingEvent$event.detail
kai-chat[messages]="messages"(kai-submit){ value: string }
kai-conversations[conversations]="conversations"(kai-conversation-select){ id: string }
kai-conversations[groups]="groups"(kai-new-chat)
kai-conversations(kai-toggle-sidebar)
kai-resizableorientation="horizontal"(kai-change){ sizes: number[] }
kai-resizable-itemsize="25%" min="200px"

You can drop individual display elements anywhere in your UI without adopting a full chat shell. <kai-markdown>, <kai-code-block>, <kai-artifact>, <kai-reasoning>, and <kai-tool> all render rich AI content as standalone elements:

<kai-markdown [content]="assistantReply"></kai-markdown>
<kai-reasoning [text]="thinkingText" (kai-open-change)="onReasoningToggle($event)"></kai-reasoning>
<kai-artifact [files]="files" (kai-file-select)="onFileSelect($event)"></kai-artifact>

Each fills its container and is controlled entirely through [prop] bindings and (event) listeners — no Shadow DOM piercing, no CSS manipulation.

streamFromYourAPI above is your POST /api/chat. Hosting it inside the Angular app requires SSR. @angular/build:dev-server exposes no middleware hook and takes no Vite plugins, so a browser-only Angular app cannot serve an endpoint at all. Its only options are proxying to a separate server or running the handler elsewhere.

With SSR enabled, the server is a plain Express app:

Terminal window
ng add @angular/ssr

That generates src/server.ts. The Angular CLI loads it through the reqHandler export at the bottom for both ng serve and ng build, so one registration covers development and production.

src/server.ts
const app = express();
const angularApp = new AngularNodeAppEngine();
// ORDER MATTERS: register the endpoint BEFORE the catch-all below. After it,
// Angular renders an HTML page for /api/chat and the browser tries to parse
// that as SSE.
app.post('/api/chat', express.json(), async (req, res) => {
// call your model, pipe the SSE body through to res
});
app.use(express.static(browserDistFolder, { maxAge: '1y', index: false, redirect: false }));
// Everything else renders the Angular application.
app.use((req, res, next) => {
angularApp
.handle(req)
.then((response) => (response ? writeResponseToNodeResponse(response, res) : next()))
.catch(next);
});
export const reqHandler = createNodeRequestHandler(app);

Two things that bite when piping a provider stream through Express: forward response.status (a 401 that reaches the browser as a 200 is a blank bubble and no error), and write each chunk as it lands rather than buffering the body.

The route’s contract, the SSE shape, and the parser that folds it back onto messages are the same in every framework: see Connect any backend and the wire adapter.