createChatHook from @tanstack/ai-angular/ui binds your chat options and components once. Tool calls and approvals stay typed.
Each screen then creates a chat with injectAppChat() and renders it with <ai-chat [chat]="chat" />.
You supply every visible component. There is no default markup or style.
npm i @tanstack/ai @tanstack/ai-angular @tanstack/ai-openaipnpm add @tanstack/ai @tanstack/ai-angular @tanstack/ai-openaiyarn add @tanstack/ai @tanstack/ai-angular @tanstack/ai-openaibun add @tanstack/ai @tanstack/ai-angular @tanstack/ai-openaiimport {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
export async function POST(request: Request) {
const { messages, threadId, runId } = await chatParamsFromRequest(request)
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages,
threadId,
runId,
})
return toServerSentEventsResponse(stream)
}import { Component, input } from '@angular/core'
import type { Type } from '@angular/core'
import { NgComponentOutlet } from '@angular/common'
import { fetchServerSentEvents } from '@tanstack/ai-angular'
import {
createChatHook,
type MessageProps,
type PartProps,
} from '@tanstack/ai-angular/ui'
const chatOptions = {
connection: fetchServerSentEvents('/api/chat'),
}
@Component({
selector: 'app-chat-layout',
imports: [NgComponentOutlet],
template: `
<section>
<ng-container [ngComponentOutlet]="Messages()" />
</section>
`,
})
class ChatLayout {
Messages = input.required<Type<unknown>>()
Interrupts = input.required<Type<unknown>>()
Queue = input.required<Type<unknown>>()
Input = input<Type<unknown>>()
}
@Component({
selector: 'app-chat-message',
imports: [NgComponentOutlet],
template: `
<article [attr.data-role]="message().role">
<ng-container
[ngComponentOutlet]="Parts()"
[ngComponentOutletInputs]="{ message: message() }"
/>
</article>
`,
})
class ChatMessage {
message = input.required<MessageProps<typeof chatOptions>['message']>()
Parts = input.required<Type<unknown>>()
}
@Component({
selector: 'app-text-part',
template: `<p>{{ part().content }}</p>`,
})
class TextPartView {
part = input.required<PartProps<typeof chatOptions, 'text'>['part']>()
}
@Component({
selector: 'app-empty-part',
template: ``,
})
class EmptyPart {
part = input<unknown>()
}
const { injectAppChat, Chat } = createChatHook({
options: chatOptions,
components: { layout: ChatLayout, message: ChatMessage },
partsComponents: { text: TextPartView, fallback: EmptyPart },
})
@Component({
selector: 'app-chat',
imports: [Chat],
template: `
<main>
<ai-chat [chat]="chat" />
<form (submit)="send($event, field)">
<input #field name="message" />
<button type="submit">Send</button>
</form>
</main>
`,
})
export class ChatScreen {
chat = injectAppChat({ threadId: 'support-chat' })
send(event: Event, field: HTMLInputElement) {
event.preventDefault()
const text = field.value.trim()
if (!text) return
field.value = ''
void this.chat.sendMessage(text)
}
}The reply streams into the page as the model writes it.
Declare every input that the factory passes to a widget, even when the template does not use it.
Each step adds one thing to the quick setup. The chat UI recipes have the full file for each one. The recipes use React. In Angular, each widget is a component class with input().
Add the tool to tools in chatOptions. Then register a component under the tool name in toolsComponents. A tool component gets the part, result, and interrupt inputs:
@Component({
selector: 'app-weather-tool',
template: `
@if (part().state === 'complete') {
<p>{{ part().input?.city }}: {{ part().output?.temperature }}</p>
} @else {
<p>Looking up weather</p>
}
`,
})
class WeatherTool {
part = input.required<ToolProps<typeof chatOptions, 'getWeather'>['part']>()
result = input<unknown>()
interrupt = input<unknown>()
}
// In createChatHook:
toolsComponents: { getWeather: WeatherTool },The factory needs one entry for every tool in chatOptions. Full example: Make a tool look right.
Set needsApproval: true on the tool. The tool component then gets an interrupt input:
type DeleteFileProps = ToolProps<typeof chatOptions, 'deleteFile'>
@Component({
selector: 'app-delete-file-tool',
template: `
@if (interrupt()?.status === 'pending') {
<button (click)="interrupt()?.resolveInterrupt(true)">
Delete {{ part().input?.path }}
</button>
} @else {
<p>{{ part().input?.path }}</p>
}
`,
})
class DeleteFileTool {
part = input.required<DeleteFileProps['part']>()
result = input<DeleteFileProps['result']>()
interrupt = input<DeleteFileProps['interrupt']>()
}resolveInterrupt(true) approves. resolveInterrupt(false) denies. Full example: Ask before a tool runs.
Define the question with defineInterrupt. Add it to interrupts in chatOptions. Register a component under its id in interruptsComponents.generic, and render Interrupts in the layout:
const choosePlan = defineInterrupt({
id: 'choosePlan',
payloadSchema: z.object({ title: z.string() }),
responseSchema: z.string(),
})
@Component({
selector: 'app-choose-plan',
template: `
<button (click)="interrupt().resolveInterrupt('pro')">
{{ interrupt().payload?.title }}
</button>
`,
})
class ChoosePlan {
interrupt =
input.required<InterruptProps<typeof chatOptions, 'choosePlan'>['interrupt']>()
}
// In createChatHook:
interruptsComponents: { generic: { choosePlan: ChoosePlan } },The factory needs one entry for every interrupt id in chatOptions. Full example: Ask the user your own question.
Mapped components do not get chat as an input. Call injectChatContext() in a field initializer of the widget. This also moves the composer into the layout: register it as input, then render Input in the layout.
Create the contexts first with createChatHookContexts(), and pass them under context:
import { createChatHookContexts } from '@tanstack/ai-angular/ui'
const contexts = createChatHookContexts()
@Component({
selector: 'app-chat-input',
template: `
<form (submit)="send($event, field)">
<input #field name="message" [disabled]="chat.isLoading()" />
<button type="submit">Send</button>
</form>
`,
})
class ChatInput {
chat = contexts.injectChatContext()
send(event: Event, field: HTMLInputElement) {
event.preventDefault()
const text = field.value.trim()
if (!text) return
field.value = ''
void this.chat.sendMessage(text)
}
}
// In createChatHook:
context: {
chatRef: contexts.chatRef,
part: contexts.part,
interrupt: contexts.interrupt,
},
components: { input: ChatInput, layout: ChatLayout, message: ChatMessage },In the layout template, render the input with <ng-container [ngComponentOutlet]="Input()" />. Then remove the form from ChatScreen.
Chat state is signals. Read it by calling it, such as chat.messages().
@tanstack/ai-angular/ui has no TextPart component. Render part().content in your own text part, with the markdown library of your choice.
Type each signal input from the prop types: ToolProps, PartProps, InterruptProps, and MessageProps. Pass typeof chatOptions and a name, then index the prop that you need:
part = input.required<ToolProps<typeof chatOptions, 'getWeather'>['part']>()Put chatOptions in a shared module so every widget file can import its type.
You now have a typed chat UI in Angular. Each widget is your own component.
The config keys, part keys, and prop types are the same as in React. See Every widget slot.
The factory passes these inputs to the chrome:
It passes these inputs to each widget:
injectAppChat() takes per-instance options: threadId (required), initialMessages, live, and forwardedProps. The factory also returns ChatProvider and injectChatContext.
Register queue on components, then render Queue in the layout. A queue component gets one item input. Call item().cancelQueued() to drop it.