Quickstart
The single tutorial: install the conversation family items into your Expo project and drive a seeded chat screen with a working prompt input in Expo Go.
In this tutorial we build a seed-data chat screen you can open in Expo Go. By the end you will see a scrolling message list seeded with a 24-turn conversation, type into a working prompt input, and watch your own message appear as a new turn — everything runs on seed data, with no model and no network call.
This tutorial does not cover choosing an engine or wiring a real model. Read Installation first if your project is not on an engine yet; the worked example linked at the end shows the live-model path.
What you need
- An Expo project you can already build and run on your engine — the Installation page sets that up.
- A terminal at your project root.
- Expo Go on your phone, or a simulator or emulator on your machine.
Everything this tutorial installs runs in Expo Go — no dev client needed.
Build the screen
-
We install the Conversation registry item from your engine’s tree. Select the tab for your engine, copy the complete command, and run it at your project root. When the CLI finishes, you should see the paths it wrote, starting with
components/ai/conversation.tsx.npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/conversation.jsonnpx @react-native-reusables/cli@latest add https://rnraielements.com/r/uniwind/conversation.json -
We install the Message registry item. The conversation item does not install it automatically, and the message list needs it. You should see
components/ai/message.tsxamong the paths the CLI reports, along with the peers both items declare.npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/message.jsonnpx @react-native-reusables/cli@latest add https://rnraielements.com/r/uniwind/message.json -
We install the composed PromptInput together with the Item and Sheet registry items its installed source imports. You should see
components/ai/prompt-input.tsx,components/ui/item.tsx, andcomponents/ui/sheet.tsxwritten into your project by one command.npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/prompt-input.json https://rnraielements.com/r/nativewind/item.json https://rnraielements.com/r/nativewind/sheet.jsonnpx @react-native-reusables/cli@latest add https://rnraielements.com/r/uniwind/prompt-input.json https://rnraielements.com/r/uniwind/item.json https://rnraielements.com/r/uniwind/sheet.json -
We add the seed transcript at
shared/quickstart-messages.ts. The file is identical for both engines, so pick either panel and copy the complete source. You should see 24 authored turns in the file, fromSeed turn 01: Plan a weekend walk.toSeed turn 24: Ready for your message./** * The quickstart seed transcript — the finite, named seed data the single quickstart * tutorial loads before any learner interaction. * * 24 chronological turns, stable IDs `seed-01`..`seed-24`, alternating 12 user and 12 * assistant turns. The oldest turn ("Plan a weekend walk.") and the newest ("Ready for * your message.") are the tutorial's named end state; the middle is a coherent * walk-planning exchange so the learner sees a real thread rather than placeholder * rows. No model request ever originates here and no assistant reply is fabricated at * runtime — every assistant line is authored seed content. */ export type SeedMessage = { /** Stable, monotonic key — the Conversation FlatList keyExtractor reads it. */ id: string; /** `user` renders contained and reversed; `assistant` renders full-width. */ from: 'user' | 'assistant'; /** The turn body, prefixed `Seed turn NN:` so each leg can assert its reach. */ text: string; }; /** * Chronological (oldest-first). Conversation reverses its input internally, so the * order here is exactly the order a learner reads top-to-bottom after the list renders. * Odd indices are user turns, even indices are assistant turns. */ export const SEED_MESSAGES: readonly SeedMessage[] = [ { id: 'seed-01', from: 'user', text: 'Seed turn 01: Plan a weekend walk.' }, { id: 'seed-02', from: 'assistant', text: 'Seed turn 02: Sounds great. Which neighborhood were you thinking of?' }, { id: 'seed-03', from: 'user', text: 'Seed turn 03: Maybe the river trail near the old mill.' }, { id: 'seed-04', from: 'assistant', text: 'Seed turn 04: The mill loop is about four miles and mostly flat, so a relaxed pace works.' }, { id: 'seed-05', from: 'user', text: 'Seed turn 05: Perfect. Can we add a coffee stop?' }, { id: 'seed-06', from: 'assistant', text: 'Seed turn 06: Sure. There is a cafe right by the trailhead that opens at eight.' }, { id: 'seed-07', from: 'user', text: 'Seed turn 07: Let us aim for nine, so it is warm but not crowded.' }, { id: 'seed-08', from: 'assistant', text: 'Seed turn 08: Nine works. I will check the forecast for Saturday.' }, { id: 'seed-09', from: 'user', text: 'Seed turn 09: And pack light. Just water and a jacket.' }, { id: 'seed-10', from: 'assistant', text: 'Seed turn 10: Noted. The forecast shows sun, so a light jacket is plenty.' }, { id: 'seed-11', from: 'user', text: 'Seed turn 11: Are there any uphill sections to know about?' }, { id: 'seed-12', from: 'assistant', text: 'Seed turn 12: One gentle climb at mile two. The rest is shaded and flat.' }, { id: 'seed-13', from: 'user', text: 'Seed turn 13: Good. How long should we plan in total?' }, { id: 'seed-14', from: 'assistant', text: 'Seed turn 14: About two hours with the coffee stop, so you would be back by noon.' }, { id: 'seed-15', from: 'user', text: 'Seed turn 15: That leaves the afternoon free.' }, { id: 'seed-16', from: 'assistant', text: 'Seed turn 16: Exactly. You could add the Saturday market after the walk.' }, { id: 'seed-17', from: 'user', text: 'Seed turn 17: Nice idea. Is the market near the trailhead?' }, { id: 'seed-18', from: 'assistant', text: 'Seed turn 18: Two blocks from the cafe, and it is open until two.' }, { id: 'seed-19', from: 'user', text: 'Seed turn 19: Let us do the walk, then coffee, then the market.' }, { id: 'seed-20', from: 'assistant', text: 'Seed turn 20: Locked in. I have the route and the stops saved.' }, { id: 'seed-21', from: 'user', text: 'Seed turn 21: Anything we should bring for the market?' }, { id: 'seed-22', from: 'assistant', text: 'Seed turn 22: A small tote bag and some cash. A few stalls do not take cards.' }, { id: 'seed-23', from: 'user', text: 'Seed turn 23: Great. Can you remind me the morning of?' }, { id: 'seed-24', from: 'assistant', text: 'Seed turn 24: Ready for your message.' }, ];/** * The quickstart seed transcript — the finite, named seed data the single quickstart * tutorial loads before any learner interaction. * * 24 chronological turns, stable IDs `seed-01`..`seed-24`, alternating 12 user and 12 * assistant turns. The oldest turn ("Plan a weekend walk.") and the newest ("Ready for * your message.") are the tutorial's named end state; the middle is a coherent * walk-planning exchange so the learner sees a real thread rather than placeholder * rows. No model request ever originates here and no assistant reply is fabricated at * runtime — every assistant line is authored seed content. */ export type SeedMessage = { /** Stable, monotonic key — the Conversation FlatList keyExtractor reads it. */ id: string; /** `user` renders contained and reversed; `assistant` renders full-width. */ from: 'user' | 'assistant'; /** The turn body, prefixed `Seed turn NN:` so each leg can assert its reach. */ text: string; }; /** * Chronological (oldest-first). Conversation reverses its input internally, so the * order here is exactly the order a learner reads top-to-bottom after the list renders. * Odd indices are user turns, even indices are assistant turns. */ export const SEED_MESSAGES: readonly SeedMessage[] = [ { id: 'seed-01', from: 'user', text: 'Seed turn 01: Plan a weekend walk.' }, { id: 'seed-02', from: 'assistant', text: 'Seed turn 02: Sounds great. Which neighborhood were you thinking of?' }, { id: 'seed-03', from: 'user', text: 'Seed turn 03: Maybe the river trail near the old mill.' }, { id: 'seed-04', from: 'assistant', text: 'Seed turn 04: The mill loop is about four miles and mostly flat, so a relaxed pace works.' }, { id: 'seed-05', from: 'user', text: 'Seed turn 05: Perfect. Can we add a coffee stop?' }, { id: 'seed-06', from: 'assistant', text: 'Seed turn 06: Sure. There is a cafe right by the trailhead that opens at eight.' }, { id: 'seed-07', from: 'user', text: 'Seed turn 07: Let us aim for nine, so it is warm but not crowded.' }, { id: 'seed-08', from: 'assistant', text: 'Seed turn 08: Nine works. I will check the forecast for Saturday.' }, { id: 'seed-09', from: 'user', text: 'Seed turn 09: And pack light. Just water and a jacket.' }, { id: 'seed-10', from: 'assistant', text: 'Seed turn 10: Noted. The forecast shows sun, so a light jacket is plenty.' }, { id: 'seed-11', from: 'user', text: 'Seed turn 11: Are there any uphill sections to know about?' }, { id: 'seed-12', from: 'assistant', text: 'Seed turn 12: One gentle climb at mile two. The rest is shaded and flat.' }, { id: 'seed-13', from: 'user', text: 'Seed turn 13: Good. How long should we plan in total?' }, { id: 'seed-14', from: 'assistant', text: 'Seed turn 14: About two hours with the coffee stop, so you would be back by noon.' }, { id: 'seed-15', from: 'user', text: 'Seed turn 15: That leaves the afternoon free.' }, { id: 'seed-16', from: 'assistant', text: 'Seed turn 16: Exactly. You could add the Saturday market after the walk.' }, { id: 'seed-17', from: 'user', text: 'Seed turn 17: Nice idea. Is the market near the trailhead?' }, { id: 'seed-18', from: 'assistant', text: 'Seed turn 18: Two blocks from the cafe, and it is open until two.' }, { id: 'seed-19', from: 'user', text: 'Seed turn 19: Let us do the walk, then coffee, then the market.' }, { id: 'seed-20', from: 'assistant', text: 'Seed turn 20: Locked in. I have the route and the stops saved.' }, { id: 'seed-21', from: 'user', text: 'Seed turn 21: Anything we should bring for the market?' }, { id: 'seed-22', from: 'assistant', text: 'Seed turn 22: A small tote bag and some cash. A few stalls do not take cards.' }, { id: 'seed-23', from: 'user', text: 'Seed turn 23: Great. Can you remind me the morning of?' }, { id: 'seed-24', from: 'assistant', text: 'Seed turn 24: Ready for your message.' }, ]; -
We add the chat screen at
shared/quickstart-screen.tsx. The same source runs on both engines because every import resolves through your project’s@/alias into the installed registry items — no engine wrapper, no model call, and the submit handler receives the composedPromptInputMessageobject, never a plain string. You should see the screen compose Conversation, Message, and PromptInput, with the scroll button lifted above the measured composer.import { Conversation, ConversationScrollButton } from '@/components/ai/conversation'; import { Message, MessageContent, MessageResponse } from '@/components/ai/message'; import { PromptInput, PromptInputBody, PromptInputFooter, PromptInputSubmit, PromptInputTextarea, type PromptInputMessage, } from '@/components/ai/prompt-input'; import * as React from 'react'; import { View } from 'react-native'; import { SEED_MESSAGES, type SeedMessage } from './quickstart-messages'; /** * The single quickstart seed-data chat screen — the compressed tutorial's named end * state: a scrolling transcript over the finite seed, with a working composer that * appends local user turns only. * * Engine-agnostic on purpose: every import resolves through the consumer's `@/` alias * into the installed registry components, so the same bytes compile in both the * nativewind and uniwind consumers. It never imports an engine wrapper, never calls a * model, and never fabricates an assistant reply — the transcript is seed data plus * exactly the turns the learner submits. */ export function QuickstartScreen() { const [messages, setMessages] = React.useState<readonly SeedMessage[]>(SEED_MESSAGES); const [composerHeight, setComposerHeight] = React.useState(0); const handleSubmit = React.useCallback((message: PromptInputMessage) => { // Append exactly one local user turn. PromptInput already trims the text and only // invokes onSubmit with a non-empty value, and it clears the composer on success, // so this callback only ever records the message it is handed. setMessages((prev) => [ ...prev, { id: `user-${prev.length + 1}`, from: 'user', text: message.text }, ]); }, []); return ( <View className="flex-1 bg-background"> <Conversation data={messages} keyExtractor={(item) => item.id} renderItem={({ item }) => ( <Message from={item.from}> <MessageContent> {/* Default MessageResponse: plain RNR Text, no markdown renderer, so the core stays Expo Go-clean with no native markdown dependency. */} <MessageResponse>{item.text}</MessageResponse> </MessageContent> </Message> )} > {/* The scroll button stays inside Conversation's context and is lifted above the measured composer by the wrapper below — never a static bottom offset that could land on the input as it grows. */} <View pointerEvents="box-none" style={{ position: 'absolute', left: 0, right: 0, bottom: composerHeight + 12 }} > <ConversationScrollButton className="bottom-0" /> </View> <PromptInput onSubmit={handleSubmit} onLayout={(event) => setComposerHeight(event.nativeEvent.layout.height)} > <PromptInputBody> <PromptInputTextarea /> <PromptInputFooter> <PromptInputSubmit /> </PromptInputFooter> </PromptInputBody> </PromptInput> </Conversation> </View> ); }import { Conversation, ConversationScrollButton } from '@/components/ai/conversation'; import { Message, MessageContent, MessageResponse } from '@/components/ai/message'; import { PromptInput, PromptInputBody, PromptInputFooter, PromptInputSubmit, PromptInputTextarea, type PromptInputMessage, } from '@/components/ai/prompt-input'; import * as React from 'react'; import { View } from 'react-native'; import { SEED_MESSAGES, type SeedMessage } from './quickstart-messages'; /** * The single quickstart seed-data chat screen — the compressed tutorial's named end * state: a scrolling transcript over the finite seed, with a working composer that * appends local user turns only. * * Engine-agnostic on purpose: every import resolves through the consumer's `@/` alias * into the installed registry components, so the same bytes compile in both the * nativewind and uniwind consumers. It never imports an engine wrapper, never calls a * model, and never fabricates an assistant reply — the transcript is seed data plus * exactly the turns the learner submits. */ export function QuickstartScreen() { const [messages, setMessages] = React.useState<readonly SeedMessage[]>(SEED_MESSAGES); const [composerHeight, setComposerHeight] = React.useState(0); const handleSubmit = React.useCallback((message: PromptInputMessage) => { // Append exactly one local user turn. PromptInput already trims the text and only // invokes onSubmit with a non-empty value, and it clears the composer on success, // so this callback only ever records the message it is handed. setMessages((prev) => [ ...prev, { id: `user-${prev.length + 1}`, from: 'user', text: message.text }, ]); }, []); return ( <View className="flex-1 bg-background"> <Conversation data={messages} keyExtractor={(item) => item.id} renderItem={({ item }) => ( <Message from={item.from}> <MessageContent> {/* Default MessageResponse: plain RNR Text, no markdown renderer, so the core stays Expo Go-clean with no native markdown dependency. */} <MessageResponse>{item.text}</MessageResponse> </MessageContent> </Message> )} > {/* The scroll button stays inside Conversation's context and is lifted above the measured composer by the wrapper below — never a static bottom offset that could land on the input as it grows. */} <View pointerEvents="box-none" style={{ position: 'absolute', left: 0, right: 0, bottom: composerHeight + 12 }} > <ConversationScrollButton className="bottom-0" /> </View> <PromptInput onSubmit={handleSubmit} onLayout={(event) => setComposerHeight(event.nativeEvent.layout.height)} > <PromptInputBody> <PromptInputTextarea /> <PromptInputFooter> <PromptInputSubmit /> </PromptInputFooter> </PromptInputBody> </PromptInput> </Conversation> </View> ); } -
We render the screen from your app’s first route. In the reference consumer that route is
app/index.tsx(expo-router), and each engine’s thin entrypoint does exactly one thing: import the shared screen and render it. You should see no other logic in the file you paste.import { QuickstartScreen } from '@/shared/quickstart-screen'; /** * The nativewind consumer's thin entrypoint — the seed-data chat screen, nothing else. * Root providers, NAV_THEME, and PortalHost live in app/_layout.tsx (ORI-RN-001); this * route only renders the shared screen so both engines consume the identical source. */ export default function QuickstartIndex() { return <QuickstartScreen />; }import { QuickstartScreen } from '@/shared/quickstart-screen'; /** * The uniwind consumer's thin entrypoint — the seed-data chat screen, nothing else. * Root providers, NAV_THEME, and PortalHost live in app/_layout.tsx (ORI-RN-001); this * route only renders the shared screen so both engines consume the identical source. */ export default function QuickstartIndex() { return <QuickstartScreen />; } -
We run the app and open it in Expo Go. Run
npx expo startat the project root and scan the QR code with Expo Go. You should see the seeded transcript scroll under your thumb, and after you type a message and press send, your message appears exactly once as a new turn and the composer clears.
Where to go next
This screen is deliberately finite: the transcript is seed data, and the only turns added are the ones you type. When you are ready to send real messages, the worked example in apps/example composes the same registry items around AI SDK v7’s useChat hook, streaming from a small server that keeps your provider credentials off the device — open the worked example to see the wiring and run it.
- Read the full URL and destination contract: CLI
- See the whole ownership split before you wire a model: What the library owns
- Point the items at your tokens: Theming