Multi-turn Conversations

Build conversational AI that maintains context across multiple messages.

iOSAndroid

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:

hooks/useChat.tstypescript
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?' },
]);