Use when building Towns Protocol bots - covers SDK initialization, slash commands, message handlers, reactions, interactive forms, blockchain operations, and deployment
Build Towns Protocol bots using the SDK for slash commands, message handlers, reactions, interactive forms, and blockchain operations. Use when creating bots that respond to user interactions, process payments, or execute smart contract calls on Base.
/plugin marketplace add towns-protocol/skills/plugin install towns@towns-skillsThis skill inherits all available tools. When active, it can use any tool Claude has access to.
MUST follow these rules - violations cause silent failures:
0x... format, never usernames<@{userId}> format in text AND mentions array in optionsbot.viem.account.address = Gas wallet (signs & pays fees) - MUST fund with Base ETHbot.appAddress = Treasury (optional, for transfers)type property - Not case (e.g., type: 'form')receipt.status === 'success' before granting accessimport { 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'
| 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 |
| 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 |
bunx towns-bot init my-bot
cd my-bot
bun install
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
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()
Validate environment on startup to fail fast:
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)
}
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!')
}
if (event.message.includes('hello')) {
await handler.sendMessage(event.channelId, 'Hello there!')
}
})
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
})
bot.onReaction(async (handler, event) => {
// event: { reaction, messageId, channelId }
if (event.reaction === '👋') {
await handler.sendMessage(event.channelId, 'I saw your wave!')
}
})
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!')
}
})
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) {
await handler.sendMessage(event.channelId,
'Success: https://basescan.org/tx/' + tx.txHash)
}
break
}
})
Always validate context before using - events can have missing fields:
bot.onSlashCommand('cmd', async (handler, event) => {
if (!event.spaceId || !event.channelId) {
console.error('Missing context:', { userId: event.userId })
return
}
// Safe to proceed
})
MUST include BOTH formatted text AND mentions array:
// Format: Hello <@0x...>!
const text = 'Hello <@' + userId + '>!'
await handler.sendMessage(channelId, text, {
mentions: [{ userId, displayName: 'User' }]
})
// @channel
await handler.sendMessage(channelId, 'Attention!', {
mentions: [{ atChannel: true }]
})
// Reply in thread
await handler.sendMessage(channelId, 'Thread reply', { threadId: event.eventId })
// Reply to specific message
await handler.sendMessage(channelId, 'Reply', { replyId: messageId })
// Image
attachments: [{ type: 'image', url: 'https://...jpg', alt: 'Description' }]
// Miniapp
attachments: [{ type: 'miniapp', url: 'https://your-app.com/miniapp.html' }]
// Large file (chunked)
attachments: [{
type: 'chunked',
data: readFileSync('./video.mp4'),
filename: 'video.mp4',
mimetype: 'video/mp4'
}]
Towns has specific rendering behavior:
\n\n (double newlines) between sections - single \n causes overlap--- as separator - renders as zero-height ruleValue: $1.00 · P&L: $0.50// Good - double newlines
const msg = ['**Header**', 'Line 1', 'Line 2'].join('\n\n')
// Bad - single newlines will collapse
const bad = lines.join('\n')
await handler.sendInteractionRequest(channelId, {
type: 'form', // NOT 'case'
id: 'my-form',
components: [
{ id: 'yes', type: 'button', label: 'Yes' },
{ id: 'no', type: 'button', label: 'No' }
],
recipient: event.userId // Optional: private to this user
})
import { encodeFunctionData, erc20Abi, parseUnits } from 'viem'
await handler.sendInteractionRequest(channelId, {
type: 'transaction',
id: 'payment',
title: 'Send Tokens',
subtitle: 'Transfer 50 USDC',
tx: {
chainId: '8453',
to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // USDC
value: '0',
data: encodeFunctionData({
abi: erc20Abi,
functionName: 'transfer',
args: [recipient, parseUnits('50', 6)]
})
},
recipient: event.userId
})
const balance = await readContract(bot.viem, {
address: tokenAddress,
abi: erc20Abi,
functionName: 'balanceOf',
args: [userAddress]
})
const hash = await execute(bot.viem, {
address: bot.appAddress,
account: bot.viem.account,
calls: [{
to: targetAddress,
abi: contractAbi,
functionName: 'transfer',
args: [recipient, amount]
}]
})
await waitForTransactionReceipt(bot.viem, { hash })
Never grant access based on txHash alone. Always verify on-chain:
bot.onInteractionResponse(async (handler, event) => {
if (event.response.payload.content?.case !== 'transaction') return
const tx = event.response.payload.content.value
if (tx.txHash) {
const receipt = await waitForTransactionReceipt(bot.viem, {
hash: tx.txHash
})
if (receipt.status !== 'success') {
await handler.sendMessage(event.channelId, 'Transaction failed on-chain')
return
}
// NOW safe to grant access
await grantUserAccess(event.userId)
await handler.sendMessage(event.channelId, 'Payment confirmed!')
}
})
const TOKENS = {
ETH: zeroAddress,
USDC: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
TOWNS: '0x00000000A22C618fd6b4D7E9A335C4B96B189a38'
}
# Start bot (default port 5123)
bun run dev
# Expose webhook via Tailscale Funnel
tailscale funnel 5123
# Creates URL like: https://your-machine.taild8e1b0.ts.net/
# Alternative: ngrok
ngrok http 5123
Setup webhook in Developer Portal:
/webhook
https://your-machine.taild8e1b0.ts.net/webhookTesting checklist:
bun run dev)const gasBalance = await bot.viem.getBalance({ address: bot.viem.account.address })
const treasuryBalance = await bot.viem.getBalance({ address: bot.appAddress })
console.log('Gas: ' + formatEther(gasBalance) + ' ETH')
Handle SIGTERM for clean shutdown (required for Render/Kubernetes):
process.on('SIGTERM', async () => {
console.log('SIGTERM received, closing...')
await pool.end() // Close DB connections
process.exit(0)
})
import { Pool } from 'pg'
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000
})
// Health check on startup
await pool.query('SELECT 1')
// First writer wins
await pool.query(
`INSERT INTO threads (thread_id, user_id)
VALUES ($1, $2)
ON CONFLICT (thread_id) DO NOTHING`,
[threadId, userId]
)
// Check ownership
const result = await pool.query(
'SELECT user_id FROM threads WHERE thread_id = $1',
[threadId]
)
return result.rows[0]?.user_id === userId
Most common issue. Check in order:
Webhook URL correct?
# Your bot should log incoming requests
curl -X POST https://your-webhook-url/webhook \
-H "Content-Type: application/json" \
-d '{"test": true}'
Tunnel running? (local dev)
# Tailscale
tailscale funnel status
# ngrok
curl http://127.0.0.1:4040/api/tunnels
Bot added to channel? Bot must be:
Message forwarding mode? In Developer Portal:
@bot messagesonTip)Slash command registered? Must be in commands array passed to makeTownsBot
const bot = await makeTownsBot(
process.env.APP_PRIVATE_DATA!,
process.env.JWT_SECRET!,
{ commands }
)
// Log all incoming events
bot.onMessage(async (handler, event) => {
console.log('[onMessage]', {
userId: event.userId,
channelId: event.channelId,
message: event.message.slice(0, 100),
isMentioned: event.isMentioned
})
// ... rest of handler
})
bot.onSlashCommand('*', async (handler, event) => {
console.log('[onSlashCommand]', {
command: event.command,
args: event.args,
userId: event.userId
})
})
| Error | Cause | Fix |
|---|---|---|
JWT verification failed | Wrong JWT_SECRET | Match secret in Developer Portal |
insufficient funds for gas | Empty gas wallet | Fund bot.viem.account.address |
Invalid APP_PRIVATE_DATA | Malformed credentials | Re-copy from Developer Portal |
ECONNREFUSED on RPC | Bad RPC URL or rate limited | Use dedicated RPC (Alchemy/Infura) |
nonce too low | Concurrent transactions | Add transaction queue or retry logic |
// Add health check endpoint
import { Hono } from 'hono'
const app = new Hono()
app.get('/health', (c) => c.json({
status: 'ok',
timestamp: new Date().toISOString(),
gasWallet: bot.viem.account.address
}))
// Test from outside
// curl https://your-webhook-url/health
import { waitForTransactionReceipt } from 'viem/actions'
try {
const hash = await execute(bot.viem, { /* ... */ })
console.log('TX submitted:', hash)
const receipt = await waitForTransactionReceipt(bot.viem, { hash })
console.log('TX result:', {
status: receipt.status,
gasUsed: receipt.gasUsed.toString(),
blockNumber: receipt.blockNumber
})
if (receipt.status !== 'success') {
console.error('TX reverted. Check on basescan:',
'https://basescan.org/tx/' + hash)
}
} catch (err) {
console.error('TX failed:', err.message)
// Common: insufficient funds, nonce issues, contract revert
}
| 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 |
This skill should be used when the user asks to "create a hookify rule", "write a hook rule", "configure hookify", "add a hookify rule", or needs guidance on hookify rule syntax and patterns.
Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.