Cygnet
A modern framework for building signal bots
I’ve always had a growing disdain for telegram as an app, even though it’s one of the best messenger apps and has the best bot features of any popular messenger I’ve ever used.
maybe it was due to encryption signaling but not actually having enough encryption in their public comms, maybe it was that telegram is not open source and a secure messenger should be?
I don’t know the reason, all I know is, that for a couple of weeks I wanted to build a bot on signal and there was no ergonomic framework. Telegram has a few like telegraf, Grammy or whatever new thing someone cooler than me cooked up.
So I built cygnet, to write bots on signal more elegantly and learnt a whole lot more about signal during the process.
Here's what my agent thinks we did
Cygnet is a modern TypeScript framework for building production-grade Signal bots with typed filters, middleware, sessions, scenes, and a small context API for common bot actions.
Quick Start
Install
npm install cygnet
docker run -d --name signal-api \
-p 8080:8080 \
-v ~/.local/share/signal-cli:/home/.local/share/signal-cli \
-e MODE=json-rpc \
bbernhard/signal-cli-rest-api
Usage
import { Bot } from 'cygnet'
const bot = new Bot({
signalService: 'localhost:8080',
phoneNumber: '+491234567890',
})
bot.command('start', (ctx) => ctx.reply('Hello!'))
bot.on('message:text', (ctx) => ctx.reply(`You said: ${ctx.text}`))
bot.start()
What it supports
- Type-safe update filters like
message:text,message:reaction,message:attachments,message:poll_create,group_update,edit_message,delete_message,receipt, andtyping - Koa-style middleware with
use,filter,branch,fork,lazy, and isolated error boundaries - First-class context helpers for replies, quotes, reactions, typing indicators, edits, deletes, link previews, styled text, polls, contacts, and direct API access
- Session storage with in-memory, file-backed, and custom adapter support
- Scenes and wizard scenes for multi-step conversations
- WebSocket, polling, and webhook transports
- Structured logging and clean startup/configuration errors
- Best-effort group state tracking for Signal's eventually consistent group update payloads
Typed filters
Filters narrow the context type at compile time. After bot.on('message:text'), ctx.text is a string, not a maybe-present field that every handler has to defensively unwrap.
bot.on('message:text', async (ctx) => {
await ctx.reply(`received: ${ctx.text}`)
})
bot.on('message:reaction', async (ctx) => {
await ctx.reply(`reaction: ${ctx.reaction?.emoji}`)
})
bot.hears(/order (\d+)/i, async (ctx) => {
await ctx.reply(`Looking up order ${ctx.match![1]}`)
})
Middleware and state
Cygnet uses composable middleware so bot behavior can be layered instead of packed into one large message handler. Sessions can be keyed by chat, sender UUID, or a custom key, and storage can be swapped out for a persistent backend when the bot moves beyond a single process.
import { Bot, Context, FileStorage, session } from 'cygnet'
import type { SessionFlavor } from 'cygnet'
interface MySession {
count: number
}
type MyContext = Context & SessionFlavor<MySession>
const bot = new Bot<MyContext>({
signalService: 'localhost:8080',
phoneNumber: '+491234567890',
})
bot.use(
session<MySession, MyContext>({
storage: new FileStorage('.cygnet-session.json'),
initial: () => ({ count: 0 }),
})
)
bot.on('message:text', async (ctx) => {
ctx.session.count += 1
await ctx.reply(`Message #${ctx.session.count}`)
})
Scenes and workflows
For flows that need memory across multiple messages, Cygnet includes scenes and wizard scenes. This is useful for registration, support triage, quizzes, approvals, or any workflow where a bot asks a question, waits for the next message, and then advances based on the answer.
import { WizardScene } from 'cygnet'
const register = new WizardScene<MyContext>(
'register',
async (ctx) => {
await ctx.reply("What's your name?")
await ctx.wizard.advance()
},
async (ctx) => {
await ctx.reply(`Nice to meet you, ${ctx.text}. Registration complete.`)
await ctx.scene.leave()
}
)
Signal-native features
The framework exposes more than plain text messaging. Bots can react to messages, handle incoming reactions, send styled text, manage contacts, inspect attachments, respond to read and delivery receipts, create and close polls, and receive updates through a webhook when a persistent WebSocket connection is not the right deployment shape.
await ctx.react('👍')
await ctx.reply('**bold**, *italic*, ||spoiler||, `monospace`', {
textMode: 'styled',
})
await ctx.createPoll("What's for lunch?", ['Pizza', 'Sushi', 'Tacos'], {
allowMultipleSelections: true,
})