Skip to content
UI

Svelte Chat UI

createChatHook from @tanstack/ai-svelte/ui binds your chat options and components once. Tool calls and approvals stay typed.

Each screen then creates a chat with createAppChat() and renders it with UIChat. 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-svelte @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 three widget files in src/lib/chat/. Layout.svelte places the message list:
svelte
<script lang="ts">
  import type { Snippet } from 'svelte'

  let { messages }: { messages: Snippet } = $props()
</script>

<section>
  {@render messages()}
</section>

Message.svelte wraps one message:

svelte
<script lang="ts">
  import type { Snippet } from 'svelte'
  import type { MessageProps } from '@tanstack/ai-svelte/ui'
  import type { chatOptions } from './chat-ui'

  let {
    message,
    parts,
  }: MessageProps<typeof chatOptions> & { parts: Snippet } = $props()
</script>

<article data-role={message.role}>
  {@render parts()}
</article>

Text.svelte renders a text part:

svelte
<script lang="ts">
  import type { PartProps } from '@tanstack/ai-svelte/ui'
  import type { chatOptions } from './chat-ui'

  let { part }: PartProps<typeof chatOptions, 'text'> = $props()
</script>

<p>{part.content}</p>
  1. Create src/lib/chat/chat-ui.ts:
ts
import { fetchServerSentEvents } from '@tanstack/ai-svelte'
import { createChatHook } from '@tanstack/ai-svelte/ui'
import Layout from './Layout.svelte'
import Message from './Message.svelte'
import Text from './Text.svelte'

export const chatOptions = {
  connection: fetchServerSentEvents('/api/chat'),
}

export const { createAppChat, ui } = createChatHook({
  options: chatOptions,
  components: { layout: Layout, message: Message },
  partsComponents: { text: Text },
})
  1. Create src/lib/chat/Chat.svelte:
svelte
<script lang="ts">
  import { UIChat } from '@tanstack/ai-svelte/ui'
  import { createAppChat, ui } from './chat-ui'

  const chat = createAppChat({ threadId: 'support-chat' })
  let draft = $state('')

  function send(event: SubmitEvent) {
    event.preventDefault()
    const text = draft.trim()
    if (!text) return
    draft = ''
    void chat.sendMessage(text)
  }
</script>

<main>
  <UIChat {ui} {chat} />
  <form onsubmit={send}>
    <input bind:value={draft} />
    <button type="submit">Send</button>
  </form>
</main>
  1. Render <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 ui, the descriptor that UIChat reads.
  • createAppChat({ threadId }) creates one chat instance. UIChat renders that instance with your components.
  • layout gets the messages, interrupts, queue, and input snippets. message gets one message plus the parts snippet.
  • partsComponents.text renders text. A part type with no component renders nothing and warns once in development. Register partsComponents.fallback to catch those parts.

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, and the option names are the same in Svelte.

Render a tool

Add the tool to tools in chatOptions. Then register a component under the tool name in toolsComponents. Type its props with ToolProps:

svelte
<script lang="ts">
  import type { ToolProps } from '@tanstack/ai-svelte/ui'
  import type { chatOptions } from './chat-ui'

  let { part }: ToolProps<typeof chatOptions, 'getWeather'> = $props()
</script>

{#if part.state === 'complete'}
  <p>{part.input?.city}: {part.output?.temperature}</p>
{:else}
  <p>Looking up weather</p>
{/if}

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 prop:

svelte
<script lang="ts">
  import type { ToolProps } from '@tanstack/ai-svelte/ui'
  import type { chatOptions } from './chat-ui'

  let { part, interrupt }: ToolProps<typeof chatOptions, 'deleteFile'> =
    $props()
</script>

{#if interrupt?.status === 'pending'}
  <button onclick={() => interrupt?.resolveInterrupt(true)}>
    Delete {part.input?.path}
  </button>
{:else}
  <p>{part.input?.path}</p>
{/if}

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 the interrupts snippet in the layout:

svelte
<script lang="ts">
  import type { InterruptProps } from '@tanstack/ai-svelte/ui'
  import type { chatOptions } from './chat-ui'

  let { interrupt }: InterruptProps<typeof chatOptions, 'choosePlan'> =
    $props()
</script>

<button onclick={() => interrupt.resolveInterrupt('pro')}>
  {interrupt.payload?.title}
</button>

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 a prop. Import ui and call ui.useChatContext() inside the widget. This also moves the composer into the layout. Create Input.svelte:

svelte
<script lang="ts">
  import { ui } from './chat-ui'

  const chat = ui.useChatContext()
  let draft = $state('')

  function send(event: SubmitEvent) {
    event.preventDefault()
    const text = draft.trim()
    if (!text) return
    draft = ''
    void chat.sendMessage(text)
  }
</script>

<form onsubmit={send}>
  <input bind:value={draft} disabled={chat.isLoading} />
  <button type="submit">Send</button>
</form>

Register it as components.input. Then render {@render input()} in Layout.svelte, and remove the form from Chat.svelte.

Call ui.useChatContext() only inside UIChat or UIProvider. A call outside that tree throws.

Render markdown

@tanstack/ai-svelte/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

Every widget above is already in its own file. Type its $props() with ToolProps, PartProps, InterruptProps, or MessageProps. Pass typeof chatOptions and a name.

You now have a typed chat UI in Svelte. 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. In Svelte, components place content with snippets:

  • layout: the messages, interrupts, queue, and input snippets.
  • message: the message prop and the parts snippet.

createAppChat() takes per-instance options: threadId (required), initialMessages, live, and forwardedProps.

Approvals and generic interrupts

  • A tool approval renders in the tool when you read the interrupt prop.
  • To show it in the list instead, register a component under interruptsComponents.tools and render the interrupts snippet.
  • 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 {@render queue()} in the layout. A queue component gets one item. Call item.cancelQueued() to drop it.