Skip to content
Core Plugins

Chat

Add conversations with AI or people to your project.


Overview

The chat plugin adds conversations to your project. A conversation is a reactive object: its messages, participants, and typing indicators update live, and you render them with your own markup. The same markup can power an AI assistant, a support box, a group thread, or a comment section.

Adapters decide where messages live. Built-in adapters cover AI models, an Appwrite backend, and an in-browser demo — or you can register your own. A conversation with an AI works the same as one between people.


Setup

The chat plugin is included in manifest.js with all core plugins. It loads automatically when $chat appears in your page's markup, or when manifest.json contains an ai or chat entry (a config block, or "chat": true). It can also be declared in data-plugins, where the + prefix adds it alongside the default plugins.

<!-- Meta: manifest.json contains an "ai" or "chat" entry -->
<link rel="manifest" href="/manifest.json">

<!-- Scripts -->
<script src="https://cdn.jsdelivr.net/npm/mnfst@latest/lib/manifest.min.js"></script>

Markup detection covers $chat written in the page itself. If your only usage lives inside a lazily loaded component, use one of the other two triggers.


AI Chat

Manifest supports turnkey AI integration using Anthropic's Claude, grounded in your own content.

Configuration

Register the model in manifest.json under an ai block:

manifest.json
{ "ai": { "provider": "anthropic", "model": "claude-haiku-4-5", "system": "You are the support assistant for Universal Exports. Answer briefly.", "grounding": "https://universalexports.com/llms.txt" } }
Property Default Description
provider "anthropic" The AI provider; more may be added in the future.
model "claude-haiku-4-5" Any Claude model ID. Haiku is fast and inexpensive; larger models reason more deeply.
system Instructions that shape every reply, like the assistant's role, tone, and boundaries.
grounding A URL or project file whose text is added to the instructions, so answers come from your content instead of the model's general knowledge. Loaded once at server start.
maxTokens 1024 The maximum length of a single reply.

Then put your API key in the project's .env file:

.env
ANTHROPIC_API_KEY=sk-ant-your-key-here

Long system and grounding text is sent as a cacheable prompt. After the first message, Anthropic serves it from cache at a fraction of the price.


Markup

Open a conversation with $chat.open and render it. The call returns a reactive handle. Loop over its messages, bind a field to a draft, and call send.

Three details complete the UI. Style your own messages by comparing each message's author to the handle's me. Render finished replies with the markdown plugin, and show plain text with x-text while a reply streams. When the text comes from participants you don't control, use x-markdown.safe to sanitize it.

HTML
<div class="chat-wrapper" x-data="{ c: $chat.open('support', { adapter: 'claude' }), draft: '' }">

    <!-- Message log -->
    <div role="log">
        <div>
            <template x-for="m in c.messages" :key="m.id">
                <div :class="m.author?.id === c.me?.id && 'own'">
                    <span x-show="m.status === 'streaming'" x-text="m.body.text"></span>
                    <div x-show="m.status !== 'streaming'" x-markdown="m.body.text"></div>
                </div>
            </template>
        </div>
    </div>

    <!-- Composer -->
    <form class="bg-popover-surface border border-line focus-within:outlined rounded-lg"
        @submit.prevent="c.send({ text: draft }); draft = ''">
        <textarea class="transparent no-focus" x-model="draft" placeholder="Ask anything…"
            @keydown.enter="if (!$event.shiftKey) { $event.preventDefault(); $el.form.requestSubmit() }"></textarea>
        <button type="submit" class="transparent" x-icon="lucide:corner-down-left" aria-label="Send"></button>
    </form>

</div>

This frame uses the demo adapter, which plays the model's part so this page doesn't need an API key. With an ai block configured, change the adapter name to claude and the same markup talks to the real model. The layout comes from the chats element styles; the plugin only supplies the data.

Replies stream in live. A reply appears with status: 'streaming' and grows until it's done. Conversation history is kept in the visitor's browser per conversation ID, so an open conversation picks up where it left off after a reload.

Option Description
adapter: 'claude' Use the built-in Claude adapter.
endpoint Override the relay URL (defaults to /_ai/chat). Point this at a self-hosted proxy.
system Per-conversation instructions, replacing the ai block's for this handle.
model Per-conversation model override.

Image and PDF attachments ride along on send:

Attachments
<!-- media items: { kind: 'image'|'document', mediaType, data (base64), url, name } --> <button @click="c.send({ body: { text: draft, media: files } })">Send with files</button>

Message Objects

Every adapter feeds the same reactive conversation object, so markup written for one adapter works with any other. Each message carries everything a chat UI needs:

Field Description
body.text The message text, raw and exactly as written.
body.media Attachments, if any.
author Who sent it: displayName, color, and a kind of human, agent (an AI), contact, or system.
status pendingstreamingsentdeliveredread, plus failed, edited, retracted.
replyTo The parent message's ID, when this message is a reply.
reactions Emoji reactions: { emoji, count, byMe }.
ts The message timestamp.

The frame below lays these fields out for a seeded support thread. Each row shows the author's kind, the text, and the delivery status. Notice kind distinguishing the customer (contact) from the agent (human).

HTML
<div x-data="{ c: $chat.open('hist-em-1', { adapter: 'demo' }) }">
    <template x-for="m in c.messages" :key="m.id">
        <p>
            <b :style="`color:${m.author?.color}`" x-text="m.author?.displayName"></b>
            <small class="chip" x-text="m.author?.kind"></small>
            <span x-text="m.body.text"></span>
            <small x-text="m.status"></small>
        </p>
    </template>
</div>

Sends are optimistic. Your message appears instantly as pending and settles when the backend confirms, so the UI never waits on the network. Failures mark the message failed instead of losing it.


Groups & Reactions

A conversation can hold any number of participants, human or AI. The handle exposes the participant list reactively. Reactions toggle with one call: react adds yours, and calling it again removes it.

In the frame, invite Cy and the participant list updates. Ask the assistant and its reply streams into the conversation.

HTML
<div x-data="{ g: $chat.open('grp-1', { adapter: 'demo' }) }">

    <p><small>
        In this conversation:
        <span x-text="g.participants.map(p => p.displayName).join(', ')"></span>
    </small></p>

    <div class="col gap-1">
        <template x-for="m in g.messages" :key="m.id">
            <p>
                <b :style="`color:${m.author?.color}`" x-text="m.author?.displayName"></b>
                <span x-text="m.body.text"></span>
                <button class="ghost sm" :class="(m.reactions || []).some(r => r.byMe) && 'selected'"
                    @click="g.react(m.id, (m.reactions || [])[0]?.emoji || '🎉')"
                    x-text="(m.reactions || [])[0] ? m.reactions[0].emoji + ' ' + m.reactions[0].count : '🎉'">
                </button>
            </p>
        </template>
    </div>

    <div class="row gap-2">
        <button @click="g.addParticipant({ id: 'u_cy', kind: 'human', role: 'member', displayName: 'Cy', color: '#ea580c' })"
            :disabled="g.participants.some(p => p.id === 'u_cy')">Invite Cy</button>
        <button @click="$chat.sim.aiReply('grp-1', 'On it — the v2 doc looks ready to ship.')">Ask the assistant</button>
    </div>

</div>

Threaded Replies

Messages can reply to other messages through replyTo. The tree() and flatTree() methods project the flat list into a nested one, so the same conversation renders as a chat, a forum, or a comment section. Nesting depth is a rendering choice: cap it with maxDepth or leave it unlimited.

Replying is a send with a parent ID. Pick a message in the frame and your reply nests under it.

HTML
<div x-data="{ f: $chat.open('forum-1', { adapter: 'demo' }), to: null, reply: '' }">

    <template x-for="n in f.flatTree({ maxDepth: 3 })" :key="n.id">
        <p :style="`margin-left:${n.depth * 1.5}rem`">
            <b :style="`color:${n.author?.color}`" x-text="n.author?.displayName"></b>
            <span x-text="n.body.text"></span>
            <button class="ghost sm" @click="to = n">reply</button>
        </p>
    </template>

    <form class="row gap-2" x-show="to"
        @submit.prevent="f.send({ text: reply, replyTo: to.id }); reply = ''; to = null">
        <input type="text" x-model="reply" :placeholder="`Reply to ${to?.author?.displayName}…`">
        <button>Reply</button>
    </form>

</div>

A reply whose parent isn't loaded still renders, flagged as an orphan, instead of disappearing. The last seeded message above is one.


Appwrite Adapter

Conversations between real visitors need a real backend. Manifest integrates with Appwrite, an open source backend platform, for authentication, databases, and more. See Appwrite setup to connect a project.

The built-in appwrite adapter stores messages as rows in your Appwrite database, and new messages arrive over its realtime connection. Identity comes from auth. Visitors get a guest session on their first message, so commenting needs no signup.

Configure it in the same chat block that activates the plugin:

manifest.json
{ "chat": { "appwriteDatabaseId": "your-database-id", "appwriteTableId": "chat_messages", "ttlHours": 24 } }
Property Default Description
appwriteDatabaseId The database holding the messages table.
appwriteTableId "chat_messages" A table with columns conversationId (indexed), text, authorId, authorName, and optional authorColor, replyTo. Permissions: read any, create users + guests.
ttlHours Optional: only load messages younger than this, making conversations ephemeral. Pair with the prune function to physically delete the rest on a schedule.

The frame below is this page's real comment thread. Comments come from other readers, live, and disappear after 24 hours:

HTML
<div x-data="{ c: $chat.open('website-comments', { adapter: 'appwrite' }), draft: '' }">

    <div class="col gap-1">
        <template x-for="m in c.messages" :key="m.id">
            <p>
                <b :style="`color:${m.author?.color}`" x-text="m.author?.displayName"></b>
                <span x-text="m.body.text"></span>
            </p>
        </template>
        <small x-show="!c.messages.length">No comments in the last 24 hours — leave the first.</small>
    </div>

    <form class="row gap-2" @submit.prevent="if (draft.trim()) { c.send({ text: draft }); draft = '' }">
        <input type="text" x-model="draft" placeholder="Leave an anonymous comment…">
        <button>Comment</button>
    </form>

</div>

The same adapter serves support threads, project discussions, and small group chats — anywhere the conversation is part of your app's data. Messages are ordinary rows, so the rest of the Appwrite toolbox applies: permissions, the console, your own functions.


Custom Adapters

An adapter is where messages actually live. The plugin renders and drives the conversation; the adapter stores and transports it. Connect your own backend or messaging platform by registering one object:

Custom adapter
<script> document.addEventListener('alpine:init', () => { window.ManifestChatAdapters.register('mybackend', (opts) => ({ identity: () => ({ id: 'me', kind: 'human', displayName: 'Me' }), async load(conversationId) { const r = await fetch(`/api/chats/${conversationId}`) return await r.json() // { messages, participants } }, subscribe(conversationId, handlers) { const es = new EventSource(`/api/chats/${conversationId}/stream`) es.onmessage = (e) => handlers.onMessage(JSON.parse(e.data)) return () => es.close() }, async send(conversationId, draft) { const r = await fetch(`/api/chats/${conversationId}`, { method: 'POST', body: JSON.stringify(draft) }) return await r.json() // { id, ts } } })) }) </script>

identity, load, and subscribe are required. Everything else — send, react, edit, setTyping, and more — is optional, and the handle's can flags reflect exactly what you implemented. A read-only transcript is just an adapter with no send. The full adapter guide, covering streaming, paging, reconnection, and a copyable skeleton, lives in the Manifest repository.

Chat visibility in the browser is cosmetic, like all client-side gating. Real access control belongs to whatever backend the adapter talks to. As with payments, secrets live server-side only: the adapter calls your API, and your API holds the keys.


Reference

The $chat magic:

Property Type Description
$chat.open(id, options) method Open a conversation. Returns a reactive handle.
$chat.merge(handles) method Merge several conversations into one time-ordered read view.
$chat.adapter(name, factory) method Register a custom adapter.
$chat.version reactive Shared integer that increments on every update across all conversations, readable before any handle exists. If a list expression looks its handle up through a key that may not be set yet (threads[key]?.messages ?? []), start it with void $chat.version; so the list still re-renders when the handle arrives and loads.

The handle returned by $chat.open:

Property Type Description
messages reactive The conversation, oldest first.
participants reactive Who's in the conversation.
typing reactive Participants typing right now.
status reactive idleloadingready, or error.
live reactive false while the connection is re-establishing.
version reactive Integer that increments on every conversation update. Reading any list above already tracks it; read version itself when an effect needs an explicit dependency on "anything changed". Merged views expose it too.
can reactive What this conversation supports: can.send, can.react, can.edit, … Hide affordances the adapter doesn't offer.
send(draft) method Send a message: { text }, or { body: { text, media }, replyTo }.
react(id, emoji) / edit(id, body) / retract(id) method Act on a message, where supported.
setTyping(on) / markRead(upToId) method Report typing and read state.
loadOlder() / loadNewer() method Page through history; atStart/atEnd flag the boundaries.
tree(options) / flatTree(options) method Nested or flattened reply-tree projection; { maxDepth } caps indentation.
addParticipant(p) / removeParticipant(id) / transfer(from, to) method Change who's in the conversation, where supported.
close() method Disconnect this handle. Call it when the UI unmounts.