- MCP Server ToolsUpdated
- Managed MCP with chat()Updated
- Manual MCPUpdated
- MCP Type GenerationUpdated
- MCP Client InputUpdated
- MCP Server Tools: AdvancedNew
- MCP EventsNew
- MCP AppsUpdated
- Serve Tools over HTTPUpdated
- Serve Resources and PromptsUpdated
- Ask for InputUpdated
- Draft Text from a ToolUpdated
- MCP Server TasksUpdated
- MCP Server AuthUpdated
- MCP Server SessionsUpdated
- MCP Server on stdioUpdated
Persistence (Advanced)
- GenerationsUpdated
- Realtime Voice ChatUpdated
- Text-to-SpeechUpdated
- TranscriptionUpdated
- Transcription: AdvancedNew
- Voice CreationUpdated
- Audio RecordingUpdated
- Audio GenerationUpdated
- Image GenerationUpdated
- Image Generation: AdvancedNew
- Video GenerationUpdated
- Video Generation: AdvancedNew
- Live GenerationUpdated
- World GenerationUpdated
- Generation HooksUpdated
- Build your first harnessUpdated
- Host optionsNew
- Send and show mediaUpdated
- Connect clientsUpdated
- Write a client in any languageNew
- Access and tenantsNew
- Run in the terminalUpdated
- Deploy a harnessUpdated
- Send inputs safelyUpdated
- Run a tool without a model
- Durable sessionsUpdated
- Run background work on workersNew
- List, rename, and fork sessionsUpdated
- Lifecycle and retentionNew
- Run side effects onceUpdated
- Wait for a personNew
- Build on the session logUpdated
- Control how a turn endsUpdated
- Store settings per threadUpdated
- Compact a long sessionUpdated
- Fork and reset a threadUpdated
- One log for a session treeUpdated
- Write a pluginUpdated
- Track usage and costUpdated
- Ask before risky tool callsUpdated
- Give your agent coding toolsUpdated
- Sandbox and extend the coding toolsUpdated
- Run the coding tools for many threadsNew
- Undo a turnUpdated
- Build a coding agentUpdated
- Delegate to coding agentsUpdated
- Switch between agent profilesUpdated
- Auth and connectorsUpdated
- Share a thread between peopleUpdated
- Connect model providersUpdated
- Build your own UIUpdated
- Code mode in a harnessUpdated
- Work until a goal is metUpdated
- Run agents from a harnessUpdated
- Hand off a conversationNew
- Add skillsUpdated
- Use MCP serversUpdated
- Give your agent a computerNew
- Use from any MCP clientUpdated
- Use a harness from an editor (ACP)Updated
- OverviewUpdated
- Quick StartUpdated
- ProvidersUpdated
- HarnessesUpdated
- Harness AuthUpdated
- WorkspaceUpdated
- ToolsUpdated
- PolicyUpdated
- Lifecycle & SnapshotsUpdated
- Portable SnapshotsUpdated
- Keep Files After ReloadUpdated
- Save a Named VersionUpdated
- Branch From a VersionUpdated
- Send a Frozen FileUpdated
- Let the Agent Save and ForkUpdated
- Pick Which Files to KeepUpdated
- What a Snapshot StoresUpdated
- Instance DurabilityUpdated
- Durable RunsUpdated
- EventsUpdated
Sandboxes (Advanced)
- Runtime ContextUpdated
- Bring Your Own KeyUpdated
- Keep Keys on the ServerNew
- Debug LoggingUpdated
- Multimodal ContentUpdated
- Files APIUpdated
- Per-Model Type SafetyUpdated
- Runtime Adapter SwitchingUpdated
- Tree-ShakingUpdated
- Extend AdapterUpdated
- Extend Adapter: AdvancedNew
- Typed Pre-Configured OptionsUpdated
- Test with a Fake ModelUpdated
- Prompt CachingUpdated
- Mid-Conversation ChangesUpdated
- Approval Flow ProcessingUpdated
Remix Chat UI
createChatHook from @tanstack/ai-remix/ui binds your chat options and components once. Tool calls and approvals stay typed.
Each screen then creates a chat with createAppChat(handle, { threadId }) and renders it with ui.Chat. Each widget is a Remix setup function that reads handle.props. You supply every visible component. There is no default markup or style.
Quick setup#
- Install the packages:
npm i @tanstack/ai @tanstack/ai-remix @tanstack/ai-openai remixpnpm add @tanstack/ai @tanstack/ai-remix @tanstack/ai-openai remixyarn add @tanstack/ai @tanstack/ai-remix @tanstack/ai-openai remixbun add @tanstack/ai @tanstack/ai-remix @tanstack/ai-openai remix- Put OPENAI_API_KEY in your server environment. Then add a POST route at /api/chat:
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)
}- Create app/chat.tsx:
import { fetchServerSentEvents } from '@tanstack/ai-remix'
import {
createChatHook,
type LayoutProps,
type MessageProps,
type PartProps,
} from '@tanstack/ai-remix/ui'
import { clientEntry, on, type Handle } from 'remix/ui'
const chatOptions = {
connection: fetchServerSentEvents('/api/chat'),
}
const { createAppChat, ui } = createChatHook({
options: chatOptions,
components: {
layout(handle: Handle<LayoutProps<typeof chatOptions>>) {
return () => {
const { Messages } = handle.props
return (
<section>
<Messages />
</section>
)
}
},
message(handle: Handle<MessageProps<typeof chatOptions>>) {
return () => {
const { message, Parts } = handle.props
return (
<article data-role={message.role}>
<Parts />
</article>
)
}
},
},
partsComponents: {
text(handle: Handle<PartProps<typeof chatOptions, 'text'>>) {
return () => <p>{handle.props.part.content}</p>
},
fallback() {
return () => null
},
},
})
export const ChatScreen = clientEntry(
import.meta.url,
function ChatScreen(handle: Handle) {
const chat = createAppChat(handle, { threadId: 'support-chat' })
return () => (
<main>
<ui.Chat chat={chat} />
<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" />
<button type="submit">Send</button>
</form>
</main>
)
},
)- 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. It returns ui, the kit with ui.Chat.
- createAppChat(handle, { threadId }) creates one chat instance inside a setup function. ui.Chat 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.
Render Messages and Parts as tags, such as <Messages />, not as function calls.
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 Remix.
Render a tool#
Add the tool to tools in chatOptions. Then register a component under the tool name in toolsComponents. Type its handle with ToolProps:
toolsComponents: {
getWeather(handle: Handle<ToolProps<typeof chatOptions, 'getWeather'>>) {
return () => {
const { part } = handle.props
if (part.state !== 'complete') return <p>Looking up weather</p>
return (
<p>
{part.input?.city}: {part.output?.temperature}
</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:
toolsComponents: {
deleteFile(handle: Handle<ToolProps<typeof chatOptions, 'deleteFile'>>) {
return () => {
const { part, interrupt } = handle.props
if (interrupt?.status !== 'pending') return <p>{part.input?.path}</p>
return (
<button
mix={on('click', () => interrupt.resolveInterrupt(true))}
>
Delete {part.input?.path}
</button>
)
}
},
},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:
const choosePlan = defineInterrupt({
id: 'choosePlan',
payloadSchema: z.object({ title: z.string() }),
responseSchema: z.string(),
})
// In createChatHook:
interruptsComponents: {
generic: {
choosePlan(
handle: Handle<InterruptProps<typeof chatOptions, 'choosePlan'>>,
) {
return () => {
const { interrupt } = handle.props
return (
<button mix={on('click', () => 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(handle) inside the widget. This also moves the composer into the layout. Create app/chat-input.tsx:
import { on, type Handle, type RemixNode } from 'remix/ui'
import { ui } from './chat'
export function ChatInput(handle: Handle): () => RemixNode {
const chat = ui.useChatContext(handle)
return () => (
<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">Send</button>
</form>
)
}Then:
- Export ui from app/chat.tsx.
- Register input: ChatInput on components.
- Render <Input /> in the layout, from handle.props.
- Remove the form from ChatScreen.
The return type on ChatInput stops a circular type between the widget and the ui that registers it. Call ui.useChatContext(handle) only under ui.Chat or ui.Provider. A call outside that tree throws.
Render markdown#
TextPart from @tanstack/ai-remix/ui renders the content as plain text, with no markdown. To render markdown, render part.content in your own text part with the markdown library of your choice.
Type a widget in its own file#
Type the handle.props of a widget with ToolProps, PartProps, InterruptProps, LayoutProps, or MessageProps. Pass typeof chatOptions and a name. Share chatOptions from its own module:
import type { ToolProps } from '@tanstack/ai-remix/ui'
import type { Handle } from 'remix/ui'
import { chatOptions } from './chat-options'
export function WeatherTool(
handle: Handle<ToolProps<typeof chatOptions, 'getWeather'>>,
) {
return () => <strong>{handle.props.part.input?.city}</strong>
}You now have a typed chat UI in Remix. 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 Remix, layout gets Messages, Interrupts, Queue, and Input. If you render Input with no registered input, it renders nothing and warns once in development.
createAppChat(handle, overrides) 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 Interrupts.
- 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. Call item.cancelQueued() to drop it.