bots
SKILL.md
Towns Protocol Bot SDK Reference
Critical Rules
MUST follow these rules - violations cause silent failures:
- User IDs are Ethereum addresses - Always
0x...format, never usernames - Mentions require BOTH -
<@{userId}>format in text ANDmentionsarray in options - Two-wallet architecture:
bot.viem.account.address= Gas wallet (signs & pays fees) - MUST fund with Base ETHbot.appAddress= Treasury (optional, for transfers)
- Slash commands DON'T trigger onMessage - They're exclusive handlers
- Interactive forms use
typeproperty - Notcase(e.g.,type: 'form') - Never trust txHash alone - Verify
receipt.status === 'success'before granting access
Quick Reference
Key Imports
import { makeTownsBot, getSmartAccountFromUserId } from '@towns-protocol/bot'
import type { BotCommand, BotHandler } from '@towns-protocol/bot'
import { Permission } from '@towns-protocol/web3'
import { parseEther, formatEther, erc20Abi, zeroAddress } from 'viem'
import { readContract, waitForTransactionReceipt } from 'viem/actions'
import { execute } from 'viem/experimental/erc7821'
Handler Methods
| Method | Signature | Notes |
|---|---|---|
sendMessage |
(channelId, text, opts?) → { eventId } |
opts: { threadId?, replyId?, mentions?, attachments? } |
editMessage |
(channelId, eventId, text) |
Bot's own messages only |
removeEvent |
(channelId, eventId) |
Bot's own messages only |
sendReaction |
(channelId, messageId, emoji) |
|
sendInteractionRequest |
(channelId, payload) |
Forms, transactions, signatures |
hasAdminPermission |
(userId, spaceId) → boolean |
|
ban / unban |
(userId, spaceId) |
Needs ModifyBanning permission |
Bot Properties
| Property | Description |
|---|---|
bot.viem |
Viem client for blockchain |
bot.viem.account.address |
Gas wallet - MUST fund with Base ETH |
bot.appAddress |
Treasury wallet (optional) |
bot.botId |
Bot identifier |
For detailed guides, see references/:
- Messaging API - Mentions, threads, attachments, formatting
- Blockchain Operations - Read/write contracts, verify transactions
- Interactive Components - Forms, transaction requests
- Deployment - Local dev, Render, tunnels
- Debugging - Troubleshooting guide
Bot Setup
Project Initialization
bunx towns-bot init my-bot
cd my-bot
bun install
Environment Variables
APP_PRIVATE_DATA=<base64_credentials> # From app.towns.com/developer
JWT_SECRET=<webhook_secret> # Min 32 chars
PORT=3000
BASE_RPC_URL=https://base-mainnet.g.alchemy.com/v2/KEY # Recommended
Basic Bot Template
import { makeTownsBot } from '@towns-protocol/bot'
import type { BotCommand } from '@towns-protocol/bot'
const commands = [
{ name: 'help', description: 'Show help' },
{ name: 'ping', description: 'Check if alive' }
] as const satisfies BotCommand[]
const bot = await makeTownsBot(
process.env.APP_PRIVATE_DATA!,
process.env.JWT_SECRET!,
{ commands }
)
bot.onSlashCommand('ping', async (handler, event) => {
const latency = Date.now() - event.createdAt.getTime()
await handler.sendMessage(event.channelId, 'Pong! ' + latency + 'ms')
})
export default bot.start()
Config Validation
import { z } from 'zod'
const EnvSchema = z.object({
APP_PRIVATE_DATA: z.string().min(1),
JWT_SECRET: z.string().min(32),
DATABASE_URL: z.string().url().optional()
})
const env = EnvSchema.safeParse(process.env)
if (!env.success) {
console.error('Invalid config:', env.error.issues)
process.exit(1)
}
Event Handlers
onMessage
Triggers on regular messages (NOT slash commands).
bot.onMessage(async (handler, event) => {
// event: { userId, spaceId, channelId, eventId, message, isMentioned, threadId?, replyId? }
if (event.isMentioned) {
await handler.sendMessage(event.channelId, 'You mentioned me!')
}
})
onSlashCommand
Triggers on /command. Does NOT trigger onMessage.
bot.onSlashCommand('weather', async (handler, { args, channelId }) => {
// /weather San Francisco → args: ['San', 'Francisco']
const location = args.join(' ')
if (!location) {
await handler.sendMessage(channelId, 'Usage: /weather <location>')
return
}
// ... fetch weather
})
onReaction
bot.onReaction(async (handler, event) => {
// event: { reaction, messageId, channelId }
if (event.reaction === '👋') {
await handler.sendMessage(event.channelId, 'I saw your wave!')
}
})
onTip
Requires "All Messages" mode in Developer Portal.
bot.onTip(async (handler, event) => {
// event: { senderAddress, receiverAddress, amount (bigint), currency }
if (event.receiverAddress === bot.appAddress) {
await handler.sendMessage(event.channelId,
'Thanks for ' + formatEther(event.amount) + ' ETH!')
}
})
onInteractionResponse
bot.onInteractionResponse(async (handler, event) => {
switch (event.response.payload.content?.case) {
case 'form':
const form = event.response.payload.content.value
for (const c of form.components) {
if (c.component.case === 'button' && c.id === 'yes') {
await handler.sendMessage(event.channelId, 'You clicked Yes!')
}
}
break
case 'transaction':
const tx = event.response.payload.content.value
if (tx.txHash) {
// IMPORTANT: Verify on-chain before granting access
// See references/BLOCKCHAIN.md for full verification pattern
await handler.sendMessage(event.channelId,
'TX: https://basescan.org/tx/' + tx.txHash)
}
break
}
})
Event Context Validation
Always validate context before using:
bot.onSlashCommand('cmd', async (handler, event) => {
if (!event.spaceId || !event.channelId) {
console.error('Missing context:', { userId: event.userId })
return
}
// Safe to proceed
})
Common Mistakes
| Mistake | Fix |
|---|---|
insufficient funds for gas |
Fund bot.viem.account.address with Base ETH |
| Mention not highlighting | Include BOTH <@userId> in text AND mentions array |
| Slash command not working | Add to commands array in makeTownsBot |
| Handler not triggering | Check message forwarding mode in Developer Portal |
writeContract failing |
Use execute() for external contracts |
| Granting access on txHash | Verify receipt.status === 'success' first |
| Message lines overlapping | Use \n\n (double newlines), not \n |
| Missing event context | Validate spaceId/channelId before using |
Resources
- Developer Portal: https://app.towns.com/developer
- Documentation: https://docs.towns.com/build/bots
- SDK: https://www.npmjs.com/package/@towns-protocol/bot
- Chain ID: 8453 (Base Mainnet)