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
-
A key of its own. In the dashboard, your app, New API key, template Bot (or
Moderator): rights
subscribeandclient_events, kind presence, and only the rooms it works in, for example prefixpresence-chat-. The secret (sk_k_….…) goes in the bot's environment asPIKOMAN_API_KEYand nowhere else. - 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.
-
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.
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 channelsWhat a bot has to work with
| To | Use | Right |
|---|---|---|
| See which channels are in use, how many are in each, how many messages | GET /channels?info=user_count,message_count, GET /{channel}/stats | stats |
| Be told when channels fill and empty, who comes and goes | Webhooks | set in the dashboard |
| Read what members say | Join the channel with its API key, or the client_event webhook | subscribe |
| Answer one member only | room.sendTo(member, …), or POST /{channel}/{event}?to_user=42 | client_events / publish |
| Remove someone from one room, or keep them out a while | room.kick(member, { reason, banSeconds }), or DELETE /{channel}/clients?to_user=42 | kick / ban |
| Tell everyone | POST /{channel}/{event}, POST /mass_events | publish |
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
-
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). -
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. - 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: 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) ?? []));
}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.
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_memberroom.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_idin its signed member data, so a new tab as the same user is refused too, withbanned. 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_removedwebhook 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) orrest.kick(channel, { userId: "42" }, { banSeconds: 600 }). -
To throw someone out of the whole app, not one room:
rest.disconnect(connectionId), with the rightdisconnect. The connection leaves every channel and is closed with4401; their page hearspikoman.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: 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);// 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();
});// 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 getssubscribe,client_events,kickandbanwith the prefixpresence-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.
// 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.