Skip to content

Quick start

Seven steps, and about 70 lines of Node at the end. One cast runs through every example on this site: lucky, a slot machine wisp, in the server Saturday Squad, which ana owns, where ana, bo, caio and dani run its commands in #general.

POST /api/wisps
Authorization: Bearer <your token>
Content-Type: application/json
{
"name": "lucky",
"description": "Slots, for fun. Coins only, never money.",
"hears": [],
"does": ["post", "commands"],
"requiredDoes": ["post", "commands"],
"permissions": 0,
"slices": 1
}
  • name is required, and a username: 2 to 32 letters, digits, _, - or ., free across people and wisps whatever the casing.
  • description is up to 400 characters, which admins read before they add it.
  • hears and does are what it asks for (Introduction). requiredHears and requiredDoes are the items an admin cannot leave out, and must be among what it asks for.
  • permissions is the server permissions it asks for, as bits added together (see Acting in a server). Any of them but Administrator.
  • slices is 1 to 64: 1 until your wisp outgrows one process (Slices and scale).

Everything but name may be left out: a wisp that asks for nothing, with one slice. The answer holds the wisp and its secret:

{
"wisp": {
"id": "d2b6f4a8-3e1c-4b7d-9a05-6c8e0f2a4b19",
"name": "lucky",
"avatarUrl": "https://…",
"description": "Slots, for fun. Coins only, never money.",
"hears": [],
"does": ["post", "commands"],
"permissions": 0,
"requiredHears": [],
"requiredDoes": ["post", "commands"],
"slices": 1,
"reviewed": false,
"servers": 0,
"createdAt": "2026-10-17T14:02:11.482Z"
},
"secret": "wisp_…"
}

servers is how many servers it is in, and reviewed whether Woosh has reviewed it. Its picture is one of Woosh’s own; choosing your own, and deleting a wisp, are not open yet. You can make up to 25 wisps.

A refused request answers 403, 404 or 409 with {"error": "<words>", "traceId": "…"}. Making a wisp, the words are one of:

Status error
403 “Making wisps is open to testers for now.”
403 “Keep your account to make a wisp.” (from a guest)
409 “That username is taken.”, or what is wrong with the name, such as “Usernames can only use letters, numbers, _ - and .”
409 “A description can be up to 400 characters.”
409 “A wisp cannot hear ‘typing’.” or “A wisp cannot do ‘kick’.”: an item that is not one
409 “A wisp can ask for any server permission but administrator.”
409 “A wisp can only require what it asks for.”
409 “A wisp has 1 to 64 slices.”
409 “You can make up to 25 wisps.”

Later, GET /api/wisps lists your wisps, each as above without the secret, and PATCH /api/wisps/{id} changes one: send only what changes, with the same fields and rules. A wisp that comes to ask for less loses the rest at once in every server it is in; one that asks for more waits for each server’s admin to approve it. A wisp that is not yours answers 404, “No such wisp”.

PUT /api/wisps/d2b6f4a8-3e1c-4b7d-9a05-6c8e0f2a4b19/commands
Authorization: Bearer <your token>
Content-Type: application/json
[
{
"name": "spin",
"description": "Spin the reels",
"options": [{ "name": "bet", "kind": "number", "description": "Coins to bet" }]
}
]

The list replaces the wisp’s commands whole, and the answer is the list. Commands has the rules.

An Administrator of the server adds it, approving what it asks for: the owner, or anyone with a role that has Administrator. The approval page in the apps is not open yet; today it is one request, here made by ana:

POST /api/servers/5c3b1a2d-8e4f-4a6b-9c7d-0e1f2a3b4c55/wisps
Authorization: Bearer <ana's token>
Content-Type: application/json
{
"wisp": "d2b6f4a8-3e1c-4b7d-9a05-6c8e0f2a4b19",
"hears": [],
"does": ["post", "commands"],
"permissions": 0,
"channels": ["6d4c2b3e-9f5a-4b7c-8d6e-1f2a3b4c5d66"]
}

hears, does and permissions are what the admin approves: only what the wisp asks for, and everything it requires. channels names where it may hear and act; leave it out for every channel, those made later too. Anyone signed in can read what a wisp asks for, and who made it, with GET /api/wisps/{id}.

The wisp joins the server with a role made for it and named after it, which holds the permissions, and your line hears server with new and its grants (What a wisp hears). The answer is the wisp as the server holds it:

{
"id": "d2b6f4a8-3e1c-4b7d-9a05-6c8e0f2a4b19",
"name": "lucky",
"avatarUrl": "https://…",
"description": "Slots, for fun. Coins only, never money.",
"hears": [],
"does": ["post", "commands"],
"permissions": 0,
"channels": ["6d4c2b3e-9f5a-4b7c-8d6e-1f2a3b4c5d66"],
"asksMore": null,
"installedAt": "2026-10-17T14:05:40.118Z"
}

asksMore is what the wisp asks for beyond its grant (hears, does, permissions), or null when it was given all of it. From then on, the server’s admins look after it:

Request
GET /api/servers/{id}/wisps the server’s wisps, each as above
PATCH /api/servers/{id}/wisps/{wisp} a new grant: hears, does, permissions or channels. What is left out stands, and "channels": [] is every channel. Approving what a wisp asks for beyond its grant is this
DELETE /api/servers/{id}/wisps/{wisp} the wisp out, its role with it: answers 204, and your line hears server.gone with removed

Each answers 403, “Missing permission: Administrator.”, to anyone else. Adding or changing a grant is refused with:

Status error
404 “No such wisp”
404 “This wisp is not in the server.”
404 “A channel is not in this server.”
409 “This wisp is in the server already.”
409 “A wisp can be given only what it asks for.”
409 “This wisp needs what it requires to work.”
409 “This wisp reads messages and is in as many servers as it can be until Woosh has reviewed it.”
403 “You can’t join this server.”: the wisp is banned there

A wisp kicked or banned is out as if removed, and hears server.gone with kicked or banned. Coming back is a new grant.

The wisp’s id and secret buy a token with OAuth2’s client credentials request, which every OAuth2 library already makes:

POST /api/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=d2b6f4a8-3e1c-4b7d-9a05-6c8e0f2a4b19&client_secret=wisp_…
{
"access_token": "eyJhbGciOiJIUzI1NiJ9.eyJuYW1laWQiOiJkMmI2ZjRhOCIsIndpc3AiOiIxIn0.c2ln",
"token_type": "Bearer",
"expires_in": 3600,
"line": "wss://api.woosh.no/ws"
}

The id and secret may also go in an HTTP Basic header, each URL-encoded first. A wrong pair answers 401 with {"error":"invalid_client"}, and any grant_type but client_credentials 400 with {"error":"unsupported_grant_type"}. One address may ask 120 times a minute; past that, 429.

The token lives expires_in seconds, an hour. It is read when a line opens, so an open line outlives it. Buy a new one before you open a line if the last is near its end.

Open a WebSocket at line, always the address the answer gives and never one of your own, with ?delivery=seen on it. The token goes in the first message, never in the address:

{"t":"hello","v":2,"a":"eyJhbGciOiJIUzI1NiJ9.eyJuYW1laWQiOiJkMmI2ZjRhOCIsIndpc3AiOiIxIn0.c2ln","f":1}

Send it within 10 seconds of the socket opening. A wisp with several slices adds "slice": 3 for the one this line serves; left out, it is slice 0.

{
"t": "ready",
"v": 2,
"id": "w.Lk7Wd2rTq9Ys4XbN0vHc5E",
"s": {"61":113},
"e": "4b9d0c1e7a2f43e8b5c6d7e8f9a0b1c2",
"replay": 600,
"silence": 30,
"retry": {"cap":5,"later":60,"after":30},
"media": "https://…",
"slices": 1,
"budget": {"calls":100,"burst":500,"serverCalls":50,"channelLines":5,"edits":4}
}

Your wisp is online. s is where its one group stands, the cursor you keep to come back without missing anything, and budget is how fast it may act. The line explains every key.

When ana types /spin 50, your line hears:

{
"c": 61,
"n": 109,
"t": "command",
"d": {
"server": "5c3b1a2d-8e4f-4a6b-9c7d-0e1f2a3b4c55",
"channel": "6d4c2b3e-9f5a-4b7c-8d6e-1f2a3b4c5d66",
"id": "0199dea4-1c20-7a3b-8c4d-5e6f7a8b9c10",
"user": "3f1c9a52-6d1e-4c8b-9a51-2b7e0c4d8f11",
"name": "ana",
"avatar": "639201477315520417",
"command": "spin",
"options": {"bet":50},
"expires": 1791916215000000
}
}

Her app already shows lucky working on it. Answer the run once, before expires (15 seconds on), with respond:

{
"i": 7,
"t": "respond",
"d": {
"to": "0199dea4-1c20-7a3b-8c4d-5e6f7a8b9c10",
"text": "ana bet 50: cherry, cherry, lemon. No win this time.",
"card": {
"color": "#f97316",
"blocks": [
{ "type": "text", "text": "**Spin**", "size": "large" },
{ "type": "grid", "rows": [[{ "emoji": "🍒" }, { "emoji": "🍒" }, { "emoji": "🍋" }]] }
]
}
}
}
{"r":7,"v":"0199dea4-1d31-7b4c-9d5e-6f7a8b9c0d21"}

Everyone looking at #general sees the card, with “ana used /spin” above it. v is the id of the line you made.

Node 22 or later, which has WebSocket and fetch built in. Save it as wisp.mjs and run WISP_ID=… WISP_SECRET=… node wisp.mjs.

// lucky: answers /spin with a card. A first wisp, from the public contract alone.
const API = "https://api.woosh.no";
const { WISP_ID, WISP_SECRET } = process.env;
const reels = ["🍒", "🍋", "🔔", "💎"];
const roll = () => reels[Math.floor(Math.random() * reels.length)];
const waiting = new Map(); // call number -> its promise's ends
let next = 1;
async function token() {
const res = await fetch(`${API}/api/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ grant_type: "client_credentials", client_id: WISP_ID, client_secret: WISP_SECRET }),
});
if (!res.ok) throw new Error(`token refused: ${res.status}`);
return res.json(); // { access_token, token_type, expires_in, line }
}
function call(ws, t, d) {
const i = next++;
ws.send(JSON.stringify({ i, t, d }));
return new Promise((resolve, reject) => waiting.set(i, { resolve, reject }));
}
async function heard(ws, { t, d }) {
if (t !== "command" || d.command !== "spin") return;
const bet = d.options?.bet ?? 10;
const spin = [roll(), roll(), roll()];
const won = new Set(spin).size === 1;
await call(ws, "respond", {
to: d.id,
text: `${d.name} bet ${bet}: ${spin.join(" ")}. ${won ? `Won ${bet * 10}!` : "No win this time."}`,
card: {
color: won ? "#22c55e" : "#f97316",
blocks: [
{ type: "text", text: "**Spin**", size: "large" },
{ type: "grid", rows: [spin.map((emoji) => ({ emoji }))] },
{ type: "fields", fields: [{ name: "Bet", value: String(bet) }, { name: "Result", value: won ? "Win" : "No win" }] },
],
},
});
}
async function open() {
const { access_token, line } = await token();
const ws = new WebSocket(`${line}?delivery=seen`);
ws.onopen = () => ws.send(JSON.stringify({ t: "hello", v: 2, a: access_token, f: 1 }));
ws.onmessage = ({ data }) => {
for (const text of String(data).split("\n")) {
if (!text) continue;
const frame = JSON.parse(text);
if (typeof frame.r === "number") {
const call = waiting.get(frame.r);
waiting.delete(frame.r);
if (frame.e) call?.reject(new Error(`${frame.e}: ${frame.w ?? ""}`));
else call?.resolve(frame.v);
} else if (frame.t === "ready") console.log("ready", frame.budget);
else heard(ws, frame).catch((err) => console.error(err.message));
}
};
ws.onclose = ({ code }) => {
for (const call of waiting.values()) call.reject(new Error("the line closed"));
waiting.clear();
if (code === 4403 || code === 4426) return console.error(`closed for good: ${code}`);
console.log(`closed ${code}; again in 2 s`);
setTimeout(() => open().catch(console.error), 2000);
};
}
open().catch(console.error);

This is the smallest wisp that works, not yet a good one. Before it meets real people, read The line: keeping its cursor so a restart misses nothing, keeping a channel’s lines in order, and coming back on the right waits. Then Budgets and limits, so it never answers too fast.

© 2026 Woosh AS. All rights reserved.