Skip to main content
Towns bots are built on Hono. While /webhook is reserved for Towns events, you can add custom routes for external webhooks, APIs, and scheduled tasks.

Using Bot Methods Anywhere

Important: Bot methods like sendMessage() can be called directly on the bot instance, outside of event handlers. This enables integration with external services, webhooks, and scheduled tasks.
import { Hono } from 'hono'
import { makeTownsBot } from '@towns-protocol/bot'

const bot = await makeTownsBot(privateData, jwtSecret)
const { jwtMiddleware, handler } = bot.start()

const app = new Hono()

// Towns webhook (required)
app.post('/webhook', jwtMiddleware, handler)

// Custom API endpoint
app.post('/api/send', async (c) => {
  const { channelId, message } = await c.req.json()
  await bot.sendMessage(channelId, message)
  return c.json({ ok: true })
})

export default app

GitHub Webhooks

import { Hono } from 'hono'
import { makeTownsBot } from '@towns-protocol/bot'

const bot = await makeTownsBot(privateData, jwtSecret, { commands })

let githubChannelId: string | null = null

// Register channel for GitHub notifications
bot.onSlashCommand('setup-github', async (handler, event) => {
  githubChannelId = event.channelId
  await handler.sendMessage(
    event.channelId,
    'GitHub notifications configured for this channel'
  )
})

// Towns webhook (required)
const { jwtMiddleware, handler } = bot.start()
const app = new Hono()

app.post('/webhook', jwtMiddleware, handler)

// GitHub webhook endpoint
app.post('/github-webhook', async (c) => {
  const payload = await c.req.json()
  
  if (!githubChannelId) {
    return c.json({ error: 'No channel configured' }, 400)
  }
  
  // Send GitHub events to Towns channel
  if (payload.action === 'opened' && payload.pull_request) {
    await bot.sendMessage(
      githubChannelId,
      `PR opened: **${payload.pull_request.title}** by ${payload.sender.login}\n${payload.pull_request.html_url}`
    )
  } else if (payload.pusher) {
    const commits = payload.commits?.length || 0
    await bot.sendMessage(
      githubChannelId,
      `Push to ${payload.repository.name}: ${commits} commits by ${payload.pusher.name}`
    )
  }
  
  return c.json({ success: true })
})

export default app

Scheduled Tasks

const bot = await makeTownsBot(privateData, jwtSecret)
const priceChannels = new Set()

bot.onSlashCommand('price-alerts', async (handler, event) => {
  priceChannels.add(event.channelId)
  await handler.sendMessage(event.channelId, 'Price alerts enabled')
})

// Check price every 5 minutes
setInterval(async () => {
  const price = await fetch('https://api.example.com/eth-price').then(r => r.json())
  
  for (const channelId of priceChannels) {
    await bot.sendMessage(channelId, `ETH: $${price}`)
  }
}, 5 * 60 * 1000)

Storing State

In-memory storage (Map, Set) only works on always-on servers. Free hosting tiers can restart your server, losing all in-memory data. Consider storing data in a external database.

SQLite (Drizzle)

bun add drizzle-orm better-sqlite3
import { drizzle } from 'drizzle-orm/better-sqlite3'
import Database from 'better-sqlite3'
import { sqliteTable, text } from 'drizzle-orm/sqlite-core'

const channels = sqliteTable('channels', {
  channelId: text('channel_id').primaryKey(),
  service: text('service')
})

const db = drizzle(new Database('bot.db'))

bot.onSlashCommand('setup', async (handler, event) => {
  await db.insert(channels).values({
    channelId: event.channelId,
    service: 'github'
  })
})

Turso (Managed SQLite)

Free tier, perfect for bots on free hosting:
bun add @libsql/client drizzle-orm
import { drizzle } from 'drizzle-orm/libsql'
import { createClient } from '@libsql/client'

const client = createClient({
  url: process.env.TURSO_DATABASE_URL!,
  authToken: process.env.TURSO_AUTH_TOKEN!
})

const db = drizzle(client)

Redis

For caching and rate limiting:
bun add ioredis
import Redis from 'ioredis'

const redis = new Redis(process.env.REDIS_URL)

// Rate limiting
bot.onSlashCommand('premium', async (handler, event) => {
  const count = await redis.incr(`ratelimit:${event.userId}`)
  
  if (count > 5) {
    await handler.sendMessage(event.channelId, 'Rate limit exceeded')
    return
  }
  
  // Process request...
})

Next Steps