Skip to main content
Integrate elizaOS agents into web applications running entirely in the browser. This example shows how to build chat interfaces, handle user interactions, and manage state client-side.

Overview

Running agents in the browser enables instant interactions, offline capability, and reduced server costs. Perfect for web apps, Chrome extensions, and Progressive Web Apps. What you’ll learn:
  • Set up elizaOS for browser environments
  • Build a React chat interface
  • Use local storage for persistence
  • Handle real-time streaming
  • Implement offline support

Quick Start

1

Create React App

bun create vite eliza-chat --template react-ts
cd eliza-chat
2

Install Dependencies

bun add @elizaos/core @elizaos/plugin-openai
3

Create Chat Component

Build a chat interface with elizaOS integration
4

Run Development Server

bun dev

Complete Example

Runtime Setup

src/lib/eliza.ts
import { AgentRuntime } from "@elizaos/core";
import { openaiPlugin } from "@elizaos/plugin-openai";

let runtime: AgentRuntime | null = null;

export async function getRuntime(): Promise<AgentRuntime> {
  if (runtime) return runtime;

  // Create runtime with browser-compatible plugins
  runtime = new AgentRuntime({
    character: {
      name: "Eliza",
      bio: "A helpful AI assistant running in your browser.",
      system: "You are a friendly and helpful assistant.",
    },
    plugins: [openaiPlugin],
  });

  await runtime.initialize();
  return runtime;
}

export async function sendMessage(
  message: string,
  onChunk?: (text: string) => void
): Promise<string> {
  const runtime = await getRuntime();

  // Use the runtime's model directly
  if (onChunk) {
    let fullResponse = "";
    // Streaming not directly supported in browser, simulate chunks
    const response = await runtime.useModel("TEXT_LARGE", {
      prompt: message,
    });
    
    fullResponse = String(response);
    
    // Simulate streaming by breaking into chunks
    const words = fullResponse.split(" ");
    for (let i = 0; i < words.length; i++) {
      const chunk = words[i] + " ";
      onChunk(chunk);
      await new Promise(resolve => setTimeout(resolve, 50));
    }
    
    return fullResponse;
  } else {
    const response = await runtime.useModel("TEXT_LARGE", {
      prompt: message,
    });
    return String(response);
  }
}

Chat Component

src/components/Chat.tsx
import { useState, useEffect, useRef } from "react";
import { sendMessage } from "../lib/eliza";

interface Message {
  id: string;
  role: "user" | "assistant";
  content: string;
  timestamp: Date;
}

export function Chat() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState("");
  const [loading, setLoading] = useState(false);
  const messagesEndRef = useRef<HTMLDivElement>(null);

  // Load messages from localStorage
  useEffect(() => {
    const saved = localStorage.getItem("chat-messages");
    if (saved) {
      const parsed = JSON.parse(saved);
      setMessages(
        parsed.map((m: any) => ({
          ...m,
          timestamp: new Date(m.timestamp),
        }))
      );
    }
  }, []);

  // Save messages to localStorage
  useEffect(() => {
    if (messages.length > 0) {
      localStorage.setItem("chat-messages", JSON.stringify(messages));
    }
  }, [messages]);

  // Auto-scroll to bottom
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);

  const handleSend = async () => {
    if (!input.trim() || loading) return;

    const userMessage: Message = {
      id: Date.now().toString(),
      role: "user",
      content: input.trim(),
      timestamp: new Date(),
    };

    setMessages((prev) => [...prev, userMessage]);
    setInput("");
    setLoading(true);

    // Create assistant message placeholder
    const assistantId = (Date.now() + 1).toString();
    const assistantMessage: Message = {
      id: assistantId,
      role: "assistant",
      content: "",
      timestamp: new Date(),
    };

    setMessages((prev) => [...prev, assistantMessage]);

    try {
      // Send message with streaming
      await sendMessage(userMessage.content, (chunk) => {
        setMessages((prev) =>
          prev.map((msg) =>
            msg.id === assistantId
              ? { ...msg, content: msg.content + chunk }
              : msg
          )
        );
      });
    } catch (error) {
      console.error("Error:", error);
      setMessages((prev) =>
        prev.map((msg) =>
          msg.id === assistantId
            ? { ...msg, content: "Sorry, an error occurred." }
            : msg
        )
      );
    } finally {
      setLoading(false);
    }
  };

  const handleKeyPress = (e: React.KeyboardEvent) => {
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      handleSend();
    }
  };

  const clearHistory = () => {
    setMessages([]);
    localStorage.removeItem("chat-messages");
  };

  return (
    <div className="chat-container">
      <div className="chat-header">
        <h1>Eliza Chat</h1>
        <button onClick={clearHistory} className="clear-btn">
          Clear History
        </button>
      </div>

      <div className="messages">
        {messages.length === 0 && (
          <div className="empty-state">
            <p>Start a conversation with Eliza!</p>
          </div>
        )}

        {messages.map((message) => (
          <div key={message.id} className={`message ${message.role}`}>
            <div className="message-header">
              <strong>{message.role === "user" ? "You" : "Eliza"}</strong>
              <span className="timestamp">
                {message.timestamp.toLocaleTimeString()}
              </span>
            </div>
            <div className="message-content">{message.content}</div>
          </div>
        ))}

        <div ref={messagesEndRef} />
      </div>

      <div className="input-container">
        <textarea
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyPress={handleKeyPress}
          placeholder="Type your message..."
          disabled={loading}
          rows={2}
        />
        <button onClick={handleSend} disabled={loading || !input.trim()}>
          {loading ? "Sending..." : "Send"}
        </button>
      </div>
    </div>
  );
}

Styling

src/App.css
.chat-container {
  display: flex;
  flex-direction: column;
  height: 100vh;
  max-width: 800px;
  margin: 0 auto;
  background: #f5f5f5;
}

.chat-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1rem;
  background: white;
  border-bottom: 1px solid #e0e0e0;
}

.chat-header h1 {
  margin: 0;
  font-size: 1.5rem;
}

.clear-btn {
  padding: 0.5rem 1rem;
  background: #ff5252;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.clear-btn:hover {
  background: #ff1744;
}

.messages {
  flex: 1;
  overflow-y: auto;
  padding: 1rem;
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

.empty-state {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #666;
}

.message {
  padding: 1rem;
  border-radius: 8px;
  max-width: 80%;
}

.message.user {
  align-self: flex-end;
  background: #2196f3;
  color: white;
}

.message.assistant {
  align-self: flex-start;
  background: white;
  border: 1px solid #e0e0e0;
}

.message-header {
  display: flex;
  justify-content: space-between;
  margin-bottom: 0.5rem;
  font-size: 0.875rem;
}

.timestamp {
  opacity: 0.7;
  font-size: 0.75rem;
}

.message-content {
  white-space: pre-wrap;
  word-break: break-word;
}

.input-container {
  display: flex;
  gap: 0.5rem;
  padding: 1rem;
  background: white;
  border-top: 1px solid #e0e0e0;
}

.input-container textarea {
  flex: 1;
  padding: 0.75rem;
  border: 1px solid #e0e0e0;
  border-radius: 4px;
  resize: none;
  font-family: inherit;
  font-size: 1rem;
}

.input-container button {
  padding: 0.75rem 1.5rem;
  background: #2196f3;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 1rem;
}

.input-container button:hover:not(:disabled) {
  background: #1976d2;
}

.input-container button:disabled {
  background: #ccc;
  cursor: not-allowed;
}

App Setup

src/App.tsx
import { Chat } from "./components/Chat";
import "./App.css";

function App() {
  return <Chat />;
}

export default App;

Environment Configuration

Create .env file:
VITE_OPENAI_API_KEY=your-key-here
Update runtime to use environment variable:
runtime = new AgentRuntime({
  character: {
    name: "Eliza",
    bio: "A helpful AI assistant.",
    secrets: {
      OPENAI_API_KEY: import.meta.env.VITE_OPENAI_API_KEY,
    },
  },
  plugins: [openaiPlugin],
});

Advanced Features

Conversation Context

Maintain conversation history:
import { createMessageMemory, stringToUuid } from "@elizaos/core";

const conversationId = stringToUuid("browser-chat");
const userId = stringToUuid("user-" + Date.now());

export async function sendMessageWithContext(
  message: string,
  messages: Message[]
): Promise<string> {
  const runtime = await getRuntime();

  // Create message with full context
  const context = messages
    .slice(-5) // Last 5 messages
    .map((m) => `${m.role}: ${m.content}`)
    .join("\n");

  const fullPrompt = `Conversation history:\n${context}\n\nUser: ${message}\n\nAssistant:`;

  const response = await runtime.useModel("TEXT_LARGE", {
    prompt: fullPrompt,
  });

  return String(response);
}

IndexedDB Storage

Use IndexedDB for larger data:
class ChatStorage {
  private db: IDBDatabase | null = null;

  async init() {
    return new Promise<void>((resolve, reject) => {
      const request = indexedDB.open("ElizaChat", 1);

      request.onerror = () => reject(request.error);
      request.onsuccess = () => {
        this.db = request.result;
        resolve();
      };

      request.onupgradeneeded = (event) => {
        const db = (event.target as IDBOpenDBRequest).result;
        db.createObjectStore("messages", { keyPath: "id" });
      };
    });
  }

  async saveMessage(message: Message) {
    const tx = this.db!.transaction("messages", "readwrite");
    await tx.objectStore("messages").add(message);
  }

  async getMessages(): Promise<Message[]> {
    const tx = this.db!.transaction("messages", "readonly");
    const request = tx.objectStore("messages").getAll();
    return new Promise((resolve) => {
      request.onsuccess = () => resolve(request.result);
    });
  }
}

Offline Support

Use service worker for offline capability:
// public/service-worker.js
self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open("eliza-v1").then((cache) => {
      return cache.addAll(["/", "/index.html", "/assets/index.js"]);
    })
  );
});

self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      return response || fetch(event.request);
    })
  );
});
Register in your app:
if ("serviceWorker" in navigator) {
  navigator.serviceWorker.register("/service-worker.js");
}

Voice Input

Add speech recognition:
function useVoiceInput(onResult: (text: string) => void) {
  const recognition = new (window as any).webkitSpeechRecognition();
  recognition.continuous = false;
  recognition.interimResults = false;

  recognition.onresult = (event: any) => {
    const text = event.results[0][0].transcript;
    onResult(text);
  };

  const start = () => recognition.start();
  const stop = () => recognition.stop();

  return { start, stop };
}
Use in component:
const { start, stop } = useVoiceInput((text) => {
  setInput(text);
});

<button onClick={start}>🎤 Start Recording</button>

Text-to-Speech

Add voice output:
function speak(text: string) {
  const utterance = new SpeechSynthesisUtterance(text);
  utterance.rate = 1.0;
  utterance.pitch = 1.0;
  utterance.volume = 1.0;
  speechSynthesis.speak(utterance);
}

Chrome Extension

Turn your chat into a Chrome extension:
manifest.json
{
  "manifest_version": 3,
  "name": "Eliza Chat",
  "version": "1.0",
  "description": "AI assistant in your browser",
  "action": {
    "default_popup": "index.html",
    "default_icon": "icon.png"
  },
  "permissions": ["storage"],
  "host_permissions": ["https://api.openai.com/*"]
}

Best Practices

API Key Security: Never expose API keys in client code. Use a backend proxy for production.
Error Handling: Implement robust error handling for network issues and API failures.
Loading States: Show clear loading indicators during API calls.
Responsive Design: Ensure your chat works on mobile devices.
Accessibility: Add proper ARIA labels and keyboard navigation.

Next Steps

REST API Server

Build a backend for your browser app

Multi-Agent

Run multiple agents in the browser

Custom Character

Create unique chat personalities

Deploy Guide

Deploy your web app

Build docs developers (and LLMs) love