Chat with an AI Assistant
An AI assistant is just another participant - synthetic user id, role labels, and your own LLM call.
Chatpack has no special AI features - and that's the point. To Chatpack, an AI
assistant is just another participant: pick a synthetic user id (any
string you'll never issue to a real user, e.g. ai:assistant) and have your
backend send its replies. The same permissions apply.
The pattern
const ASSISTANT_ID = "ai:assistant";
// find-or-create the user's conversation with the assistant
const conversation = await chat.api.getOrCreateConversation({
userId: user.id,
otherUserId: ASSISTANT_ID,
});
// the user's message arrives (via your route or the REST API)...
await chat.api.sendMessage({
userId: user.id,
conversationId: conversation.id,
body: userText,
});
// ...your backend calls your LLM of choice with your own keys...
const reply = await generateReply(userText); // Claude, GPT, Gemini, ...
// ...and sends the answer as the assistant participant
await chat.api.sendMessage({
userId: ASSISTANT_ID,
conversationId: conversation.id,
body: reply,
role: "assistant", // "user" | "assistant" | "system" - stored & returned as-is
});Chatpack stores, orders, and delivers the messages; the LLM call is yours -
model, keys, prompts, streaming. If the user has the SSE stream open, the
assistant's reply arrives as a live message.created event like any other
message.
An assistant in a group
The same id works in a group - put it in userIds at creation, or add it later:
const room = await chat.api.createGroupConversation({
userId: user.id,
userIds: ["bob", "carol", ASSISTANT_ID],
name: "Design review",
});It joins as a member, so it can send and read but not manage membership. Give
it role: "admin" only if your backend needs it to add or remove people. Since
every participant receives the group's messages, decide when the assistant should
answer - replying to every message in a busy room is rarely what you want.
The cleanest gate is a mention. Your composer passes the assistant's id in
mentions when the user @-picks it, and afterMessageMutation hands you the
validated set, so the check is an id comparison rather than a regex over the body:
afterMessageMutation: async ({ action, message, mentions }) => {
if (action !== "send" || !mentions.includes(ASSISTANT_ID)) return;
await queueAssistantReply(message);
};Chatpack won't help you find @assistant in the text - it never parses body
(ADR 0022) - but it does guarantee the id is a real
participant, so an id in mentions means the assistant is actually in the room. A
command prefix or a direct address works too; recipientIds is there when you want
every participant instead.
The role field
role must be "user" | "assistant" | "system" (default "user"). It's a
plain label for your UI - core never behaves differently based on it. Any
other value is a 400 INVALID_INPUT.
Use it to render assistant bubbles differently, or to rebuild an LLM-conversation transcript from history:
const { messages } = await chat.api.listMessages({
userId: user.id,
conversationId: conversation.id,
limit: 50,
});
// newest-first from the API → reverse for the LLM
const transcript = messages.reverse().map((m) => ({
role: m.role,
content: m.body,
}));Reserve the synthetic namespace
Since otherUserId accepts any non-empty string, make sure your
auth/validation layer prevents real users from registering ids in your
synthetic namespace (e.g. reserve the ai: prefix). Otherwise a user could
sign up as ai:assistant and impersonate your bot.
Token streaming
Chatpack stores whole messages. Two options while chunks arrive:
- Send the final text once generation completes - simplest, and the SSE event delivers it live.
- Edit a placeholder: send an empty-ish message immediately, then
chat.api.editMessageas chunks accumulate - each edit fans out as amessage.updatedevent.