Docs
CodeRabbit
Cloudflare
AG Grid
SerpAPI
Netlify
OpenRouter
Neon
WorkOS
Clerk
Electric
PowerSync
Sentry
Railway
Prisma
Strapi
Unkey
CodeRabbit
Cloudflare
AG Grid
SerpAPI
Netlify
OpenRouter
Neon
WorkOS
Clerk
Electric
PowerSync
Sentry
Railway
Prisma
Strapi
Unkey
Class References
Function References
Interface References
Type Alias References
Variable References
Getting Started

Quick Start: Angular

You have an Angular app and want to add AI chat. By the end of this guide, you'll have a streaming chat component powered by TanStack AI and OpenAI.

Tip: If you'd prefer not to sign up with individual AI providers, OpenRouter gives you access to 300+ models with a single API key and is the easiest way to get started.

Installation

sh
npm install @tanstack/ai @tanstack/ai-angular @tanstack/ai-openai
# or
pnpm add @tanstack/ai @tanstack/ai-angular @tanstack/ai-openai
# or
yarn add @tanstack/ai @tanstack/ai-angular @tanstack/ai-openai
npm install @tanstack/ai @tanstack/ai-angular @tanstack/ai-openai
# or
pnpm add @tanstack/ai @tanstack/ai-angular @tanstack/ai-openai
# or
yarn add @tanstack/ai @tanstack/ai-angular @tanstack/ai-openai

Server Setup

Angular apps typically use a separate backend. Here's an Express server that streams chat responses:

typescript
import express from 'express'
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'

const app = express()
app.use(express.json())

app.post('/api/chat', async (req, res) => {
  const { messages } = req.body

  if (!process.env.OPENAI_API_KEY) {
    res.status(500).json({ error: 'OPENAI_API_KEY not configured' })
    return
  }

  try {
    // `chat()` uses the AG-UI `threadId` for devtools correlation
    // when available — no need to plumb `conversationId` manually.
    const stream = chat({
      adapter: openaiText('gpt-5.5'),
      messages,
    })

    const response = toServerSentEventsResponse(stream)
    res.writeHead(response.status, Object.fromEntries(response.headers))

    const body = response.body
    if (body) {
      const reader = body.getReader()
      const pump = async () => {
        const { done, value } = await reader.read()
        if (done) {
          res.end()
          return
        }
        res.write(value)
        await pump()
      }
      await pump()
    }
  } catch (error) {
    res.status(500).json({
      error: error instanceof Error ? error.message : 'An error occurred',
    })
  }
})

app.listen(3000, () => console.log('Server running on port 3000'))
import express from 'express'
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'

const app = express()
app.use(express.json())

app.post('/api/chat', async (req, res) => {
  const { messages } = req.body

  if (!process.env.OPENAI_API_KEY) {
    res.status(500).json({ error: 'OPENAI_API_KEY not configured' })
    return
  }

  try {
    // `chat()` uses the AG-UI `threadId` for devtools correlation
    // when available — no need to plumb `conversationId` manually.
    const stream = chat({
      adapter: openaiText('gpt-5.5'),
      messages,
    })

    const response = toServerSentEventsResponse(stream)
    res.writeHead(response.status, Object.fromEntries(response.headers))

    const body = response.body
    if (body) {
      const reader = body.getReader()
      const pump = async () => {
        const { done, value } = await reader.read()
        if (done) {
          res.end()
          return
        }
        res.write(value)
        await pump()
      }
      await pump()
    }
  } catch (error) {
    res.status(500).json({
      error: error instanceof Error ? error.message : 'An error occurred',
    })
  }
})

app.listen(3000, () => console.log('Server running on port 3000'))

Tip: Any backend that returns the TanStack AI SSE format works — you can use Fastify, Hono, Nitro, or any other Node.js framework.

Client Setup

Create a standalone ChatComponent using the injectChat function:

typescript
import { Component, signal } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { injectChat } from '@tanstack/ai-angular'
import { fetchServerSentEvents } from '@tanstack/ai-client'

@Component({
  selector: 'app-chat',
  standalone: true,
  imports: [FormsModule],
  template: `
    <div class="chat">
      <div class="messages">
        @for (message of chat.messages(); track message.id) {
          <div [class]="message.role">
            <strong>{{ message.role === 'assistant' ? 'Assistant' : 'You' }}</strong>
            @for (part of message.parts; track $index) {
              @if (part.type === 'text') {
                <p>{{ part.content }}</p>
              }
            }
          </div>
        }
      </div>

      <form (ngSubmit)="handleSubmit()">
        <input
          [(ngModel)]="input"
          name="input"
          placeholder="Type a message..."
          [disabled]="chat.isLoading()"
        />
        <button
          type="submit"
          [disabled]="!input().trim() || chat.isLoading()"
        >
          Send
        </button>
      </form>
    </div>
  `,
})
export class ChatComponent {
  // injectChat is called in a field initializer — this is a valid injection context.
  chat = injectChat({
    connection: fetchServerSentEvents('/api/chat'),
  })

  input = signal('')

  handleSubmit() {
    const text = this.input().trim()
    if (text && !this.chat.isLoading()) {
      this.chat.sendMessage(text)
      this.input.set('')
    }
  }
}
import { Component, signal } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { injectChat } from '@tanstack/ai-angular'
import { fetchServerSentEvents } from '@tanstack/ai-client'

@Component({
  selector: 'app-chat',
  standalone: true,
  imports: [FormsModule],
  template: `
    <div class="chat">
      <div class="messages">
        @for (message of chat.messages(); track message.id) {
          <div [class]="message.role">
            <strong>{{ message.role === 'assistant' ? 'Assistant' : 'You' }}</strong>
            @for (part of message.parts; track $index) {
              @if (part.type === 'text') {
                <p>{{ part.content }}</p>
              }
            }
          </div>
        }
      </div>

      <form (ngSubmit)="handleSubmit()">
        <input
          [(ngModel)]="input"
          name="input"
          placeholder="Type a message..."
          [disabled]="chat.isLoading()"
        />
        <button
          type="submit"
          [disabled]="!input().trim() || chat.isLoading()"
        >
          Send
        </button>
      </form>
    </div>
  `,
})
export class ChatComponent {
  // injectChat is called in a field initializer — this is a valid injection context.
  chat = injectChat({
    connection: fetchServerSentEvents('/api/chat'),
  })

  input = signal('')

  handleSubmit() {
    const text = this.input().trim()
    if (text && !this.chat.isLoading()) {
      this.chat.sendMessage(text)
      this.input.set('')
    }
  }
}

Environment Variables

Create a .env file (or .env.local depending on your setup) with your API key:

sh
# OpenRouter (recommended — access 300+ models with one key)
OPENROUTER_API_KEY=sk-or-...

# OpenAI
OPENAI_API_KEY=your-openai-api-key
# OpenRouter (recommended — access 300+ models with one key)
OPENROUTER_API_KEY=sk-or-...

# OpenAI
OPENAI_API_KEY=your-openai-api-key

Your server reads this key at runtime. Never expose it to the browser.

Angular-Specific Notes

State is exposed as Angular Signals. The injectChat function returns state wrapped in read-only Signals. Read them by calling them as functions:

typescript
// In component class
if (this.chat.isLoading()) { /* ... */ }
const count = this.chat.messages().length

// In template — same syntax, no .value needed
// In component class
if (this.chat.isLoading()) { /* ... */ }
const count = this.chat.messages().length

// In template — same syntax, no .value needed
html
<!-- In template, call the signal as a function -->
@if (chat.isLoading()) {
  <p>Thinking...</p>
}
<span>{{ chat.messages().length }} messages</span>
<!-- In template, call the signal as a function -->
@if (chat.isLoading()) {
  <p>Thinking...</p>
}
<span>{{ chat.messages().length }} messages</span>

injectChat must be called in an injection context. Angular's dependency injection requires that inject() is called during component construction. The recommended approach is a field initializer (shown above). You can also call it in the constructor or inside runInInjectionContext:

typescript
// Field initializer (recommended)
export class MyComponent {
  chat = injectChat({ connection: fetchServerSentEvents('/api/chat') })
}

// Constructor
export class MyComponent {
  chat: ReturnType<typeof injectChat>
  constructor() {
    this.chat = injectChat({ connection: fetchServerSentEvents('/api/chat') })
  }
}
// Field initializer (recommended)
export class MyComponent {
  chat = injectChat({ connection: fetchServerSentEvents('/api/chat') })
}

// Constructor
export class MyComponent {
  chat: ReturnType<typeof injectChat>
  constructor() {
    this.chat = injectChat({ connection: fetchServerSentEvents('/api/chat') })
  }
}

Calling injectChat outside an injection context — for example, in a lifecycle hook like ngOnInit — will throw a runtime error.

Automatic cleanup. The function subscribes to DestroyRef internally, so in-flight requests are stopped when the component is destroyed. No manual cleanup needed.

Same API shape as React and Vue. If you're coming from @tanstack/ai-react or @tanstack/ai-vue, injectChat returns the same properties (messages, sendMessage, isLoading, error, status, stop, reload, clear). The only difference is that each property is an Angular Signal rather than a React state value or a Vue ShallowRef.

That's It!

You now have a working Angular chat application. The injectChat function handles:

  • Message state management
  • Streaming responses
  • Loading states
  • Error handling

Next Steps

  • Learn about Tools to add function calling
  • Check out the Adapters to connect to different providers
  • See the React Quick Start if you're comparing frameworks