Skip to content
UI

Octane Chat UI

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

Each screen then renders the chat with one hook call. The API is the same as React. Widgets are Octane components. 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-octane @tanstack/ai-openai octane

@tanstack/ai-octane publishes uncompiled .tsrx source. Add octane/compiler/vite (or the rspack or rspeedy plugin) to your app build.

  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/chat.tsx:
tsx
import { fetchServerSentEvents } from '@tanstack/ai-octane'
import { createChatHook } from '@tanstack/ai-octane/ui'

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

const { useAppChat } = createChatHook({
  options: chatOptions,
  components: {
    layout: (props) => (
      <section>
        <props.Messages />
      </section>
    ),
    message: (props) => (
      <article data-role={props.message.role}>
        <props.Parts />
      </article>
    ),
  },
  partsComponents: {
    text: (props) => <p>{props.part.content}</p>,
    fallback: () => null,
  },
})

export function ChatScreen() {
  const chat = useAppChat({ threadId: 'support-chat' })

  return (
    <main>
      <chat.AppChat />
      <form
        onSubmit={(event) => {
          event.preventDefault()
          const field = event.currentTarget.elements.namedItem('message')
          if (!(field instanceof HTMLInputElement)) return
          const text = field.value.trim()
          if (!text) return
          field.value = ''
          void chat.sendMessage(text)
        }}
      >
        <input name="message" />
        <button type="submit">Send</button>
      </form>
    </main>
  )
}
  1. Render <ChatScreen /> 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.
  • useAppChat({ threadId }) creates one chat instance. chat.AppChat renders that instance with your components.
  • layout places the message list. message wraps one message. Parts picks a component for each part of that message.
  • partsComponents.text renders text. fallback catches every part type that you did not register.

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

Render a tool

Add the tool to tools in chatOptions. Then register a component under the tool name in toolsComponents. part.input and part.output are typed from the tool schemas.

tsx
toolsComponents: {
  getWeather: (props) =>
    props.part.state === 'complete' ? (
      <p>
        {props.part.input?.city}: {props.part.output?.temperature}
      </p>
    ) : (
      <p>Looking up weather</p>
    ),
},

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:

tsx
toolsComponents: {
  deleteFile: (props) =>
    props.interrupt?.status === 'pending' ? (
      <button onClick={() => props.interrupt?.resolveInterrupt(true)}>
        Delete {props.part.input?.path}
      </button>
    ) : (
      <p>{props.part.input?.path}</p>
    ),
},

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 props.Interrupts in the layout:

tsx
const choosePlan = defineInterrupt({
  id: 'choosePlan',
  payloadSchema: z.object({ title: z.string() }),
  responseSchema: z.string(),
})

// In createChatHook:
interruptsComponents: {
  generic: {
    choosePlan: (props) => (
      <button onClick={() => props.interrupt.resolveInterrupt('pro')}>
        {props.interrupt.payload?.title}
      </button>
    ),
    fallback: (props) => <p>{props.interrupt.reason}</p>,
  },
},

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. Call useChatContext() inside the widget. This also moves the composer into the layout: register it as input, then render props.Input.

tsx
import { fetchServerSentEvents } from '@tanstack/ai-octane'
import { createChatHook } from '@tanstack/ai-octane/ui'

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

const { useAppChat, useChatContext } = createChatHook({
  options: chatOptions,
  components: {
    input: () => {
      const chat = useChatContext()
      return (
        <form
          onSubmit={(event) => {
            event.preventDefault()
            const field = event.currentTarget.elements.namedItem('message')
            if (!(field instanceof HTMLInputElement)) return
            const text = field.value.trim()
            if (!text) return
            field.value = ''
            void chat.sendMessage(text)
          }}
        >
          <input name="message" disabled={chat.isLoading} />
          <button type="submit">Send</button>
        </form>
      )
    },
    layout: (props) => (
      <main>
        <props.Messages />
        <props.Input />
      </main>
    ),
    message: (props) => (
      <article>
        <props.Parts />
      </article>
    ),
  },
  partsComponents: {
    text: (props) => <p>{props.part.content}</p>,
    fallback: () => null,
  },
})

export function ChatScreen() {
  const chat = useAppChat({ threadId: 'support-chat' })
  return <chat.AppChat />
}

Call useChatContext() only inside chat.AppChat. A call outside that tree throws. For hooks inside a widget, import them from octane.

Render markdown

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

Move a widget to its own file and type its props with ToolProps, PartProps, or InterruptProps. Pass typeof chatOptions and a key. For a tool widget, the key is the tool name, and part.input and part.output are typed for that tool:

tsx
import { fetchServerSentEvents } from '@tanstack/ai-octane'
import { createChatHook, type ToolProps } from '@tanstack/ai-octane/ui'
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'

const getWeather = toolDefinition({
  name: 'getWeather',
  description: 'Look up weather',
  inputSchema: z.object({ city: z.string() }),
  outputSchema: z.object({ temperature: z.number() }),
}).client()

const chatOptions = {
  connection: fetchServerSentEvents('/api/chat'),
  tools: [getWeather],
}

export function WeatherTool(
  props: ToolProps<typeof chatOptions, 'getWeather'>,
) {
  return <strong>{props.part.input?.city}</strong>
}

export const { useAppChat } = createChatHook({
  options: chatOptions,
  components: {
    layout: (props) => <props.Messages />,
    message: (props) => (
      <article>
        <props.Parts />
      </article>
    ),
  },
  partsComponents: { fallback: () => null },
  toolsComponents: { getWeather: WeatherTool },
})

Put chatOptions in a shared module so every widget file can import its type.

You now have a typed chat UI in Octane. 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 Octane, layout gets Messages, Interrupts, and Queue, plus Input when you register input.

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

Approvals, interrupts, and the queue

These work the same as in React: