🎁 Get the FREE AI Skills Starter Guide β€” Subscribe β†’
BytesAgainBytesAgain
πŸ¦€ ClawHub

Towns Protocol Skills

by @andreyz

Use when building Towns Protocol bots - covers SDK initialization, slash commands, message handlers, reactions, interactive forms, blockchain operations, and deployment. Triggers: "towns bot", "makeTownsBot", "onSlashCommand", "onMessage", "sendInteractionRequest", "webhook", "bot deployment", "@towns-protocol/bot"

Versionv2.0.0
Downloads1,998
Stars⭐ 1
TERMINAL
clawhub install towns-protocol

πŸ“– About This Skill


name: bots description: >- Use when building Towns Protocol bots - covers SDK initialization, slash commands, message handlers, reactions, interactive forms, blockchain operations, and deployment. Triggers: "towns bot", "makeTownsBot", "onSlashCommand", "onMessage", "sendInteractionRequest", "webhook", "bot deployment", "@towns-protocol/bot" license: MIT compatibility: Requires Bun runtime, Base network RPC access, @towns-protocol/bot SDK metadata: author: towns-protocol version: "2.0.0"

Towns Protocol Bot SDK Reference

Critical Rules

MUST follow these rules - violations cause silent failures:

1. User IDs are Ethereum addresses - Always 0x... format, never usernames 2. Mentions require BOTH - <@{userId}> format in text AND mentions array in options 3. Two-wallet architecture: - bot.viem.account.address = Gas wallet (signs & pays fees) - MUST fund with Base ETH - bot.appAddress = Treasury (optional, for transfers) 4. Slash commands DON'T trigger onMessage - They're exclusive handlers 5. Interactive forms use type property - Not case (e.g., type: 'form') 6. 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=   # From app.towns.com/developer
    JWT_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 ')
        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)