Back to skills
SkillHub ClubRun DevOpsFull StackDevOpsIntegration

bots

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"

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
3,127
Hot score
99
Updated
March 20, 2026
Overall rating
C4.0
Composite score
4.0
Best-practice grade
B73.6

Install command

npx @skill-hub/cli install openclaw-skills-towns-protocol

Repository

openclaw/skills

Skill path: skills/andreyz/towns-protocol

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"

Open repository

Best for

Primary workflow: Run DevOps.

Technical facets: Full Stack, DevOps, Integration.

Target audience: everyone.

License: MIT.

Original source

Catalog source: SkillHub Club.

Repository owner: openclaw.

This is still a mirrored public skill entry. Review the repository before installing into production workflows.

What it helps with

  • Install bots into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/openclaw/skills before adding bots to shared team environments
  • Use bots for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

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

```typescript
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/](references/):**
- [Messaging API](references/MESSAGING.md) - Mentions, threads, attachments, formatting
- [Blockchain Operations](references/BLOCKCHAIN.md) - Read/write contracts, verify transactions
- [Interactive Components](references/INTERACTIVE.md) - Forms, transaction requests
- [Deployment](references/DEPLOYMENT.md) - Local dev, Render, tunnels
- [Debugging](references/DEBUGGING.md) - Troubleshooting guide

---

## Bot Setup

### Project Initialization

```bash
bunx towns-bot init my-bot
cd my-bot
bun install
```

### Environment Variables

```bash
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

```typescript
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

```typescript
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).

```typescript
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.

```typescript
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

```typescript
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.

```typescript
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

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

```typescript
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)


---

## Referenced Files

> The following files are referenced in this skill and included for context.

### references/MESSAGING.md

```markdown
# Messaging API

## Send Message with Mention

**MUST include BOTH formatted text AND mentions array:**

```typescript
// 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 }]
})
```

## Threads & Replies

```typescript
// Reply in thread
await handler.sendMessage(channelId, 'Thread reply', { threadId: event.eventId })

// Reply to specific message
await handler.sendMessage(channelId, 'Reply', { replyId: messageId })
```

## Attachments

```typescript
// 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'
}]
```

## Message Formatting

Towns has specific rendering behavior:
- **Use `\n\n`** (double newlines) between sections - single `\n` causes overlap
- **Never use `---`** as separator - renders as zero-height rule
- **Use middot** for inline data: `Value: $1.00 ยท P&L: $0.50`

```typescript
// Good - double newlines
const msg = ['**Header**', 'Line 1', 'Line 2'].join('\n\n')

// Bad - single newlines will collapse
const bad = lines.join('\n')
```

## Edit and Delete

```typescript
// Edit bot's own message
await handler.editMessage(channelId, eventId, 'Updated text')

// Delete bot's own message
await handler.removeEvent(channelId, eventId)
```

## Reactions

```typescript
await handler.sendReaction(channelId, messageId, '๐Ÿ‘')
```

```

### references/BLOCKCHAIN.md

```markdown
# Blockchain Operations

## Read Contract

```typescript
import { readContract } from 'viem/actions'
import { erc20Abi } from 'viem'

const balance = await readContract(bot.viem, {
  address: tokenAddress,
  abi: erc20Abi,
  functionName: 'balanceOf',
  args: [userAddress]
})
```

## Execute Transaction

```typescript
import { execute } from 'viem/experimental/erc7821'
import { waitForTransactionReceipt } from 'viem/actions'

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 })
```

## Verify Transaction (Critical for Payments)

**Never grant access based on txHash alone.** Always verify on-chain:

```typescript
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!')
  }
})
```

## Debug Transaction Failures

```typescript
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
}
```

## Token Addresses (Base Mainnet)

```typescript
import { zeroAddress } from 'viem'

const TOKENS = {
  ETH: zeroAddress,
  USDC: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
  TOWNS: '0x00000000A22C618fd6b4D7E9A335C4B96B189a38'
}
```

## Check Balances

```typescript
import { formatEther } from 'viem'

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')
console.log('Treasury: ' + formatEther(treasuryBalance) + ' ETH')
```

## Get User's Smart Account

```typescript
import { getSmartAccountFromUserId } from '@towns-protocol/bot'

const userSmartAccount = getSmartAccountFromUserId(event.userId)
```

```

### references/INTERACTIVE.md

```markdown
# Interactive Components

## Send Button Form

```typescript
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
})
```

## Handle Form Response

```typescript
bot.onInteractionResponse(async (handler, event) => {
  if (event.response.payload.content?.case !== 'form') return

  const form = event.response.payload.content.value
  for (const c of form.components) {
    if (c.component.case === 'button') {
      console.log('Button clicked:', c.id)

      if (c.id === 'yes') {
        await handler.sendMessage(event.channelId, 'You clicked Yes!')
      }
    }
  }
})
```

## Request Transaction

```typescript
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
})
```

## Handle Transaction Response

```typescript
bot.onInteractionResponse(async (handler, event) => {
  if (event.response.payload.content?.case !== 'transaction') return

  const tx = event.response.payload.content.value

  if (tx.txHash) {
    // IMPORTANT: Always verify on-chain before granting access
    // See BLOCKCHAIN.md for full verification pattern
    const receipt = await waitForTransactionReceipt(bot.viem, {
      hash: tx.txHash
    })

    if (receipt.status === 'success') {
      await handler.sendMessage(event.channelId,
        'Payment confirmed: https://basescan.org/tx/' + tx.txHash)
    } else {
      await handler.sendMessage(event.channelId, 'Transaction failed on-chain')
    }
  } else if (tx.error) {
    await handler.sendMessage(event.channelId, 'Transaction rejected: ' + tx.error)
  }
})
```

## Request Signature

```typescript
await handler.sendInteractionRequest(channelId, {
  type: 'signature',
  id: 'sign-message',
  title: 'Sign Message',
  message: 'I agree to the terms of service',
  recipient: event.userId
})
```

## Important Notes

- **Use `type` property** - NOT `case` (common mistake)
- **`recipient` is optional** - If set, only that user sees the interaction
- **Always verify transactions** - Never trust txHash alone, check receipt.status

```

### references/DEPLOYMENT.md

```markdown
# Deployment

## Local Development

```bash
# 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

1. Go to https://app.towns.com/developer
2. Select your bot
3. Set Webhook URL to your tunnel URL + `/webhook`
   - Example: `https://your-machine.taild8e1b0.ts.net/webhook`
4. Save changes

## Testing Checklist

- [ ] Bot server running (`bun run dev`)
- [ ] Tunnel active (Tailscale/ngrok)
- [ ] Webhook URL updated in Developer Portal
- [ ] Bot installed in a Space (Settings โ†’ Integrations)
- [ ] Bot added to the specific channel (Channel Settings โ†’ Integrations)
- [ ] Check logs for incoming webhook events

## Render.com Deployment

1. Create Web Service from Git repo
2. Set build command: `bun install`
3. Set start command: `bun run start`
4. Set environment variables:
   - `APP_PRIVATE_DATA`
   - `JWT_SECRET`
   - `DATABASE_URL` (if using database)
   - `BASE_RPC_URL` (recommended: Alchemy/Infura)
5. Set webhook URL in app.towns.com/developer to Render URL + `/webhook`

## Health Check Endpoint

Add for deployment platforms that require health checks:

```typescript
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
}))
```

## Graceful Shutdown

Handle SIGTERM for clean shutdown (required for Render/Kubernetes):

```typescript
process.on('SIGTERM', async () => {
  console.log('SIGTERM received, closing...')
  await pool.end()  // Close DB connections
  process.exit(0)
})
```

## Database Connection Pool

```typescript
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')
```

## Thread Ownership Pattern (Race-Safe)

```typescript
// 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
```

```

### references/DEBUGGING.md

```markdown
# Debugging

## Handler Not Triggering

Most common issue. Check in order:

### 1. Webhook URL Correct?

```bash
# Your bot should log incoming requests
curl -X POST https://your-webhook-url/webhook \
  -H "Content-Type: application/json" \
  -d '{"test": true}'
```

### 2. Tunnel Running? (local dev)

```bash
# Tailscale
tailscale funnel status

# ngrok
curl http://127.0.0.1:4040/api/tunnels
```

### 3. Bot Added to Channel?

Bot must be:
- Installed in the Space (Settings โ†’ Integrations)
- Added to the specific channel (Channel Settings โ†’ Integrations)

### 4. Message Forwarding Mode?

In Developer Portal:
- "Mentions Only" = only `@bot` messages
- "All Messages" = everything (required for `onTip`)

### 5. Slash Command Registered?

Must be in `commands` array passed to `makeTownsBot`:

```typescript
const commands = [
  { name: 'mycommand', description: 'Does something' }
] as const satisfies BotCommand[]

const bot = await makeTownsBot(creds, secret, { commands })
```

## Add Request Logging

```typescript
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
  })
})
```

## Common Error Messages

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

## Verify Webhook Connectivity

```typescript
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
```

## Debug Transaction Failures

```typescript
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
}
```

## Check Gas Wallet Balance

```typescript
import { formatEther } from 'viem'

const balance = await bot.viem.getBalance({
  address: bot.viem.account.address
})

console.log('Gas wallet balance:', formatEther(balance), 'ETH')

if (balance === 0n) {
  console.error('WARNING: Gas wallet is empty!')
  console.error('Fund this address:', bot.viem.account.address)
}
```

```



---

## Skill Companion Files

> Additional files collected from the skill directory layout.

### _meta.json

```json
{
  "owner": "andreyz",
  "slug": "towns-protocol",
  "displayName": "Towns Protocol Skills",
  "latest": {
    "version": "2.0.0",
    "publishedAt": 1768220595344,
    "commit": "https://github.com/clawdbot/skills/commit/5bb37ecb932de2b699531f1b74f5222d9dc2ccf5"
  },
  "history": []
}

```

bots | SkillHub