Build a bot

A bot in Pikoman is a program that is in your channels, with an API key of its own, and that members see as a bot: it answers, greets, keeps history, moderates or counts from inside the room. Pikoman marks it as a bot next to its member data, set by the server and never by what anyone writes.

If all you need is numbers, or to push events from your backend, you need no bot: see Numbers without a bot. Bot keys (the rights subscribe and client_events, which join channels) need the Pro plan; keys for your backend work on Free, moderating over HTTP with kick, ban and disconnect included.

Set one up

  1. A key of its own. In the dashboard, your app, New API key, template Bot (or Moderator): rights subscribe and client_events, kind presence, and only the rooms it works in, for example prefix presence-chat-. The secret (sk_k_….…) goes in the bot's environment as PIKOMAN_API_KEY and nowhere else.
  2. It signs its own join tokens with that key, so it needs nothing from your token endpoint. With access 3 it may speak; with 0 it only listens.
  3. It joins like any member and is listed as a bot: room.isBot(id) and the key it joined with, for everyone in the room.
The start of every bot (Node 22+)
import { Pikoman, OfflineAuthenticator } from "@pikoman/ws";

const pikoman = new Pikoman(process.env.PIKOMAN_APP, { ws_address: "wss://next.pikoman.se" });
const auth = OfflineAuthenticator.fromSecret(process.env.PIKOMAN_API_KEY, { name: "Greeter" }, 3);
const room = pikoman.listenPresence("presence-chat-lobby", auth);
room.refused((code) => console.error(code)); // join_refused: check the key's rights and channels

What a bot has to work with

ToUseRight
See which channels are in use, how many are in each, how many messagesGET /channels?info=user_count,message_count, GET /{channel}/statsstats
Be told when channels fill and empty, who comes and goesWebhooksset in the dashboard
Read what members sayJoin the channel with its API key, or the client_event webhooksubscribe
Answer one member onlyroom.sendTo(member, …), or POST /{channel}/{event}?to_user=42client_events / publish
Remove someone from one room, or keep them out a whileroom.kick(member, { reason, banSeconds }), or DELETE /{channel}/clients?to_user=42kick / ban
Tell everyonePOST /{channel}/{event}, POST /mass_eventspublish

Message counts are kept by the server for each channel since it became occupied: the count starts at 0 when the first member joins and is gone when the last one leaves. Pushes and client events count once each, however many they reach; a push to an empty channel is not counted. app_message_count is the app's total since the server last started. Keep your own running total if you need one across days.

Three limits, and what they do not mean

  1. 100 channels per connection is per client or bot, not per room: a room can have any number of connections in it. A bot (a connection that joined a channel with an API key) may be in 1 000. Counting needs no membership at all: the counts come from GET /channels (no bot needed).
  2. 500 members is for presence channels only, where everyone gets the member list. A counter that thousands of pages show goes in a public channel such as status: pages join it as listeners, there is no member list and no member cap, and the bot pushes the number every few seconds.
  3. Connections at once are set by your plan (Free 100, Pro 1 000). Every page with a websocket open is one. A busy homepage that shows a counter therefore needs the plan, or pattern B below: your backend writes the number to your own site (a cached JSON file or your API), and the page fetches it every 30 seconds, with no websocket per visitor.

The live counter

Sits in your chat rooms as a listener, counts every message and says the total, pushed to a public status channel that every page can show, or inside the room. Step by step, with its code: Build your bot and the counter.

The Presence greeter

Welcomes each person who joins a room, to them alone, with the names your backend signed: "Welcome, Ada! Bo and Cy are here." It is a member of the room, so it sees each member_joined as it happens and answers with room.sendTo(id, "welcome", { text }); a person with two tabs is greeted once, and someone seen before gets "Welcome back". Its key: rights subscribe and client_events, kind presence, prefix presence-chat-. The whole bot, ready to run: the greeter.

A greeter that should not be in the room is your backend instead: the webhook member_added names the user and the connection, and rest.publish(room, "welcome", { text }, { to: connection }) greets that connection alone.

The history bot

Keeps the last messages of each room in its own storage, and when a member comes back it sends them what they missed, to them alone with sendTo. It has to be in the rooms to read them, so it joins as a bot. Members are told apart by the user_id in the member data your backend signs ({"user_id": "42", "name": "Ada"}); the server passes it on with each client event as info.userId, so a bot never has to trust what a message says about its sender. Its key: rights subscribe and client_events, kinds presence, prefix presence-chat-.

history-bot.mjs
// history-bot.mjs: keeps the last 50 messages of each room in its own storage, and when someone
// comes back it sends them what they missed, to them alone. Needs an API key with subscribe and
// client_events, limited to the rooms (prefix presence-chat-). Node 22+.
import { appendFile, readFile } from "node:fs/promises";
import { Pikoman, OfflineAuthenticator } from "@pikoman/ws";

const ROOMS = ["presence-chat-lobby", "presence-chat-help"];
const KEEP = 50;
const history = new Map(); // room -> [{ at, from, text }], also in rooms.jsonl
const lastSeen = new Map(); // "room user" -> time the user was last in the room
const userOf = new Map(); // "room connection" -> user id, while they are in

// Its own storage: one JSON line per message, read back at start.
try {
  for (const line of (await readFile("rooms.jsonl", "utf8")).split("\n").filter(Boolean)) {
    const { room, ...message } = JSON.parse(line);
    history.set(room, [...(history.get(room) ?? []), message].slice(-KEEP));
  }
} catch {}

const pikoman = new Pikoman(process.env.PIKOMAN_APP, { ws_address: "wss://next.pikoman.se" });
// Joins as a bot with its API key (access 3: it may send). Members see it as one.
const auth = OfflineAuthenticator.fromSecret(process.env.PIKOMAN_API_KEY, { name: "History" }, 3);

for (const name of ROOMS) {
  const room = pikoman.listenPresence(name, auth);

  // Every message in the room, as the server relays it.
  room.onClient("message", async (from, data, info) => {
    const message = { at: Date.now(), from: info?.userId ?? from, text: String(data?.text ?? "").slice(0, 500) };
    history.set(name, [...(history.get(name) ?? []), message].slice(-KEEP));
    await appendFile("rooms.jsonl", JSON.stringify({ room: name, ...message }) + "\n");
  });

  // Someone joined. Their user id is in the member data your backend signed.
  room.subscribe((_type, _count, connection, data) => {
    if (!data?.user_id) return;
    userOf.set(`${name} ${connection}`, data.user_id);
    const key = `${name} ${data.user_id}`;
    const since = lastSeen.get(key) ?? 0;
    const missed = (history.get(name) ?? []).filter((m) => m.at > since);
    // To that connection only: nobody else in the room gets it.
    if (missed.length) room.sendTo(connection, "missed", { since, messages: missed });
  });
  room.unsubscribe((_type, _count, connection) => {
    const user = userOf.get(`${name} ${connection}`);
    userOf.delete(`${name} ${connection}`);
    if (user) lastSeen.set(`${name} ${user}`, Date.now());
  });

  // Someone asks for more; answered to them alone.
  room.onClient("history", (from) => room.sendTo(from, "history", history.get(name) ?? []));
}
In the page
const room = pikoman.listenPresence("presence-chat-lobby");
room.onClient("missed", (from, { messages }, info) => {
  // info.direct: sent to this member alone. room.isBot(from): only the server says who is a bot.
  if (info?.direct && room.isBot(from)) messages.forEach(showMessage);
});
room.send("message", { text: "Hello" });  // what the bot stores

A message to one member reaches only that connection (or every connection of that user), and only if they are in the channel: anyone else is refused with no_such_member. It counts against the sender's 10 client events a second like any other. From a backend the same is rest.publish("presence-chat-lobby", "missed", items, { toUser: "42" }).

Moderation: kick and ban

A moderator is a bot whose key also has kick, and ban to keep someone out for a while. Both work in one room at a time and only in the key's channels: a moderator limited to presence-chat- can do nothing in presence-admin. The member removed is told why, leaves that room and keeps the rest of your app; the others see an ordinary leave. Its key: template Moderator (rights subscribe, client_events, kick, ban), kind presence, prefix presence-chat-. A full word-filter bot: the moderator.

In the bot
room.onClient("message", (from, data, info) => {
  if (!isAbuse(data?.text)) return;
  // Every tab of that user (the user id your backend signed), out for ten minutes.
  if (info?.userId) room.kick({ userId: info.userId }, { reason: "Keep it friendly", banSeconds: 600 });
  else room.kick(from, { reason: "Keep it friendly" }); // that connection
});
room.refused((code) => console.error(code)); // not_allowed: the key lacks kick here; no_such_member
On your page
room.kicked(({ reason, banSeconds }) => showNotice(`You were removed from the room. ${reason ?? ""}`));
room.refused((code) => code === "banned" && showNotice("You cannot join this room again yet."));
  • A ban covers that connection and the user_id in its signed member data, so a new tab as the same user is refused too, with banned. Bans last at most a day, are kept per room (100 at most) and end early if the room empties. For longer, stop signing tokens for that user in your backend.
  • A user can never kick, and a bot can kick neither itself nor another bot. A kick counts against the bot's 10 client events a second.
  • Every removal reaches your backend as a member_removed webhook with "reason": "kicked", the key that did it and its reason, so moderation can be looked back on.
  • Your backend can do the same without a bot: DELETE /{channel}/clients?to_user=42&ban_seconds=600 (HTTP API) or rest.kick(channel, { userId: "42" }, { banSeconds: 600 }).
  • To throw someone out of the whole app, not one room: rest.disconnect(connectionId), with the right disconnect. The connection leaves every channel and is closed with 4401; their page hears pikoman.disconnected(fn) and does not connect again by itself. Keep it off a moderator's key unless you mean it: it names no channel, so it reaches every room of the app. Whether they may come back is your backend's call, when it signs their next token.

Numbers without a bot: a backend script with a stats key

When all you want is numbers, you do not need a bot: no membership, no connection, nothing members see. The server counts the messages in each channel, and a script on your backend asks for them with a key that has the stats right, then shows the total on your pages either way: pushed to a public channel the pages listen to (pattern A), or written to a file your site serves (pattern B). Its key: rights stats and publish, channels chat- and status. It works on Free.

stats-script.mjs
// stats-script.mjs: counts messages per channel and shows the totals on your site.
// Needs only an API key with the stats right (and publish, for pattern A). No memberships:
// the server counts the messages itself. Node 22+, npm install @pikoman/ws@next
import { writeFile } from "node:fs/promises";
import { Rest } from "@pikoman/ws";

const rest = new Rest(); // PIKOMAN_APP and PIKOMAN_API_KEY

async function totals() {
  const counts = {};
  let after;
  do {
    // A page of up to 1 000 channels at a time, only the ones this key may see.
    const page = await rest.channels({ prefix: "chat-", info: ["message_count", "user_count"], limit: 1000, after });
    for (const [name, info] of Object.entries(page.channels)) counts[name] = info.message_count ?? 0;
    after = page.next;
  } while (after);
  const messages = Object.values(counts).reduce((a, b) => a + b, 0);
  return { messages, channels: Object.keys(counts).length, counts, at: new Date().toISOString() };
}

setInterval(async () => {
  try {
    const now = await totals();
    // Pattern A: push the numbers to a public channel that pages listen to.
    await rest.publish("status", "totals", { messages: now.messages, channels: now.channels });
    // Pattern B: write them where your site serves static files; pages fetch it every 30 s.
    await writeFile("/var/www/site/stats.json", JSON.stringify(now));
  } catch (e) {
    console.error("stats:", e.message);
  }
}, 5_000);
On your page: listen to the status channel
// Pattern A: a public channel, joined as a listener. No token, no member cap.
import { Pikoman } from "@pikoman/ws";
const pikoman = new Pikoman("app-yourapp");
pikoman.listen("status").on("totals", ({ messages }) => {
  document.querySelector("#messages").textContent = messages.toLocaleString();
});
On your page: fetch stats.json
// Pattern B: no websocket at all, so it costs no connection of your plan.
async function show() {
  const { messages } = await (await fetch("/stats.json", { cache: "no-store" })).json();
  document.querySelector("#messages").textContent = messages.toLocaleString();
}
show();
setInterval(show, 30_000);

Letting someone else's bot in

  • One API key per bot, made for it in the dashboard, with the fewest rights and the narrowest channels that work: a moderation bot for presence-chat- rooms gets subscribe, client_events, kick and ban with the prefix presence-chat-, nothing else. It can never publish as your backend or see other channels.
  • Its own webhook, limited to the same channels, if it wants to be told rather than join.
  • To stop it, delete its key (or, for one room, kick it from your backend): its connections are closed and its requests refused within about 30 seconds. Its webhook goes with a click too.
  • Bots are named as bots: other members see isBot(id) and the key it joined with, set by the server and never by member data.

An announcement to everyone

Give the pages a shared public channel, such as announcements, and push to it with a key that may only publish there (exact name announcements). One request reaches every page. To several channels at once, use POST /mass_events.

announce.mjs
// One key that may only publish to "announcements" (dashboard: rights publish, exact name
// announcements). Every page listens to the channel; one push reaches them all.
await rest.publish("announcements", "notice", { text: "Maintenance at 22:00" });

// Several channels in one request: POST /mass_events, at most 1 000 channels.

We are rebuilding the dashboard

Self-service sign-up is closed while we do. Until the new dashboard is ready we set up apps by hand: write to us with a line about what you are building, and we will set up your app and get back to you.

Write to hello@pikoman.se