← Home

Cygnet

A modern framework for building signal bots


Date

2026

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, and typing
  • 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,
})

Demo