API

@tanstack/ai-remix

Install @tanstack/ai-remix, then call createChat(handle, options) in a Remix setup function. The package publishes uncompiled source. Remix compiles JSX through jsxImportSource remix/ui.

For a typed headless chat UI, see Remix Chat UI. Import createChatHook from @tanstack/ai-remix/ui.

Installation

shell
npm i @tanstack/ai-remix remix

remix is a required peer.

Server

A Remix controller action can return the same SSE Response as any other host.

ts
import {
  chat,
  chatParamsFromRequest,
  toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { createController } from 'remix/router'
import { post, route } from 'remix/routes'

const routes = route({
  chat: {
    stream: post('/chat'),
  },
})

export default createController(routes.chat, {
  actions: {
    async stream({ request }) {
      const { messages, threadId, runId } =
        await chatParamsFromRequest(request)

      const stream = chat({
        adapter: openaiText('gpt-5.6'),
        messages,
        threadId,
        runId,
      })

      return toServerSentEventsResponse(stream)
    },
  },
})

The matching client calls createChat and streams from that route. See Quick Start.

createChat(handle, options)

Manages chat state in a Remix component. Pass the component Handle as the first argument. Put connection and tools in setup. They are not serializable clientEntry props.

tsx
import { createChat, fetchServerSentEvents } from '@tanstack/ai-remix'
import {
  createChatClientOptions,
  type InferChatMessages,
} from '@tanstack/ai-client'
import { toolDefinition } from '@tanstack/ai'
import { clientEntry, on, type Handle } from 'remix/ui'
import { z } from 'zod'

const updateUIDef = toolDefinition({
  name: 'updateUI',
  description: 'Show a notification in the UI',
  inputSchema: z.object({ message: z.string() }),
})

export const ChatComponent = clientEntry(
  import.meta.url,
  function ChatComponent(handle: Handle) {
    let notification: string | null = null
    const updateUI = updateUIDef.client((input) => {
      notification = input.message
      return { success: true }
    })
    const tools = [updateUI]

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

    type ChatMessages = InferChatMessages<typeof chatOptions>

    const chat = createChat(handle, chatOptions)

    return () => (
      <div>
        {notification}
        {chat.isLoading ? 'Loading' : null}
        {chat.error ? chat.error.message : null}
        <button
          type="button"
          mix={on('click', () => {
            void chat.sendMessage('hi')
          })}
        >
          Send
        </button>
        {chat.messages.length}
      </div>
    )
  },
)

Read chat.messages and chat.isLoading in the render function so each paint sees the latest values. The default thread id is options.threadId ?? handle.id. When handle.signal aborts, cleanup runs.

Options you pass first

Extends ChatClientOptions from @tanstack/ai-client. Pass connection or fetcher, not both. Handle is the first argument, not an option.

  • connection or fetcher - how the helper talks to your server

  • tools? - client tool implementations from .client()

  • threadId? - the only identity for this chat. Required when persistence is on

  • initialMessages? - starting transcript

  • forwardedProps? - JSON sent to the server on the AG-UI forwardedProps field

Options you add later

  • live? - subscribe on setup, unsubscribe on dispose

  • queue? - what to do when sendMessage runs while a turn is in flight. Default queues

  • interrupts? - typed interrupt definitions

  • context? - client-only runtime context for client tools. Not sent to the server

  • onResponse? / onChunk? / onFinish? / onError? / onInterruptStateChange?

  • devtools? - display options. The helper always tags framework: 'remix'

  • body? - deprecated. Use forwardedProps

    Client tools run automatically.

Returns

ts
import type { UIMessage } from '@tanstack/ai-remix'
import type { ModelMessage } from '@tanstack/ai/client'
import type {
  BoundInterrupts,
  MultimodalContent,
  ChatClientState,
  ConnectionStatus,
  QueuedMessage,
  SendMessageOptions,
} from '@tanstack/ai-client'

interface CreateChatReturn {
  messages: Array<UIMessage>
  sendMessage: (
    content: string | MultimodalContent,
    options?: SendMessageOptions,
  ) => Promise<void>
  append: (message: ModelMessage | UIMessage) => Promise<void>
  addToolResult: (result: {
    toolCallId: string
    tool: string
    output: unknown
    state?: 'output-available' | 'output-error'
    errorText?: string
  }) => Promise<void>
  interrupts: BoundInterrupts
  resolveInterrupts: (approved: boolean) => void
  reload: () => Promise<void>
  stop: () => void
  isLoading: boolean
  error: Error | undefined
  status: ChatClientState
  isSubscribed: boolean
  connectionStatus: ConnectionStatus
  sessionGenerating: boolean
  setMessages: (messages: Array<UIMessage>) => void
  clear: () => void
  queue: Array<QueuedMessage>
  cancelQueued: (id: string) => void
  runId: string | null
}

State fields are getters. queue holds sends that wait while a run is busy. runId is the in-flight turn, or null.

For a tool with needsApproval: true, read chat.interrupts. Call interrupt.resolveInterrupt(true) on the bound tool-approval item.

Connection adapters

Re-exported from @tanstack/ai-client:

ts
import {
  fetchServerSentEvents,
  fetchHttpStream,
  stream,
  type ConnectionAdapter,
} from '@tanstack/ai-remix'

Example: basic chat

tsx
import { createChat, fetchServerSentEvents } from '@tanstack/ai-remix'
import { clientEntry, on, type Handle } from 'remix/ui'

export const Chat = clientEntry(
  import.meta.url,
  function Chat(handle: Handle) {
    const chat = createChat(handle, {
      connection: fetchServerSentEvents('/chat'),
    })

    return () => (
      <div>
        {chat.messages.map((message) => (
          <div key={message.id}>
            <strong>{message.role}:</strong>
            {message.parts
              .filter((part) => part.type === 'text')
              .map((part) => part.content)
              .join('')}
          </div>
        ))}
        <form
          mix={on('submit', (event) => {
            event.preventDefault()
            const form = event.currentTarget
            const text = String(new FormData(form).get('message') ?? '').trim()
            if (text === '') {
              return
            }
            form.reset()
            void chat.sendMessage(text)
          })}
        >
          <input name="message" disabled={chat.isLoading} />
          <button type="submit" disabled={chat.isLoading}>
            Send
          </button>
        </form>
      </div>
    )
  },
)

Headless chat UI

For a typed chat layout with your own Remix components, see Remix Chat UI. Call createChatHook from @tanstack/ai-remix/ui once at module scope. Your app calls createAppChat(handle) and renders <ui.Chat chat={chat} />.

Other helpers

Each helper takes the Remix Handle as the first argument. Call it in setup.

createByok(handle, client)

Subscribe to a BYOK snapshot. The return is a getter for the latest snapshot.

tsx
import { createByok } from '@tanstack/ai-remix'
import type { Handle } from 'remix/ui'
import { byok } from './byok'

function Keys(handle: Handle) {
  const getSnapshot = createByok(handle, byok)
  return () => <p>{JSON.stringify(getSnapshot().status)}</p>
}

Call byok.update(provider, value) from your own UI to save a key. See Bring Your Own Key.

createRealtimeChat(handle, options)

Realtime voice chat. Pass getToken and adapter.

tsx
import { createRealtimeChat } from '@tanstack/ai-remix'
import { openaiRealtime } from '@tanstack/ai-openai'
import { on, type Handle } from 'remix/ui'

function VoiceChat(handle: Handle) {
  const chat = createRealtimeChat(handle, {
    getToken: () => fetch('/api/realtime-token').then((response) => response.json()),
    adapter: openaiRealtime(),
  })

  return () => (
    <div>
      <p>Status: {chat.status}</p>
      <button
        type="button"
        mix={on('click', () => {
          if (chat.status === 'idle') {
            void chat.connect()
            return
          }
          chat.disconnect()
        })}
      >
        {chat.status === 'idle' ? 'Start' : 'Stop'}
      </button>
    </div>
  )
}

createGeneration(handle, options)

Base helper for one-shot generation. Pass connection or fetcher. Call generate().

tsx
import { createGeneration, fetchServerSentEvents } from '@tanstack/ai-remix'
import { on, type Handle } from 'remix/ui'

function CustomGenerator(handle: Handle) {
  const gen = createGeneration(handle, {
    connection: fetchServerSentEvents('/api/generate/custom'),
  })

  return () => (
    <div>
      <button
        type="button"
        mix={on('click', () => {
          void gen.generate({ prompt: 'Hello' })
        })}
      >
        Generate
      </button>
      {gen.isLoading ? <p>Generating</p> : null}
    </div>
  )
}

createGenerateImage(handle, options)

Image generation. generate() accepts ImageGenerateInput. The result is ImageGenerationResult.

tsx
import { createGenerateImage, fetchServerSentEvents } from '@tanstack/ai-remix'
import { on, type Handle } from 'remix/ui'

function ImageGenerator(handle: Handle) {
  const image = createGenerateImage(handle, {
    connection: fetchServerSentEvents('/api/generate/image'),
  })

  return () => (
    <div>
      <button
        type="button"
        mix={on('click', () => {
          void image.generate({ prompt: 'A sunset over mountains' })
        })}
      >
        Generate
      </button>
      {image.isLoading ? <p>Generating</p> : null}
    </div>
  )
}

The package also exports createGenerateAudio, createGenerateSpeech, createGenerateVideo, createTranscription, createSummarize, createAudioRecorder, and createMcpAppBridge.

Types

Re-exported from @tanstack/ai-client:

  • UIMessage<TTools>

  • ChatClientOptions<TTools, TContext>

  • InferChatMessages<T>

  • QueuedMessage, SendMessageOptions, WhenBusy

Next