Streamed answers
Ask a question on your page and watch the answer being written, word by word, by a model on your own server. It shows on every tab of yours, it can be stopped, and a page that lost its connection catches up.
Works today With @pikoman/ws 1.0.0-rc.11 from npm and Pikoman as it runs now. The model runs on your own server.
A folder that runs as it is, in Node and Python: stream.mjs, stream.py, .env.example, README.md, package.json, page.js, pikoman_bot.py, requirements.txt.
What it does
A member asks in the room. The bot sends the question to your model server over an OpenAI-compatible chat API (Ollama, llama.cpp, vLLM; we use Gemma 4 with Ollama) and streams the answer back as it is written: to the user who asked, on every tab and device of theirs, and to nobody else in the room. Pikoman relays the pieces and keeps none of them.
- member
- assistant
- your model
- every tab of that member
The key it needs
Rights subscribe and client_events on the room prefix: it may join the rooms and speak in them, and nothing else.
- Start from
- Chosen: Bot
- Name
- Assistant
- Rights
- Not chosen:
publishNot chosen:statsNot chosen:clientsNot chosen:disconnectNot chosen:disconnect_allChosen:subscribeChosen:client_eventsNot chosen:kickNot chosen:ban - Channel kinds
- Not chosen: public Not chosen: private Chosen: presence
- Channels
- Chosen: Only some channelsprefix
presence-ask- - Addresses
- Chosen: Only these addressesthe server the bot runs on
- Expires
- Never, or in 90 days for a trial
Set up
The docs cover the key and connecting; everything about the bot itself is on this page.
- The Pro plan. A key that joins channels is a bot’s key, and bot keys come with Pro.
- The key above. In the dashboard: your app, New API key, filled in as shown. How each choice works: Keys and rights. The secret,
sk_k_….…, is shown once (what it looks like). - Your app id and the secret in
.env, copied from.env.example. Nowhere else: not in a page, not in git. - A
user_idin your members’ join tokens. Then the answer goes to every tab of the user who asked. Without one, it goes to the asking tab only.
How it works
The page asks
client-askwith an id the page makes and the question, up to 2 000 characters. One question at a time per user; two answers are written at once.The model writes
POST {AI_URL}/chat/completionswithstream: true. The text comes back a few words at a time.It goes out in pieces
A connection may send 10 client events a second, so every 125 ms the answer that has waited longest gets one
client-answerwith all it has written since, numbered. Several answers at once share the 8 events a second; none waits long.Stop, and catch up
client-stopends an answer where it is. A page that was away, or a tab opened mid-answer, sees a gap in the numbers and sendsclient-resume; the bot sends the rest to that page alone. A finished answer is kept ten minutes.
Compared with Ably AI Transport
Ably sells a layer for streaming AI answers on top of its channels. This bot does the core of it with what Pikoman has today. Here is where they differ.
| Ably AI Transport | This bot on Pikoman | |
|---|---|---|
| Both ways in real time | Yes | Yes: members ask and stop, the bot answers |
| The answer as it is written | One message that grows (appends) | Numbered pieces, up to 8 a second, each with all that is new |
| After a dropped connection or a reload | From Ably’s stored channel history | From the bot’s memory, ten minutes; Pikoman stores nothing. For longer, keep answers in your own storage as the history bot does |
| Every device of the user | Yes | Yes, and nobody else in the room |
| Stop an answer | Built in | client-stop |
| Whether the agent is thinking or writing | Agent presence with a status | The bot is in the member list, marked by the server; no status |
| AI frameworks | Codecs for the Vercel AI SDK, OpenAI Responses and AG-UI | Any OpenAI-compatible chat API |
| The bot restarts mid-answer | Durable execution picks it up | The answer is lost; ask again |
| Push to closed apps, shared state | Push notifications, LiveObjects | No |
| Where the conversation is kept | On Ably, for your retention period | In the bot’s memory on your server |
| Price | Per message, connection minute and channel minute | A fixed plan: pricing |
Ably AI Transport as its documentation describes it on 28 October 2026. Something wrong? hello@pikoman.se, and we fix it.
The source
These are the files in the zip, as they are: copy one, download one, or take the whole folder.
Node: stream.mjs
// stream.mjs: answers questions with a language model on your own server and streams each answer,
// as it is written, to the member who asked: to every tab and device of that user. A member can
// stop an answer, and a page that lost its connection gets what it missed when it is back. Any
// server with an OpenAI-compatible chat API works: Ollama, llama.cpp, vLLM. We use Gemma 4 with
// Ollama. Node 22+. npm install, then npm start (reads .env).
//
// A connection may send 10 client events a second, so the text goes out in pieces: every 125 ms
// the answer that has waited longest gets one event with all it has written since.
//
// Its key (dashboard, New API key): rights subscribe and client_events, kind presence,
// channels: prefix presence-ask-.
import { Pikoman, TokenSigner } from "@pikoman/ws";
const ROOMS = (process.env.ROOMS || "presence-ask-lobby").split(",");
const AI_URL = process.env.AI_URL || "http://127.0.0.1:11434/v1"; // Ollama's OpenAI-compatible API
const AI_MODEL = process.env.AI_MODEL || "gemma4:12b";
const AT_ONCE = 2; // answers the model writes at the same time
const SEND_EVERY = 125; // ms between two events from the bot: 8 a second, under the limit of 10
const MAX_PIECE = 6000; // characters in one event, well under the 12 KiB an event may hold
const KEEP_FOR = 10 * 60_000; // a finished answer is kept this long, for pages that come back
const pikoman = new Pikoman(process.env.PIKOMAN_APP, {
ws_address: process.env.PIKOMAN_WS || "wss://next.pikoman.se",
});
const auth = TokenSigner.fromSecret(process.env.PIKOMAN_API_KEY, { name: "Assistant" }, 3);
/**
* Every answer, by `<room>/<id>`: who asked, the text so far, how much of it has gone out, and
* whether it is done. `pieces` holds where each event ended, so a page that comes back after
* piece 4 gets everything after it.
*/
const answers = new Map();
const waiting = []; // answers for the model, oldest first
let writing = 0;
/** Streams one answer from the model, adding to `answer.text` as it comes. */
async function write(answer) {
const response = await fetch(`${AI_URL}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(process.env.AI_KEY ? { Authorization: `Bearer ${process.env.AI_KEY}` } : {}),
},
body: JSON.stringify({
model: AI_MODEL,
stream: true,
messages: [
{ role: "system", content: "Answer the user's question clearly and briefly." },
{ role: "user", content: answer.question },
],
}),
signal: answer.stop.signal,
});
if (!response.ok) throw new Error(`${AI_URL}: ${response.status}`);
// Server-sent events: lines of "data: {json}", and "data: [DONE]" at the end.
const decoder = new TextDecoder();
let buffer = "";
for await (const chunk of response.body) {
buffer += decoder.decode(chunk, { stream: true });
let end;
while ((end = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, end).trim();
buffer = buffer.slice(end + 1);
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trim();
if (data === "[DONE]") return;
answer.text += JSON.parse(data).choices?.[0]?.delta?.content ?? "";
}
}
}
function work() {
while (writing < AT_ONCE && waiting.length) {
const answer = waiting.shift();
if (answer.done) continue; // stopped before the model got to it
writing++;
write(answer)
.catch((e) => {
if (!answer.stop.signal.aborted) {
console.error("model:", e.message);
answer.error = "The model did not answer.";
}
})
.finally(() => {
answer.done = true;
answer.finishedAt = Date.now();
writing--;
work();
});
}
}
/** Sends the next piece of one answer to the user who asked: every tab and device of theirs. */
function sendPiece(answer) {
const text = answer.text.slice(answer.sent, answer.sent + MAX_PIECE);
const last = answer.done && answer.sent + text.length === answer.text.length;
answer.sent += text.length;
answer.pieces.push(answer.sent);
const piece = { id: answer.id, seq: answer.pieces.length, text };
if (last) {
piece.done = true;
if (answer.stopped) piece.stopped = true;
if (answer.error) piece.error = answer.error;
answer.finished = true;
}
answer.room.sendTo(answer.to, "answer", piece);
}
// One event every SEND_EVERY ms, for the answer that has waited longest and has something new:
// all it has written since its last piece. Never more than 10 events a second.
setInterval(() => {
if (!pikoman.isConnected()) return; // the text waits until the bot is back
for (const [key, answer] of answers) {
if (answer.finished) {
if (Date.now() - answer.finishedAt > KEEP_FOR) answers.delete(key);
continue;
}
if (answer.sent < answer.text.length || answer.done) {
answers.delete(key);
answers.set(key, answer); // to the back of the line
sendPiece(answer);
return;
}
}
}, SEND_EVERY);
for (const name of ROOMS) {
const room = pikoman.listenPresence(name, auth);
// client-ask {id, text}: a question. The answer goes to the user who asked, on every tab.
room.onClient("ask", (from, data, info) => {
const id = String(data?.id ?? "").slice(0, 64);
const question = String(data?.text ?? "").trim().slice(0, 2000);
if (!id || !question || room.isBot(from)) return;
const to = info?.userId ? { userId: info.userId } : from;
const key = `${name}/${id}`;
if (answers.has(key)) return; // asked twice: the first answer stands
const busy = [...answers.values()].filter((a) => !a.done && a.owner === (info?.userId ?? from));
if (busy.length || waiting.length > 100) {
room.sendTo(to, "answer", { id, seq: 1, text: "", done: true, error: "Busy: ask again in a moment." });
return;
}
const answer = {
id, room, to, owner: info?.userId ?? from, question,
text: "", sent: 0, pieces: [], done: false, finished: false, stop: new AbortController(),
};
answers.set(key, answer);
waiting.push(answer);
work();
});
// client-stop {id}: the model stops writing; what it wrote so far goes out, marked stopped.
room.onClient("stop", (from, data, info) => {
const answer = answers.get(`${name}/${String(data?.id ?? "")}`);
if (!answer || answer.owner !== (info?.userId ?? from) || answer.done) return;
answer.stopped = true;
answer.done = true;
answer.finishedAt = Date.now();
answer.stop.abort();
});
// client-resume {id, after}: a page that was away gets the pieces after `after`, to it alone, as
// many as fit in one event. With `more`, it asks again from the new `seq`.
room.onClient("resume", (from, data, info) => {
const answer = answers.get(`${name}/${String(data?.id ?? "")}`);
if (!answer || answer.owner !== (info?.userId ?? from)) return;
const after = Math.max(0, Math.min(Math.floor(Number(data?.after)) || 0, answer.pieces.length));
const start = after === 0 ? 0 : answer.pieces[after - 1];
let seq = after;
while (seq < answer.pieces.length && (seq === after || answer.pieces[seq] - start <= MAX_PIECE)) seq++;
const caught = { id: answer.id, seq, text: answer.text.slice(start, seq === 0 ? 0 : answer.pieces[seq - 1]), from: after };
if (seq < answer.pieces.length) caught.more = true;
else if (answer.finished) {
caught.done = true;
if (answer.stopped) caught.stopped = true;
if (answer.error) caught.error = answer.error;
}
room.sendTo(from, "answer", caught);
});
room.refused((code) => console.error(`${name}: ${code}`));
}
console.log(`Answering in ${ROOMS.join(", ")} with ${AI_MODEL}`);
pikoman.revoked(() => {
console.error("The bot's key was deleted.");
process.exit(1);
});Python: stream.py
"""stream.py: answers questions with a language model on your own server and streams each answer,
as it is written, to the member who asked: to every tab and device of that user, in Python. A
member can stop an answer, and a page that lost its connection gets what it missed when it is
back. Any server with an OpenAI-compatible chat API works: Ollama, llama.cpp, vLLM. We use Gemma 4
with Ollama. Python 3.10+. pip install -r requirements.txt, then python stream.py (reads .env).
A connection may send 10 client events a second, so the text goes out in pieces: every 125 ms the
answer that has waited longest gets one event with all it has written since.
Its key (dashboard, New API key): rights subscribe and client_events, kind presence,
channels: prefix presence-ask-.
"""
import asyncio
import json
import threading
import time
import urllib.request
from pikoman_bot import Bot, env
ROOMS = env("ROOMS", "presence-ask-lobby").split(",")
AI_URL = env("AI_URL", "http://127.0.0.1:11434/v1") # Ollama's OpenAI-compatible API
AI_MODEL = env("AI_MODEL", "gemma4:12b")
AT_ONCE = 2 # answers the model writes at the same time
SEND_EVERY = 0.125 # seconds between two events from the bot: 8 a second, under the limit of 10
MAX_PIECE = 6000 # characters in one event, well under the 12 KiB an event may hold
KEEP_FOR = 600 # seconds a finished answer is kept, for pages that come back
bot = Bot("Assistant", access=3)
writing = asyncio.Semaphore(AT_ONCE)
answers = {} # "<room>/<id>" -> Answer, the one that has waited longest first
class Answer:
"""Who asked, the text so far, how much of it has gone out, and where each piece ended."""
def __init__(self, answer_id, room, to, owner, question):
self.id, self.room, self.to, self.owner, self.question = answer_id, room, to, owner, question
self.text = ""
self.sent = 0
self.pieces = [] # where each event ended, so a page that comes back gets what follows
self.done = False
self.finished = False
self.finished_at = 0.0
self.stopped = False
self.error = None
self.stop = threading.Event()
def stream_model(answer, add):
"""Streams one answer from the model (in a thread: urllib waits), calling add(text) as it comes."""
body = json.dumps({
"model": AI_MODEL,
"stream": True,
"messages": [
{"role": "system", "content": "Answer the user's question clearly and briefly."},
{"role": "user", "content": answer.question},
],
}).encode()
headers = {"Content-Type": "application/json"}
if env("AI_KEY"):
headers["Authorization"] = "Bearer " + env("AI_KEY")
request = urllib.request.Request(f"{AI_URL}/chat/completions", data=body, headers=headers, method="POST")
with urllib.request.urlopen(request, timeout=60) as response:
# Server-sent events: lines of "data: {json}", and "data: [DONE]" at the end.
for raw in response:
if answer.stop.is_set():
return
line = raw.decode().strip()
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
return
choices = json.loads(data).get("choices") or [{}]
text = (choices[0].get("delta") or {}).get("content") or ""
if text:
add(text)
def append(answer, text):
answer.text += text
async def job(answer):
async with writing:
if answer.done:
return # stopped before the model got to it
loop = asyncio.get_running_loop()
try:
await asyncio.to_thread(stream_model, answer, lambda text: loop.call_soon_threadsafe(append, answer, text))
except Exception as e: # noqa: BLE001: the next question may go better
if not answer.stop.is_set():
print("model:", e)
answer.error = "The model did not answer."
if not answer.done:
answer.done = True
answer.finished_at = time.time()
async def send_piece(answer):
"""Sends the next piece of one answer to the user who asked: every tab and device of theirs."""
text = answer.text[answer.sent:answer.sent + MAX_PIECE]
last = answer.done and answer.sent + len(text) == len(answer.text)
piece = {"id": answer.id, "seq": len(answer.pieces) + 1, "text": text}
if last:
piece["done"] = True
if answer.stopped:
piece["stopped"] = True
if answer.error:
piece["error"] = answer.error
try:
await answer.room.send_to(answer.to, "answer", piece)
except Exception as e: # noqa: BLE001: the connection is down; the text waits for it
return print("send:", e)
answer.sent += len(text)
answer.pieces.append(answer.sent)
if last:
answer.finished = True
async def sender():
"""One event every SEND_EVERY seconds, for the answer that has waited longest and has something new."""
while True:
await asyncio.sleep(SEND_EVERY)
if bot.id is None:
continue # not connected: the text waits until the bot is back
now = time.time()
for key in list(answers):
answer = answers[key]
if answer.finished:
if now - answer.finished_at > KEEP_FOR:
del answers[key]
continue
if answer.sent < len(answer.text) or answer.done:
answers[key] = answers.pop(key) # to the back of the line
await send_piece(answer)
break
def assistant(room):
pending = set() # tasks still with the model, kept so they are not collected
def owner_of(sender, info):
return info.get("user_id") or sender
def find(data):
answer_id = str((data if isinstance(data, dict) else {}).get("id", ""))
return answers.get(f"{room.name}/{answer_id}")
@room.on("ask")
async def ask(sender, data, info):
"""client-ask {id, text}: a question. The answer goes to the user who asked, on every tab."""
data = data if isinstance(data, dict) else {}
answer_id = str(data.get("id", ""))[:64]
question = str(data.get("text", "")).strip()[:2000]
if not answer_id or not question or room.is_bot(sender):
return
owner = owner_of(sender, info)
to = {"user_id": info["user_id"]} if info.get("user_id") else sender
key = f"{room.name}/{answer_id}"
if key in answers:
return # asked twice: the first answer stands
busy = any(not a.done and a.owner == owner for a in answers.values())
if busy or len(pending) > 100:
return await room.send_to(to, "answer", {"id": answer_id, "seq": 1, "text": "", "done": True, "error": "Busy: ask again in a moment."})
answer = answers[key] = Answer(answer_id, room, to, owner, question)
task = asyncio.create_task(job(answer))
pending.add(task)
task.add_done_callback(pending.discard)
@room.on("stop")
def stop(sender, data, info):
"""client-stop {id}: the model stops writing; what it wrote so far goes out, marked stopped."""
answer = find(data)
if not answer or answer.owner != owner_of(sender, info) or answer.done:
return
answer.stopped = True
answer.done = True
answer.finished_at = time.time()
answer.stop.set()
@room.on("resume")
async def resume(sender, data, info):
"""client-resume {id, after}: the pieces after `after`, to that page alone, as many as fit."""
answer = find(data)
if not answer or answer.owner != owner_of(sender, info):
return
try:
after = int(float(data.get("after") or 0))
except (TypeError, ValueError):
after = 0
after = max(0, min(after, len(answer.pieces)))
start = 0 if after == 0 else answer.pieces[after - 1]
seq = after
while seq < len(answer.pieces) and (seq == after or answer.pieces[seq] - start <= MAX_PIECE):
seq += 1
caught = {"id": answer.id, "seq": seq, "text": answer.text[start:answer.pieces[seq - 1] if seq else 0], "from": after}
if seq < len(answer.pieces):
caught["more"] = True
elif answer.finished:
caught["done"] = True
if answer.stopped:
caught["stopped"] = True
if answer.error:
caught["error"] = answer.error
await room.send_to(sender, "answer", caught)
for name in ROOMS:
assistant(bot.join(name))
print(f"Answering in {', '.join(ROOMS)} with {AI_MODEL}")
bot.start(sender())On your page: page.js
// page.js: ask the bot, and show its answer as it is written, on every tab and device of the user.
// A page that lost its connection asks for what it missed when it is back. Trust only the bot:
// room.isBot(from) is set by the server, never by a member.
// `pikoman` is your connection and `room` the presence channel your page joined:
// pikoman.listenPresence("presence-ask-lobby"). `show(id, text, piece)` puts the answer on the
// page, as text, never as HTML; `piece.done` says it is finished (`stopped`, `error`).
export function assistant(pikoman, room, show) {
const answers = new Map(); // id -> { seq, text, done }
let away = false;
room.onClient("answer", (from, piece) => {
if (!room.isBot(from) || typeof piece?.id !== "string") return;
let answer = answers.get(piece.id);
if (!answer) {
// Asked on another tab of yours: shown here too, from the start.
answer = { seq: 0, text: "", done: false };
answers.set(piece.id, answer);
}
// A piece as it is written follows the one before; a catch-up says where it starts.
const start = piece.from ?? piece.seq - 1;
if (start !== answer.seq) {
if (start > answer.seq) room.send("resume", { id: piece.id, after: answer.seq }); // a gap
return;
}
answer.text += String(piece.text ?? "");
answer.seq = piece.seq;
if (piece.more) room.send("resume", { id: piece.id, after: answer.seq });
if (piece.done) answer.done = true;
show(piece.id, answer.text, piece);
});
// Back after a lost connection: ask for the rest of every answer not finished.
pikoman.offline(() => (away = true));
room.members(() => {
if (!away) return;
away = false;
for (const [id, answer] of answers) {
if (!answer.done) room.send("resume", { id, after: answer.seq });
}
});
return {
/** Asks a question; the answer comes to `show` under the id this returns. */
ask(text) {
const id = crypto.randomUUID();
answers.set(id, { seq: 0, text: "", done: false });
room.send("ask", { id, text });
return id;
},
/** Stops an answer; what the model wrote so far stays, marked stopped. */
stop(id) {
room.send("stop", { id });
},
};
}pikoman_bot.py: what every Python bot here shares, the tokens, the websocket and the rooms
"""pikoman_bot.py: what a Pikoman bot needs in Python, in one file next to the bot.
- Rest: signed HTTP requests (publish, to one member, stats), each with a token made for that
one request, as KEYS.md says: the method, the path and a hash of the exact body.
- Bot and Room: a websocket that joins presence channels with join tokens signed by the bot's
own API key, keeps the member list (and which members are bots), hands client events to your
handlers, sends, sends to one member and kicks. It reconnects by itself.
Python 3.10+, pip install -r requirements.txt (websockets, BSD-3-Clause; cryptography,
Apache-2.0 OR BSD-3-Clause). Reads PIKOMAN_APP, PIKOMAN_API_KEY and, for tests, PIKOMAN_WS and
PIKOMAN_HTTP from the environment or from a .env file beside the bot.
"""
import asyncio
import base64
import hashlib
import inspect
import json
import os
import random
import signal
import time
import urllib.error
import urllib.parse
import urllib.request
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from websockets.asyncio.client import connect
from websockets.exceptions import ConnectionClosed
LOCAL = ("localhost", "127.0.0.1", "[::1]")
def load_env(path=".env"):
"""KEY=value lines from .env; what the environment already has wins."""
try:
with open(path, encoding="utf-8") as file:
lines = file.read().splitlines()
except FileNotFoundError:
return
for line in lines:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
name, value = (part.strip() for part in line.split("=", 1))
value = value.strip("\"'")
if value and name not in os.environ:
os.environ[name] = value
load_env()
def env(name, default=""):
"""A setting from the environment; empty counts as not set."""
return os.environ.get(name) or default
def b64(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def checked(address: str, secure: str) -> str:
"""https:// or wss://; plain http:// or ws:// only to this computer, so keys never go in the clear."""
parsed = urllib.parse.urlsplit(address)
if parsed.scheme != secure and not (parsed.scheme == secure[:-1] and parsed.hostname in ("localhost", "127.0.0.1", "::1")):
raise ValueError(f"{address}: use {secure}:// (plain {secure[:-1]}:// only for localhost)")
return address.rstrip("/")
class PikomanError(Exception):
def __init__(self, status, text):
super().__init__(f"{status}: {text}")
self.status = status
class Key:
"""An API key's secret, sk_<key id>.<seed>: the id goes in each token's header, the seed signs it."""
def __init__(self, secret: str):
if not secret.startswith("sk_") or "." not in secret:
raise ValueError("PIKOMAN_API_KEY looks like sk_k_….…: the secret the dashboard showed once")
key_id, seed = secret[3:].split(".", 1)
self.id = key_id
self._key = Ed25519PrivateKey.from_private_bytes(base64.urlsafe_b64decode(seed + "=" * (-len(seed) % 4)))
def sign(self, claims: dict) -> str:
"""An EdDSA JWT with the key id in its header."""
header = {"alg": "EdDSA", "typ": "JWT", "kid": self.id}
signed = b64(json.dumps(header).encode()) + "." + b64(json.dumps(claims).encode())
return signed + "." + b64(self._key.sign(signed.encode()))
def __repr__(self):
return f"Key({self.id}, [hidden])"
def target(member) -> dict:
"""A connection id ("c_…"), or {"user_id": "42"} for every connection of that user."""
if isinstance(member, dict):
return {"to_user": str(member["user_id"])}
return {"to": member}
class Rest:
"""The HTTP API, every request signed with the API key and bound to that one request."""
def __init__(self, app=None, api_key=None, address=None):
self.app = app or env("PIKOMAN_APP")
if not self.app:
raise ValueError("Set PIKOMAN_APP: your app id, app-…")
self.key = Key(api_key or env("PIKOMAN_API_KEY"))
self.address = checked(address or env("PIKOMAN_HTTP", "https://next-api.pikoman.se"), "https")
def request(self, method: str, path: str, data=None) -> dict:
body = b"" if data is None else json.dumps(data).encode()
now = int(time.time())
token = self.key.sign({
"iss": self.app, "iat": now, "exp": now + 60, "htm": method, "htu": path,
"bh": b64(hashlib.sha256(body).digest()),
})
request = urllib.request.Request(self.address + path, data=body if data is not None else None, method=method, headers={
"Authorization": "Bearer " + token,
"Content-Type": "application/json",
})
try:
with urllib.request.urlopen(request, timeout=10) as response:
text = response.read()
return json.loads(text) if text else {}
except urllib.error.HTTPError as e:
raise PikomanError(e.code, e.read().decode(errors="replace")) from None
def publish(self, channel: str, event: str, data=None, to=None) -> dict:
"""POST /{channel}/{event}; with `to`, to one member only."""
path = f"/{urllib.parse.quote(channel, safe='=@,.;')}/{urllib.parse.quote(event, safe='=@,.;')}"
if to is not None:
path += "?" + urllib.parse.urlencode(target(to))
return self.request("POST", path, data)
def stats(self, channel: str) -> dict:
return self.request("GET", f"/{urllib.parse.quote(channel, safe='=@,.;')}/stats")
async def call(fn, *args):
"""Runs a handler, plain or async; an error in it is printed and the bot goes on."""
try:
result = fn(*args)
if inspect.isawaitable(result):
await result
except Exception as e: # noqa: BLE001: one handler must never stop the bot
print(f"handler {getattr(fn, '__name__', fn)}: {e!r}")
def client_name(event: str) -> str:
return event if event.startswith("client-") else "client-" + event
class Room:
"""A presence channel the bot is in: its members, which of them are bots, and its events."""
def __init__(self, bot, name: str):
self.bot = bot
self.name = name
self.members = {} # connection id -> member data, as their join token gave it
self.bots = {} # connection id -> the API key id a bot joined with: only the server says
self._handlers = {}
self._any = []
self._joins = []
self._leaves = []
self._errors = []
def on(self, event: str):
"""@room.on("message"): fn(sender, data, info) for that client event (client-message)."""
def add(fn):
self._handlers.setdefault(client_name(event), []).append(fn)
return fn
return add
def on_any(self, fn):
"""@room.on_any: fn(sender, event, data, info) for every client event."""
self._any.append(fn)
return fn
def on_join(self, fn):
"""@room.on_join: fn(connection, data) when a member joins."""
self._joins.append(fn)
return fn
def on_leave(self, fn):
"""@room.on_leave: fn(connection, data) when a member leaves."""
self._leaves.append(fn)
return fn
def on_error(self, fn):
"""@room.on_error: fn(code), such as join_refused, not_allowed or rate_limited."""
self._errors.append(fn)
return fn
def is_bot(self, connection: str) -> bool:
return connection in self.bots
def member(self, connection: str) -> dict:
data = self.members.get(connection)
return data if isinstance(data, dict) else {}
async def send(self, event: str, data=None):
"""A client event to the other members (at most 10 a second per connection)."""
await self.bot.send({"op": "send", "channel": self.name, "event": client_name(event), "data": data, "echo": False})
async def send_to(self, member, event: str, data=None):
"""To one member only: a connection id, or {"user_id": "42"}."""
await self.bot.send({"op": "send", "channel": self.name, "event": client_name(event), "data": data, "echo": False, **target(member)})
async def kick(self, member, reason=None, ban_seconds=None):
"""Out of this room: needs the kick right (ban, with ban_seconds)."""
message = {"op": "kick", "channel": self.name, **target(member)}
if reason:
message["reason"] = reason
if ban_seconds:
message["ban_seconds"] = ban_seconds
await self.bot.send(message)
async def handle(self, message: dict):
op = message.get("op")
if op == "members":
self.members = dict(message.get("members") or {})
self.bots = dict(message.get("bots") or {})
elif op == "member_joined":
self.members[message["id"]] = message.get("data")
if message.get("bot"):
self.bots[message["id"]] = message["bot"]
for fn in self._joins:
await call(fn, message["id"], message.get("data"))
elif op == "member_left":
data = self.members.pop(message["id"], None)
self.bots.pop(message["id"], None)
for fn in self._leaves:
await call(fn, message["id"], data)
elif op == "client_event":
sender, event, data = message.get("from"), message.get("event", ""), message.get("data")
# Set by the server, never by the sender: direct (to this bot alone), user_id, bot.
info = {k: message[k] for k in ("direct", "user_id", "bot") if k in message}
for fn in self._handlers.get(event, []):
await call(fn, sender, data, info)
for fn in self._any:
await call(fn, sender, event, data, info)
elif op == "error":
print(f"{self.name}: {message.get('code')}")
for fn in self._errors:
await call(fn, message.get("code"))
class Bot:
"""One websocket for the bot, in as many rooms as it joins. await bot.run() keeps it going."""
def __init__(self, name: str, access: int = 3, app=None, api_key=None, address=None):
self.app = app or env("PIKOMAN_APP")
if not self.app:
raise ValueError("Set PIKOMAN_APP: your app id, app-…")
self.key = Key(api_key or env("PIKOMAN_API_KEY"))
self.address = checked(address or env("PIKOMAN_WS", "wss://next.pikoman.se"), "wss")
self.data = {"name": name} # what the members see next to "bot"
self.access = access # 3: may speak (the key needs client_events); 0: listens
self.rooms = {}
self.id = None
self._ws = None
self._ready = []
def join(self, channel: str) -> Room:
room = self.rooms[channel] = Room(self, channel)
return room
def on_ready(self, fn):
"""@bot.on_ready: fn() each time the bot is connected and has asked to join its rooms."""
self._ready.append(fn)
return fn
def token(self, channel: str) -> str:
"""A join token for this connection and channel, signed with the bot's API key."""
return self.key.sign({
"connection": self.id, "channel": channel, "access": self.access,
"data": self.data, "exp": int(time.time()) + 300,
})
async def send(self, message: dict):
if self._ws is not None:
await self._ws.send(json.dumps(message))
def start(self, *tasks):
"""Runs the bot (and any other coroutines, such as a web server) until Ctrl+C or SIGTERM."""
def stop(*_):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, stop) # as from systemd or podman stop: the same as Ctrl+C
async def main():
await asyncio.gather(self.run(), *tasks)
try:
asyncio.run(main())
except KeyboardInterrupt:
print("Stopped.")
async def run(self):
delay = 1
paused = 60 # after 4402: the first wait, doubling to five minutes
while True:
try:
async with connect(f"{self.address}/{self.app}") as ws:
self._ws = ws
async for raw in ws:
message = json.loads(raw)
if message.get("op") == "connected":
delay = 1
self.id = message["id"]
for name in self.rooms:
await self.send({"op": "join", "channel": name, "token": self.token(name)})
for fn in self._ready:
await call(fn)
elif message.get("channel") in self.rooms:
await self.rooms[message["channel"]].handle(message)
elif message.get("op") == "error":
print("pikoman:", message.get("code"))
except ConnectionClosed as e:
code = e.rcvd.code if e.rcvd is not None else None
if code == 4403:
raise SystemExit("The bot's key was deleted.") from None
if code == 4401:
raise SystemExit("The app disconnected the bot.") from None
if code == 4402:
# The key's bot rights are paused (the app's owner is off Pro): not for good.
# Try again in minutes, and the bot is back on its own when Pro is.
self._ws = None
self.id = None
print(f"The bot's key is paused until the app is on Pro again; trying again in about {paused // 60} min.")
await asyncio.sleep(random.uniform(paused / 2, paused))
paused = min(paused * 2, 300)
continue
print("connection closed:", code if code is not None else "no close frame")
except OSError as e:
print("could not connect:", e)
self._ws = None
self.id = None
paused = 60
# Back again after a random wait, longer each time it fails, up to 30 s.
await asyncio.sleep(random.uniform(0.5, delay))
delay = min(delay * 2, 30)# Copy to .env and fill in. Never commit .env.
# Your app id, app-…
PIKOMAN_APP=
# The bot's own API key, sk_k_….… (rights subscribe and client_events, prefix presence-ask-)
PIKOMAN_API_KEY=
# The rooms to answer in, comma-separated (default presence-ask-lobby)
ROOMS=
# Your model server's OpenAI-compatible API (default http://127.0.0.1:11434/v1, Ollama) and model (default gemma4:12b)
AI_URL=
AI_MODEL=
# A key for the model server, if it wants one (sent as Authorization: Bearer)
AI_KEY=
# Another server than wss://next.pikoman.se, for tests
PIKOMAN_WS={
"name": "pikoman-stream-bot",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Streams answers from a language model on your own server to the member who asked, in a Pikoman room.",
"license": "MIT",
"scripts": {
"start": "node --env-file=.env stream.mjs"
},
"engines": {
"node": ">=22"
},
"dependencies": {
"@pikoman/ws": "1.0.0-rc.11"
}
}websockets==15.0.1
cryptography==46.0.3Run it
# unpack stream-bot.zip, then in the folder:
npm install
cp .env.example .env # fill in PIKOMAN_APP and PIKOMAN_API_KEY
npm startpython -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
python stream.pyThe model, once: ollama pull gemma4:12b (Apache-2.0). Check the licence of any other model you choose: some do not allow commercial use. We ran both versions against Gemma 4 in Ollama: an answer streamed in pieces to two tabs of the same user and to no one else, a stopped answer ended where it was, and a tab opened mid-answer caught up from the start.
Ideas to take it further
- Keep the conversation: send the last few questions and answers with each new question, so the model remembers.
- Answer in the room for everyone, a shared assistant, with
sendinstead ofsendTo. - Keep finished answers in your own storage, as the history bot does, to catch up after hours, not minutes.
- A private conversation: a
private-encrypted-channel per user, so not even Pikoman could read it.