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:
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