Skip to content
UI

Angular Chat UI

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" />.

  • Widgets are standalone Angular components with signal inputs.
  • Chat state is signals.
  • The layout renders its slots with NgComponentOutlet.

You supply every visible component. There is no default markup or style.

Quick setup

  1. Install the packages:
shell
npm i @tanstack/ai @tanstack/ai-angular @tanstack/ai-openai
  1. Put OPENAI_API_KEY in your server environment. Then add a POST route at /api/chat:
ts
import {
  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)
}
  1. Create src/app/chat.ts:
ts
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)
  }
}
  1. Render <app-chat /> and send a message.

The reply streams into the page as the model writes it.

How it works

  • createChatHook runs once, at module scope. It binds chatOptions to your components. It returns injectAppChat and the Chat component (<ai-chat>).
  • injectAppChat({ threadId }) creates one chat instance. Call it in a field initializer or a constructor.
  • The layout gets Messages, Interrupts, Queue, and Input as component inputs. message gets one message plus Parts.
  • partsComponents.text renders text. fallback catches every part type that you did not register.

Declare every input that the factory passes to a widget, even when the template does not use it.

Next steps

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().

Render a tool

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:

ts
@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.

Ask before a tool runs

Set needsApproval: true on the tool. The tool component then gets an interrupt input:

ts
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.

Ask the user your own question

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:

ts
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.

Read chat state inside a widget

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:

ts
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().

Render markdown

@tanstack/ai-angular/ui has no TextPart component. Render part().content in your own text part, with the markdown library of your choice.

Type a widget in its own file

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:

ts
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.

Advanced

Slots and props

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:

  • layout: Messages, Interrupts, Queue, and Input. If you render Input with no registered input, it renders nothing and warns once in development.
  • message: message and Parts. Pass { message: message() } to Parts.

It passes these inputs to each widget:

  • A part: part.
  • A tool: part, result, and interrupt.
  • An interrupt: interrupt.
  • A queue item: item.

injectAppChat() takes per-instance options: threadId (required), initialMessages, live, and forwardedProps. The factory also returns ChatProvider and injectChatContext.

Approvals and generic interrupts

  • A tool approval renders in the tool when you read the interrupt input.
  • To show it in the list instead, register a component under interruptsComponents.tools and render Interrupts in the layout.
  • Generic interrupts always render in the list. generic.fallback renders an unregistered id and an unbound interrupt. If the copy must be different, branch on interrupt().kind === 'unbound'.

Show queued messages

Register queue on components, then render Queue in the layout. A queue component gets one item input. Call item().cancelQueued() to drop it.