Multi-turn Conversations
Build conversational AI that maintains context across multiple messages.
Overview
On-device models are stateless — they don't remember previous calls. You hold the conversation history in an array and pass the full history on every request. The model uses it to produce context-aware responses.
- Messages — the running history you pass to the AI
- System prompt — instructions that define behavior
- Context — the model sees every message you send
How It Works
Maintain an array of messages, append each turn, and pass it to sendMessage() (or streamMessage()).
import { sendMessage, type LLMMessage } from 'expo-ai-kit';
const messages: LLMMessage[] = [];
// First turn
messages.push({ role: 'user', content: 'My name is Alex.' });
const r1 = await sendMessage(messages);
messages.push({ role: 'assistant', content: r1.text });
// AI: "Nice to meet you, Alex!"
// Second turn — the AI has context from the array
messages.push({ role: 'user', content: 'What is my name?' });
const r2 = await sendMessage(messages);
messages.push({ role: 'assistant', content: r2.text });
// AI: "Your name is Alex."You own the history: append the user message before each call and the assistant's reply after it. The library adds no hidden state.
System Prompts
A system prompt defines the AI's behavior and persona. Provide it two ways:
Option 1 — the systemPrompt option
const response = await sendMessage(
[{ role: 'user', content: 'Tell me a joke' }],
{ systemPrompt: 'You are a comedian who specializes in dad jokes.' }
);Option 2 — a system message in the array
const response = await sendMessage([
{ role: 'system', content: 'You are a comedian who specializes in dad jokes.' },
{ role: 'user', content: 'Tell me a joke' },
]);If a system message is present in the array, the systemPrompt option is ignored.
Conversation Hook
A reusable React hook for managing a multi-turn conversation:
import { useState, useCallback } from 'react';
import { sendMessage, type LLMMessage } from 'expo-ai-kit';
export function useChat(systemPrompt?: string) {
const [messages, setMessages] = useState<LLMMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const chat = useCallback(async (userMessage: string) => {
setIsLoading(true);
try {
const next: LLMMessage[] = [
...messages,
{ role: 'user', content: userMessage },
];
const response = await sendMessage(next, { systemPrompt });
setMessages([...next, { role: 'assistant', content: response.text }]);
return response.text;
} finally {
setIsLoading(false);
}
}, [messages, systemPrompt]);
const clearChat = useCallback(() => setMessages([]), []);
return { messages, isLoading, chat, clearChat };
}Best Practices
1. Trim long conversations
As history grows, drop the oldest turns to stay responsive and within the model's context window.
const MAX_MESSAGES = 20;
function trim(messages: LLMMessage[]): LLMMessage[] {
return messages.length <= MAX_MESSAGES ? messages : messages.slice(-MAX_MESSAGES);
}
const response = await sendMessage(trim(messages), { systemPrompt });2. Send messages sequentially
Only one generation runs at a time — a concurrent call rejects with INFERENCE_BUSY. Wait for each response before sending the next.
// ❌ Bad — concurrent calls reject with INFERENCE_BUSY
await Promise.all([
sendMessage([{ role: 'user', content: 'Question 1' }]),
sendMessage([{ role: 'user', content: 'Question 2' }]),
]);
// ✅ Good — await each in turn
const r1 = await sendMessage([{ role: 'user', content: 'Question 1' }]);3. Always include the full history
For context-aware replies, pass the complete conversation — not just the latest message.
// ❌ Loses context
await sendMessage([{ role: 'user', content: 'What did I just say?' }]);
// ✅ Includes history
await sendMessage([
{ role: 'user', content: 'My name is Alice.' },
{ role: 'assistant', content: 'Nice to meet you, Alice!' },
{ role: 'user', content: 'What is my name?' },
]);