livqenoDocs

GuidesBuild a chat application

Build a chat application

Build a chat channel with saved history, online status, and typing indicators.

In this guide you'll build a chat channel step by step, like a support chat between a customer and an agent, or a team channel.

Where this fits: project → API key → your backend makes a chat token → your app connects. Chat is separate from calls: it has its own token and its own connection, so you can build it with or without video.

Two words used throughout:

  • A conversation is where messages live. Users must be members of a conversation to read or send in it.
  • A chat token is a short-lived pass your backend makes for one user. It lists which conversations the user may use and what they may do there.

What you'll build

  • Messages that are still there after a reload.
  • A member list showing who is online.
  • "Someone is typing…" indicators.
  • Older messages that load as you scroll up.

What you need

  • A project and an API key. See API credentials.
  • A backend with @livqeno/server installed.
  • @livqeno/chat installed in your app with npm, pnpm, yarn or bun (add @livqeno/react if you want hooks).

Steps

1. Create a conversation on your backend

On your backend, create the conversation and add its members. The app can't do either. If it could, any user could add themselves to any conversation and read it. In a support app, you'd do this when a customer opens a ticket:

import { Livqeno } from '@livqeno/server';
 
const livqeno = new Livqeno({
  apiKey: process.env.LIVQENO_API_KEY!,
  baseUrl: process.env.LIVQENO_API_URL!, // https://api.livqeno.com
});
 
const conversation = await livqeno.chat.createConversation({
  name: 'support-room-42',
  getOrCreate: true, // safe to re-run: returns the existing one (members apply only on first create)
  type: 'CHANNEL',
  members: [
    { userId: 'alice', role: 'ADMIN' },
    { userId: 'bob' },
  ],
});

type: 'CHANNEL' is a group conversation. Alice is an ADMIN, so she can manage the conversation; Bob is a regular member.

2. Make a chat token

On your backend, add an endpoint that makes a chat token for the logged-in user. The app calls it before connecting:

app.post('/chat-token', async (req, res) => {
  const token = await livqeno.chat.createToken({
    userId: req.user.id,                       // from YOUR session, never the request body
    conversations: [conversation.publicId],
    scopes: ['chat:read', 'chat:send'],
    expiresIn: 3600,
  });
  res.json(token);   // { token, chatUrl, apiUrl, conversations, expiresAt, ... }
});

The scopes say what this token may do: here, read and send. They can only narrow what the user's role allows, never widen it.

3. Connect and send a message

In your app, get the token from your endpoint, connect, and send. Every message from anyone in the conversation (including your own) arrives through the message event:

import { createChatClient } from '@livqeno/chat';
 
const minted = await fetch('/chat-token', { method: 'POST' }).then((r) => r.json());
 
const chat = createChatClient({
  token: minted.token,
  apiUrl: minted.apiUrl,
  chatUrl: minted.chatUrl,
  onTokenExpiring: async () => {
    const fresh = await fetch('/chat-token', { method: 'POST' }).then((r) => r.json());
    return fresh.token;
  },
});
 
await chat.connect({ room: minted.conversations[0] });
 
chat.on('message', (message) => appendToUi(message));
 
await chat.sendMessage({
  text: 'Hello everyone',
  clientMessageId: crypto.randomUUID(),
});

You should now see "Hello everyone" arrive in your message handler. Open a second tab as a different user and it appears there too.

4. Load older messages

When someone opens a conversation, show the latest messages, then load older ones as they scroll up. History comes in pages: you ask for 50, and get a cursor (a bookmark) to ask for the 50 before those:

const page = await chat.messages.list({ limit: 50 });
// newest first
const older = await chat.messages.list({ before: page.nextCursor, limit: 50 });
  • Messages come newest first.
  • Pass page.nextCursor to get the next older page.
  • Don't build or read cursors yourself. A bad cursor returns INVALID_CURSOR instead of quietly starting over, so retrying is safe.

5. Add typing and online status

"Bob is typing…" and a green "online" dot make chat feel alive. In your app, send typing signals as the user types, and listen for other people's typing and presence (online status):

composer.addEventListener('input', () => void chat.startTyping());
composer.addEventListener('blur', () => void chat.stopTyping());
 
chat.on('typing', (e) => showTyping(e.userId, e.isTyping));
chat.on('presence', (e) => setOnline(e.userId, e.status === 'online'));
 
await chat.setPresence('online');

Both are temporary. They are not saved, and they expire on their own:

Expires after
Typing7 seconds
Presence (online)45 seconds

Just call startTyping() on each input and let it expire. You don't need your own timer.

How it works

  • The server decides message order. When sendMessage() finishes, the message is saved. The sender also gets the message event, like everyone else. Draw messages from the event, not from sendMessage()'s result. Then everyone sees the same order, even if their clocks differ.
  • A message may arrive more than once, so send an id. On a bad network, the app may not know whether a send got through, so it retries. Livqeno ignores repeats with the same clientMessageId from the same sender in the same conversation, so a retry never posts the message twice. If a retry was a repeat, the result has deduplicated: true. This is called being idempotent: doing it twice has the same effect as doing it once.
  • Two checks decide what a user can do: the token's scopes and the user's role in the conversation. The token can only narrow the role. See Permissions.
  • "Offline" means presence expired. There's no "went offline" message. If a user's heartbeat stops for 45 seconds, they show as offline. This also works when a browser crashes.

Production tips

  • Refresh tokens. Set onTokenExpiring, or chat stops after an hour. This is the most common chat bug.
  • Expect duplicates in your own database too. If you also save messages from webhooks, skip ones whose message id you already have.
  • Limits: text up to 4000 characters, metadata up to 4096 bytes. Check these in your text box so users know before they press send.
  • Attachments need file storage set up. Without it, the API returns RAVEN_NOT_CONFIGURED. Check Known limitations before you promise attachments.
  • Add your site to Allowed Origins (Settings → Security → Allowed Origins) before launch. Once the list has entries, a page from any other site is rejected with close code 4403. localhost works by default. See Browser security & CORS.
  • Choose how long to keep messages. By default they're kept forever. You can set retention per conversation; see Conversations.

Next steps