Cygnet
A modern framework for building signal bots
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,
})