towns-agent 2.0.4 → 2.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,267 @@
1
+ # AGENTS.md
2
+
3
+ This file provides guidance to AI agents for building Towns Protocol bots.
4
+
5
+ ## Quick Start
6
+
7
+ **Requirements:**
8
+ 1. **APP_PRIVATE_DATA** - Bot credentials
9
+ 2. **JWT_SECRET** - Webhook security token
10
+ 3. **Event handlers** - Functions responding to Towns events
11
+
12
+ **CRITICAL:** Stateless architecture - no message history, thread context, or conversation memory. Store context externally if needed.
13
+
14
+ ## Base Payload
15
+
16
+ All event handlers receive a `BasePayload`:
17
+
18
+ ```typescript
19
+ type BasePayload = {
20
+ userId: string // Hex address (0x...)
21
+ channelId: string
22
+ eventId: string // Unique event ID (use as threadId/replyId when responding)
23
+ createdAt: Date
24
+ event: StreamEvent
25
+ isDm: boolean // true if the event is from a DM
26
+ isGdm: boolean // true if the event is from a GDM
27
+ }
28
+ ```
29
+
30
+ **Example:**
31
+ ```typescript
32
+ bot.onMessage(async (handler, event) => {
33
+ if (event.isDm) {
34
+ console.log('DM')
35
+ } else if (event.isGdm) {
36
+ console.log('GDM')
37
+ }
38
+ })
39
+ ```
40
+
41
+ ## Event Handlers
42
+
43
+ ### onMessage
44
+ **When:** Any non-slash-command message
45
+
46
+ ```typescript
47
+ {
48
+ ...basePayload,
49
+ message: string,
50
+ replyId?: string, // EventId of replied message
51
+ threadId?: string, // EventId of thread start
52
+ isMentioned: boolean,
53
+ mentions: Array<{ userId: string, displayName: string }>
54
+ }
55
+
56
+ bot.onMessage(async (handler, event) => {
57
+ if (event.isMentioned) {
58
+ await handler.sendMessage(event.channelId, "You mentioned me!")
59
+ }
60
+ if (event.threadId) {
61
+ await handler.sendMessage(event.channelId, "Reply", { threadId: event.threadId })
62
+ }
63
+ })
64
+ ```
65
+
66
+ ### onSlashCommand
67
+ **When:** User types `/command args` (does NOT trigger onMessage)
68
+
69
+ ```typescript
70
+ {
71
+ ...basePayload,
72
+ command: string,
73
+ args: string[],
74
+ mentions: Array<{ userId: string, displayName: string }>,
75
+ replyId?: string,
76
+ threadId?: string
77
+ }
78
+ ```
79
+
80
+ **Setup:**
81
+ ```typescript
82
+ // 1. src/commands.ts
83
+ import type { AgentCommand } from '@towns-labs/agent'
84
+
85
+ export const commands = [
86
+ { name: "help", description: "Show help" }
87
+ ] as const satisfies AgentCommand[]
88
+
89
+ // 2. Initialize
90
+ const bot = await makeTownsAgent(privateData, jwtSecret, { commands })
91
+
92
+ // 3. Register
93
+ bot.onSlashCommand("help", async (handler, event) => {
94
+ await handler.sendMessage(event.channelId, "Commands: /help")
95
+ })
96
+ ```
97
+
98
+ #### Paid Commands
99
+ Add a `paid` property to your command definition with a price in USDC:
100
+ ```typescript
101
+ { name: "generate", description: "Generate AI content", paid: { price: '$0.20' } }
102
+ ```
103
+
104
+ ### onReaction
105
+ **When:** User adds emoji reaction
106
+
107
+ ```typescript
108
+ {
109
+ ...basePayload,
110
+ reaction: string, // "thumbsup", "heart"
111
+ messageId: string // EventId
112
+ }
113
+ ```
114
+ **Note:** No access to original message content.
115
+
116
+ ### onInteractionResponse
117
+ **When:** Button click, form submit, transaction/signature response
118
+ **Pattern:** Set ID in request -> Match ID in response
119
+
120
+ ```typescript
121
+ { ...basePayload, response: DecryptedInteractionResponse }
122
+
123
+ // 1. Send request with ID
124
+ await handler.sendInteractionRequest(channelId, {
125
+ type: 'form',
126
+ id: "confirm-action",
127
+ title: "Confirm?",
128
+ components: [{ id: "yes", component: { case: "button", value: { label: "Yes" } } }]
129
+ }, hexToBytes(userId as `0x${string}`))
130
+
131
+ // 2. Match ID in response
132
+ bot.onInteractionResponse(async (handler, event) => {
133
+ if (event.response.payload.content?.case === "form") {
134
+ const form = event.response.payload.content.value
135
+ if (form.requestId === "confirm-action") {
136
+ for (const c of form.components) {
137
+ if (c.component.case === "button" && c.id === "yes") {
138
+ await handler.sendMessage(event.channelId, "Confirmed!")
139
+ }
140
+ }
141
+ }
142
+ }
143
+ })
144
+ ```
145
+
146
+ **Other Request Types:**
147
+ ```typescript
148
+ import { hexToBytes } from 'viem'
149
+
150
+ // Transaction
151
+ await handler.sendInteractionRequest(channelId, {
152
+ type: 'transaction',
153
+ id: "tx-id",
154
+ title: "Send USDC",
155
+ content: {
156
+ case: "evm",
157
+ value: { chainId: "8453", to, value: "0", data, signerWallet: undefined }
158
+ }
159
+ })
160
+
161
+ // Signature
162
+ await handler.sendInteractionRequest(channelId, {
163
+ type: 'signature',
164
+ id: "sig-id",
165
+ title: "Sign",
166
+ chainId: "8453",
167
+ data: JSON.stringify(typedData),
168
+ signatureType: InteractionRequestPayload_Signature_SignatureType.TYPED_DATA,
169
+ signerWallet: undefined
170
+ })
171
+ ```
172
+
173
+ ### Other Events
174
+
175
+ **onMessageEdit:** `{ ...basePayload, refEventId: string, message: string, ... }`
176
+
177
+ **onRedaction / onEventRevoke:** `{ ...basePayload, refEventId: string }`
178
+
179
+ **onChannelJoin / onChannelLeave:** Base payload only
180
+
181
+ **onStreamEvent:** `{ ...basePayload, event: ParsedEvent }` (advanced)
182
+
183
+ ## Handler API
184
+
185
+ **Types:** `AgentHandler`, `BasePayload`, `AgentCommand`
186
+
187
+ ```typescript
188
+ // Send (use @userId to mention a user in message text, and add mentions in sendMessage options)
189
+ await handler.sendMessage(channelId, "Hello @0x123...", {
190
+ threadId?, replyId?, mentions: [{ userId: "0x123...", displayName: "name" }], attachments? })
191
+
192
+ await handler.editMessage(channelId, messageId, newMessage) // Bot's own only
193
+ await handler.sendReaction(channelId, messageId, reaction)
194
+ await handler.sendGM(channelId, typeUrl, data) // Send typed generic message
195
+ await handler.sendRawGM(channelId, typeUrl, rawBytes) // Send raw generic message bytes
196
+ await handler.pinMessage(channelId, eventId, streamEvent)
197
+ await handler.unpinMessage(channelId, eventId)
198
+ await handler.removeEvent(channelId, eventId) // Bot's own
199
+ await handler.sendKeySolicitation(channelId)
200
+ await handler.uploadDeviceKeys()
201
+ await handler.sendBlockchainTransaction(channelId, params)
202
+ ```
203
+
204
+ ## Attachments
205
+
206
+ ```typescript
207
+ // Image
208
+ attachments: [{ type: 'image', url: 'https://...jpg', alt: 'Desc' }]
209
+
210
+ // Link
211
+ attachments: [{ type: 'link', url: 'https://...' }]
212
+
213
+ // Miniapp
214
+ attachments: [{ type: 'miniapp', url: 'https://...' }]
215
+
216
+ // Chunked (videos, screenshots)
217
+ import { readFileSync } from 'node:fs'
218
+ attachments: [{
219
+ type: 'chunked',
220
+ data: readFileSync('./video.mp4'), // Uint8Array
221
+ filename: 'video.mp4',
222
+ mimetype: 'video/mp4', // Required
223
+ width: 1920, // Optional
224
+ height: 1080
225
+ }]
226
+ ```
227
+
228
+ ## Web3 / Contract Interactions
229
+
230
+ ### Agent Wallet Architecture
231
+ **Single wallet** using ERC-7702 delegation. The agent has one address (`bot.appAddress`) that acts as both the signer (EOA) and the smart account. The agent owner (the account that registered the app) is a **super admin** of this wallet, retaining full control.
232
+
233
+ ```typescript
234
+ bot.viem, bot.appAddress // Access points
235
+
236
+ // Reading
237
+ import { readContract } from 'viem/actions'
238
+ const result = await readContract(bot.viem, { address, abi, functionName: 'balanceOf', args: [user] })
239
+ ```
240
+
241
+ ### Writing
242
+ ```typescript
243
+ import { writeContract } from 'viem/actions'
244
+ const hash = await writeContract(bot.viem, { address: targetContract, abi,
245
+ functionName: 'transfer', args: [recipient, amount] })
246
+ ```
247
+
248
+ ## External Interactions (Unprompted Messages)
249
+
250
+ `bot.start()` returns a **Hono app**. To extend with additional routes, create a new Hono app and use `.route('/', app)` per https://hono.dev/docs/guides/best-practices#building-a-larger-application
251
+
252
+ **All handler methods available on bot** (webhooks, timers, tasks):
253
+ You need data prior (channelId, etc):
254
+ ```typescript
255
+ bot.sendMessage(channelId, msg, opts?) | bot.editMessage(...) | bot.sendReaction(...) | bot.removeEvent(...)
256
+ bot.adminRemoveEvent(...) | bot.pinMessage(...) | bot.unpinMessage(...)
257
+ // Properties: bot.viem, bot.appAddress
258
+ ```
259
+
260
+ **Patterns:** Store channel IDs | Webhooks/timers | Call bot.* directly | Handle errors
261
+
262
+ ## Critical Notes
263
+
264
+ 1. **User IDs are addresses** - `0x...`, not usernames
265
+ 2. **Use `@userId` for mentions** and add mentions in sendMessage options
266
+ 3. **Slash commands exclusive** - Never trigger `onMessage`
267
+ 4. **Stateless** - Store context externally
@@ -0,0 +1,95 @@
1
+ # Towns Agent
2
+
3
+ A [Towns Protocol](https://towns.com) agent built with [`@towns-labs/agent`](https://www.npmjs.com/package/@towns-labs/agent).
4
+
5
+ ## Getting Started
6
+
7
+ ### 1. Install dependencies
8
+
9
+ ```bash
10
+ bun install
11
+ ```
12
+
13
+ ### 2. Create an agent account
14
+
15
+ ```bash
16
+ bunx towns-agent create --env prod
17
+ ```
18
+
19
+ This will prompt for authentication and bot metadata, then output your credentials. Pipe them straight into `.env`:
20
+
21
+ ```bash
22
+ bunx towns-agent create --env prod >> .env
23
+ ```
24
+
25
+ Or copy `.env.sample` and fill in the values manually:
26
+
27
+ ```bash
28
+ cp .env.sample .env
29
+ ```
30
+
31
+ | Variable | Description |
32
+ |---|---|
33
+ | `APP_ADDRESS` | Agent app address |
34
+ | `APP_PRIVATE_DATA` | Agent credentials |
35
+ | `JWT_SECRET` | Webhook security token |
36
+ | `PORT` | Server port (default: 5123) |
37
+
38
+ ### 3. Start the agent
39
+
40
+ ```bash
41
+ bun run dev
42
+ ```
43
+
44
+ ### 4. Register your webhook
45
+
46
+ Once the server is running, register its URL so Towns can deliver events:
47
+
48
+ ```bash
49
+ bunx towns-agent setup
50
+ ```
51
+
52
+ This reads the app address from your `.env` and prompts for the webhook URL and notification settings.
53
+
54
+ ### 5. View or update metadata
55
+
56
+ ```bash
57
+ # View current metadata
58
+ bunx towns-agent metadata view
59
+
60
+ # Update metadata interactively
61
+ bunx towns-agent metadata update
62
+ ```
63
+
64
+ ## Project Structure
65
+
66
+ ```
67
+ src/
68
+ index.ts # Agent initialization and event handlers
69
+ commands.ts # Slash command definitions
70
+ ```
71
+
72
+ - **`commands.ts`** — Define slash commands that appear in autocomplete.
73
+ - **`index.ts`** — Initialize the agent with `makeTownsAgent` and register event handlers (`onMessage`, `onSlashCommand`, etc.).
74
+
75
+ ## Scripts
76
+
77
+ | Script | Description |
78
+ |---|---|
79
+ | `bun run dev` | Start with hot-reload |
80
+ | `bun run start` | Start without hot-reload |
81
+ | `bun run build` | Type-check |
82
+ | `bun run lint` | Lint with oxlint |
83
+ | `bun run fmt` | Format with oxfmt |
84
+ | `bun run fmt:check` | Check formatting |
85
+
86
+ ## Updating
87
+
88
+ ```bash
89
+ bunx towns-agent update
90
+ ```
91
+
92
+ ## Resources
93
+
94
+ - [AGENTS.md](./AGENTS.md) — API reference for event handlers, payloads, and handler methods
95
+ - [@towns-labs/agent on npm](https://www.npmjs.com/package/@towns-labs/agent)
@@ -0,0 +1,33 @@
1
+ # Dependencies
2
+ node_modules/
3
+
4
+ # Build output
5
+ dist/
6
+
7
+ # Environment variables
8
+ .env
9
+ .env.local
10
+ .env.production
11
+
12
+ # Logs
13
+ *.log
14
+ npm-debug.log*
15
+ yarn-debug.log*
16
+ yarn-error.log*
17
+
18
+ # Runtime data
19
+ pids
20
+ *.pid
21
+ *.seed
22
+ *.pid.lock
23
+
24
+ # Coverage directory used by tools like istanbul
25
+ coverage/
26
+
27
+ # IDE
28
+ .vscode/
29
+ .idea/
30
+
31
+ # OS
32
+ .DS_Store
33
+ Thumbs.db
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "towns-agent-quickstart",
3
+ "private": true,
4
+ "version": "0.0.1",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "scripts": {
8
+ "build": "tsc --noEmit",
9
+ "dev": "bun run --watch src/index.ts",
10
+ "fmt": "oxfmt --write ./src",
11
+ "fmt:check": "oxfmt --check ./src",
12
+ "lint": "oxlint ./src",
13
+ "lint:fix": "oxlint ./src --fix",
14
+ "start": "bun run src/index.ts"
15
+ },
16
+ "dependencies": {
17
+ "@connectrpc/connect-node": "^2.1.0",
18
+ "@hono/node-server": "^1.19.9",
19
+ "@towns-labs/agent": "workspace:^",
20
+ "@towns-labs/proto": "workspace:^",
21
+ "dotenv": "^16.4.5",
22
+ "hono": "^4.11.7",
23
+ "viem": "2.45.1"
24
+ },
25
+ "devDependencies": {
26
+ "@types/bun": "^1.3.1",
27
+ "@types/node": "^20.14.8",
28
+ "oxfmt": "^0.1.0",
29
+ "oxlint": "^1.41.0",
30
+ "typescript": "~5.8.3"
31
+ },
32
+ "files": [
33
+ "/dist"
34
+ ]
35
+ }
@@ -0,0 +1,12 @@
1
+ import type { AgentCommand } from "@towns-labs/agent"
2
+
3
+ // Those commands will be registered to the bot as soon as the bot is initialized
4
+ // and will be available in the slash command autocomplete.
5
+ const commands = [
6
+ {
7
+ name: "help",
8
+ description: "Get help with bot commands",
9
+ },
10
+ ] as const satisfies AgentCommand[]
11
+
12
+ export default commands
@@ -0,0 +1,56 @@
1
+ import "dotenv/config"
2
+ import { serve } from "@hono/node-server"
3
+ import { makeTownsAgent } from "@towns-labs/agent"
4
+ import { createServer } from "node:http2"
5
+ import commands from "./commands"
6
+
7
+ const bot = await makeTownsAgent(
8
+ process.env.APP_PRIVATE_DATA!,
9
+ process.env.JWT_SECRET!,
10
+ { commands },
11
+ )
12
+
13
+ bot.onSlashCommand("help", async (handler, { channelId }) => {
14
+ await handler.sendMessage(
15
+ channelId,
16
+ "**Available Commands:**\n\n" +
17
+ "• `/help` - Show this help message\n" +
18
+ "**Message Triggers:**\n\n" +
19
+ "• React with 👋 - I'll wave back\n" +
20
+ '• Say "hello" or "hey" - I\'ll greet you back\n' +
21
+ '• Say "ping" - I\'ll show latency\n' +
22
+ '• Say "react" - I\'ll add a reaction\n',
23
+ )
24
+ })
25
+
26
+ bot.onMessage(async (handler, { message, channelId, eventId, createdAt }) => {
27
+ if (message.includes("hello") || message.includes("hey")) {
28
+ await handler.sendMessage(channelId, "Hello there! 👋")
29
+ return
30
+ }
31
+ if (message.includes("ping")) {
32
+ const now = new Date()
33
+ await handler.sendMessage(
34
+ channelId,
35
+ `Pong! 🏓 ${now.getTime() - createdAt.getTime()}ms`,
36
+ )
37
+ return
38
+ }
39
+ if (message.includes("react")) {
40
+ await handler.sendReaction(channelId, eventId, "👍")
41
+ return
42
+ }
43
+ })
44
+
45
+ bot.onReaction(async (handler, { reaction, channelId }) => {
46
+ if (reaction === "👋") {
47
+ await handler.sendMessage(channelId, "I saw your wave! 👋")
48
+ }
49
+ })
50
+
51
+ const app = bot.start()
52
+ serve({
53
+ fetch: app.fetch,
54
+ port: Number(process.env.PORT) || 5123,
55
+ createServer,
56
+ })
@@ -0,0 +1,25 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "esnext",
4
+ "module": "esnext",
5
+ "moduleResolution": "bundler",
6
+ "resolveJsonModule": true,
7
+ "declaration": true,
8
+ "declarationMap": true,
9
+ "sourceMap": true,
10
+ "isolatedModules": true,
11
+ "allowSyntheticDefaultImports": true,
12
+ "esModuleInterop": true,
13
+ "forceConsistentCasingInFileNames": true,
14
+ "strict": true,
15
+ "noImplicitAny": true,
16
+ "strictNullChecks": true,
17
+ "alwaysStrict": true,
18
+ "noImplicitReturns": true,
19
+ "noFallthroughCasesInSwitch": true,
20
+ "skipLibCheck": true,
21
+ "outDir": "./dist",
22
+ "types": ["node"]
23
+ },
24
+ "include": ["src/**/*"]
25
+ }