pigeonsms docs

pigeonsms.js

The bot SDK for PigeonSMS. Declare slash commands, answer interactions, post messages — Node 20+, ESM, zero runtime dependencies.

npm install pigeonsms.js
import { Client } from 'pigeonsms.js';

const client = new Client({ token: process.env.PIGEON_BOT_TOKEN });

client.command(
  'echo',
  'say it back',
  (o) => o.string('text', 'what to say', { required: true }),
  async (ctx) => ctx.reply(`you said ${ctx.options.text}`),
);

client.on('ready', (bot) => console.log(`logged in as @${bot.username}`));

await client.login();

login() identifies the bot, syncs its commands and starts the long poll. That is the whole startup — nothing has to be reachable from the internet.

The wire protocol underneath is documented in BOTS.md; this package never invents an endpoint that isn't in it.


Getting a token

Create the bot in the app (Settings → Bots → New bot) or over the API, and keep the token out of git — it is your bot:

export PIGEON_BOT_TOKEN=PGB.7412998877001.…

new Client() falls back to process.env.PIGEON_BOT_TOKEN, and the API base to process.env.PIGEON_API.

Declaring commands

Two spellings. The short one:

client.command('ping', 'check the bot is alive', (ctx) => ctx.reply('pong'));

client.command(
  'roll',
  'roll a die',
  (o) => o.integer('sides', 'how many sides', { min: 2, max: 1000 }),
  (ctx) => ctx.reply(`🎲 ${1 + Math.floor(Math.random() * (ctx.options.sides ?? 6))}`),
);

And the object form, for anything scoped or conditional:

client.command(
  {
    name: 'deploy',
    description: 'ship it',
    spaceId: '7409000000123',   // nest-scoped; omit for global
    dmEnabled: false,           // forced false anyway when spaceId is set
    options: (o) => o.string('target', 'where to', { choices: ['staging', 'prod'], required: true }),
  },
  async (ctx) => { … },
);

The option builder

o.string / o.integer / o.number / o.boolean / o.user / o.channel, each (name, description, { required, choices, min, max }), each returning the builder so calls chain:

(o) => o
  .string('text', 'what to say', { required: true, max: 200 })
  .boolean('loud', 'shout it')
  .user('target', 'who to say it to')

Names are validated locally against the server's ^[a-z0-9_-]{1,32}$, so a typo throws at declaration time instead of 400ing at deploy time.

Command sync

login() reads the registered set, diffs it against what you declared and only writes when they differ — a restart loop does not hammer the API. Force it, or run it yourself:

await client.syncCommands();               // no-op when already in sync
await client.syncCommands({ force: true }); // write regardless

The PUT is a full replacement, so a client that declares no commands never writes at all — otherwise starting a half-finished script would wipe the set. Pass { force: true } if wiping is what you actually want, or new Client({ autoSync: false }) to keep login() out of it entirely.

The interaction context

Handlers get one argument:

client.command('whoami', 'who am I', async (ctx) => {
  ctx.id;         // interaction id
  ctx.command;    // 'whoami'
  ctx.options;    // coerced + validated; options left blank are absent
  ctx.user;       // { id, username, display_name }
  ctx.channelId;  // where to reply
  ctx.spaceId;    // null in a DM
  ctx.isDm;       // boolean
  ctx.raw;        // the untouched payload

  await ctx.reply(`you are @${ctx.user.username}`);
});
method what it does
reply(content | { content, attachment, ephemeral }) answers the interaction, once
defer() buys time past the webhook's 3 s budget (a no-op when polling)
noop() closes it with nothing posted
followUp(content) a plain message in the channel, after you answered
send(content) posts to the channel without touching the interaction
typing() typing indicator in the channel

ctx.replied and ctx.deferred track state, and a second reply() throws a PigeonError with code: 'already_replied' rather than silently 409ing.

ephemeral: true reaches only the invoker, over the gateway, and is never stored — gone on reload.

Poll mode (default)

const client = new Client({ token, mode: 'poll' });

Holds GET /bots/me/updates?wait=25 open, handles each update sequentially, then reconnects. Network errors and 429s back off exponentially to 30 s; a 401 stops the loop and emits a clear error (your token was rotated, or the bot was deleted). await client.destroy() aborts the in-flight poll and lets the process exit.

Webhook mode

Needs the bot's signing secret and a public HTTPS URL.

const client = new Client({
  token: process.env.PIGEON_BOT_TOKEN,
  mode: 'webhook',
  signingSecret: process.env.PIGEON_SIGNING_SECRET,
  webhookPath: '/pigeon',
});

await client.login();
await client.listen(8787);   // GET /health included

Then point the bot at it (owner session token, once):

curl -sX PATCH "$API/bots/$BOT_ID" -H "Authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' \
  -d '{"interactions_url":"https://your-tunnel.example.com/pigeon"}'

client.webhookHandler() returns a plain (req, res) function if you already have a server:

import { createServer } from 'node:http';
createServer(client.webhookHandler()).listen(8787);

It verifies X-Pigeon-Signature — HMAC-SHA256 over "<timestamp>.<raw body>", timing-safe, 5-minute skew window — before parsing, hashing the raw bytes. Anything that fails gets a 401 and never reaches your handler.

You have 3 seconds before the server gives up. If your handler is still working at deferAfterMs (2.5 s by default) the SDK answers {"type":"defer"} for you and your eventual reply() goes out over the callback instead — a slow command degrades instead of failing.

Gateway (optional)

Interactions arrive by poll or webhook; the gateway is for ordinary messages in channels the bot is in.

const client = new Client({ token, gateway: true });
client.on('messageCreate', (message) => {
  if (message.content === 'ping') client.sendMessage(message.channel_id, 'pong');
});

It uses the runtime's global WebSocket, so it needs Node 22+ (or Node 20/21 with --experimental-websocket); anything older throws a PigeonError with code: 'no_websocket' instead of failing obscurely. Messages the bot itself sent are filtered out, so a bot cannot echo itself into a loop.

Events

client.on('ready', (bot) => {});          // login() finished
client.on('interaction', (ctx) => {});    // every interaction, before its handler
client.on('messageCreate', (msg) => {});  // gateway only
client.on('error', (err) => {});          // poll failures, handler throws
client.on('debug', (line) => {});         // retries, sync decisions, backoff

An error with no listener is logged, not thrown — a missing listener should not take the process down from inside the poll loop.

REST

Everything is on client.rest, with the common calls mirrored on the client:

await client.sendMessage(channelId, 'deploy finished ✅');
await client.sendMessage(channelId, { content: 'see below', reply_to: messageId });
await client.editMessage(messageId, 'edited');
await client.deleteMessage(messageId);
await client.react(messageId, '👍');
await client.react(messageId, '👍', { remove: true });
await client.typing(channelId);

const channelId = await client.openDm(userId);   // the user id, not a bot id
const spaces = await client.spaces();
await client.joinSpace('ABCD1234');
const members = await client.members(spaceId);

const attachment = await client.upload('./die.png');
await client.sendMessage(channelId, { content: 'rolled', attachment });

client.rest.request(path, { method, body, query }) is there for anything not wrapped. 429s and 5xx are retried twice with jittered backoff; everything else throws.

Errors

import { PigeonError } from 'pigeonsms.js';

try {
  await ctx.reply('hi');
} catch (error) {
  if (error instanceof PigeonError && error.code === 'interaction_closed') {
    // someone already answered, or it expired — give up on this one
  }
}

{ status, code, message, requestId }. status is 0 for client-side failures (double reply, no WebSocket, aborted request) and the HTTP status when the server answered. Log requestId — it is the only way to correlate with server logs.

Limits worth knowing

thing limit
commands per bot 100
options per command 25
choices per option 25
message content 4000 chars
webhook response deadline 3 s
long-poll wait 0–25 s
interaction TTL 15 minutes

Examples

Runnable bots in both modes: examples/echo-bot/.

MIT.

Rate limits

Requests are paced locally before they leave the process. Each route family gets its own token bucket sized to the server's cost model (messages, reactions, forum posts and interactions all drain separately), so a burst queues instead of collecting 429s. A 429 halves that bucket's rate and honours Retry-After; the rate creeps back as requests start succeeding again.

client.rest.limiter.state();
// { message: { rate: 4, tokens: 3.1, pausedFor: 0 } }

new Client({ token, rates: { message: 2 } }); // pace a bucket yourself

The long poll deliberately bypasses pacing — it is one blocking request and must never queue behind anything else.

Caches

Interaction payloads, gateway events and member lists already carry full entities, so they populate an LRU (5 minute TTL, 1000 entries per manager) as traffic arrives. Reads are free; misses fall back to the API and concurrent misses for the same id share one request.

client.users.get(id);                    // sync, cached or null
await client.users.fetch(id);            // cached, else fetched
await client.users.fetch(id, { force: true });
client.caches.stats();                   // { users: { size, hits, misses }, ... }

new Client({ token, cache: { ttl: 60_000, max: 200 } });