Files
clawdbot/extensions/tlon/README.md
william arzt d46642319b Add Tlon/Urbit channel plugin
Adds built-in Tlon (Urbit) channel plugin to support decentralized messaging on the Urbit network.

Features:
- DM and group chat support
- SSE-based real-time message monitoring
- Auto-discovery of group channels
- Thread replies and reactions
- Integration with Urbit's HTTP API

This resolves cron delivery issues with external Tlon plugins by making it a first-class built-in channel alongside Telegram, Signal, and other messaging platforms.

Implementation includes:
- Plugin registration via ClawdbotPluginApi
- Outbound delivery with sendText and sendMedia
- Gateway adapter for inbound message handling
- Urbit SSE client for event streaming
- Core bridge for Clawdbot runtime integration

Co-authored-by: William Arzt <william@arzt.co>
2026-01-24 00:25:38 +00:00

829 lines
22 KiB
Markdown

# Clawdbot Tlon/Urbit Integration
Complete documentation for integrating Clawdbot with Tlon Messenger (built on Urbit).
## Overview
This extension enables Clawdbot to:
- Monitor and respond to direct messages on Tlon Messenger
- Monitor and respond to group channel messages when mentioned
- Auto-discover available group channels
- Use per-conversation subscriptions for reliable message delivery
- **Automatic AI model fallback** - Seamlessly switches from Anthropic to OpenAI when rate limited (see [FALLBACK.md](./FALLBACK.md))
**Ship:** ~sitrul-nacwyl
**Test User:** ~malmur-halmex
## Architecture
### Files
- **`index.js`** - Plugin entry point, registers the Tlon channel adapter
- **`monitor.js`** - Core monitoring logic, handles incoming messages and AI dispatch
- **`urbit-sse-client.js`** - Custom SSE client for Urbit HTTP API
- **`core-bridge.js`** - Dynamic loader for clawdbot core modules
- **`package.json`** - Plugin package definition
- **`FALLBACK.md`** - AI model fallback system documentation
### How It Works
1. **Authentication**: Uses ship name + code to authenticate via `/~/login` endpoint
2. **Channel Creation**: Creates Tlon Messenger channel via PUT to `/~/channel/{uid}`
3. **Activation**: Sends "helm-hi" poke to activate channel (required!)
4. **Subscriptions**:
- **DMs**: Individual subscriptions to `/dm/{ship}` for each conversation
- **Groups**: Individual subscriptions to `/{channelNest}` for each channel
5. **SSE Stream**: Opens server-sent events stream for real-time updates
6. **Auto-Reconnection**: Automatically reconnects if SSE stream dies
- Exponential backoff (1s to 30s delays)
- Up to 10 reconnection attempts
- Generates new channel ID on each attempt
7. **Auto-Discovery**: Queries `/groups-ui/v6/init.json` to find all available channels
8. **Dynamic Refresh**: Polls every 2 minutes for new conversations/channels
9. **Message Processing**: When bot is mentioned, routes to AI via clawdbot core
10. **AI Fallback**: Automatically switches providers when rate limited
- Primary: Anthropic Claude Sonnet 4.5
- Fallbacks: OpenAI GPT-4o, GPT-4 Turbo
- Automatic cooldown management
- See [FALLBACK.md](./FALLBACK.md) for details
## Configuration
### 1. Install Dependencies
```bash
cd ~/.clawdbot/extensions/tlon
npm install
```
### 2. Configure Credentials
Edit `~/.clawdbot/clawdbot.json`:
```json
{
"channels": {
"tlon": {
"enabled": true,
"ship": "your-ship-name",
"code": "your-ship-code",
"url": "https://your-ship-name.tlon.network",
"showModelSignature": false,
"dmAllowlist": ["~friend-ship-1", "~friend-ship-2"],
"defaultAuthorizedShips": ["~malmur-halmex"],
"authorization": {
"channelRules": {
"chat/~host-ship/channel-name": {
"mode": "open",
"allowedShips": []
},
"chat/~another-host/private-channel": {
"mode": "restricted",
"allowedShips": ["~malmur-halmex", "~sitrul-nacwyl"]
}
}
}
}
}
}
```
**Configuration Options:**
- `enabled` - Enable/disable the Tlon channel (default: `false`)
- `ship` - Your Urbit ship name (required)
- `code` - Your ship's login code (required)
- `url` - Your ship's URL (required)
- `showModelSignature` - Append model name to responses (default: `false`)
- When enabled, adds `[Generated by Claude Sonnet 4.5]` to the end of each response
- Useful for transparency about which AI model generated the response
- `dmAllowlist` - Ships allowed to send DMs (optional)
- If omitted or empty, all DMs are accepted (default behavior)
- Ship names can include or omit the `~` prefix
- Example: `["~trusted-friend", "~another-ship"]`
- Blocked DMs are logged for visibility
- `defaultAuthorizedShips` - Ships authorized in new/unconfigured channels (default: `["~malmur-halmex"]`)
- New channels default to `restricted` mode using these ships
- `authorization` - Per-channel access control (optional)
- `channelRules` - Map of channel nest to authorization rules
- `mode`: `"open"` (all ships) or `"restricted"` (allowedShips only)
- `allowedShips`: Array of authorized ships (only for `restricted` mode)
**For localhost development:**
```json
"url": "http://localhost:8080"
```
**For Tlon-hosted ships:**
```json
"url": "https://{ship-name}.tlon.network"
```
### 3. Set Environment Variable
The monitor needs to find clawdbot's core modules. Set the environment variable:
```bash
export CLAWDBOT_ROOT=/opt/homebrew/lib/node_modules/clawdbot
```
Or if clawdbot is installed elsewhere:
```bash
export CLAWDBOT_ROOT=$(dirname $(dirname $(readlink -f $(which clawdbot))))
```
**Make it permanent** (add to `~/.zshrc` or `~/.bashrc`):
```bash
echo 'export CLAWDBOT_ROOT=/opt/homebrew/lib/node_modules/clawdbot' >> ~/.zshrc
```
### 4. Configure AI Authentication
The bot needs API credentials to generate responses.
**Option A: Use Claude Code CLI credentials**
```bash
clawdbot agents add main
# Select "Use Claude Code CLI credentials"
```
**Option B: Use Anthropic API key**
```bash
clawdbot agents add main
# Enter your API key from console.anthropic.com
```
### 5. Start the Gateway
```bash
CLAWDBOT_ROOT=/opt/homebrew/lib/node_modules/clawdbot clawdbot gateway
```
Or create a launch script:
```bash
cat > ~/start-clawdbot.sh << 'EOF'
#!/bin/bash
export CLAWDBOT_ROOT=/opt/homebrew/lib/node_modules/clawdbot
clawdbot gateway
EOF
chmod +x ~/start-clawdbot.sh
```
## Usage
### Testing
1. Send a DM from another ship to ~sitrul-nacwyl
2. Mention the bot: `~sitrul-nacwyl hello there!`
3. Bot should respond with AI-generated reply
### Monitoring Logs
Check gateway logs:
```bash
tail -f /tmp/clawdbot/clawdbot-$(date +%Y-%m-%d).log
```
Look for these indicators:
- `[tlon] Successfully authenticated to https://...`
- `[tlon] Auto-discovered N chat channel(s)`
- `[tlon] Connected! All subscriptions active`
- `[tlon] Received DM from ~ship: "..." (mentioned: true)`
- `[tlon] Dispatching to AI for ~ship (DM)`
- `[tlon] Delivered AI reply to ~ship`
### Group Channels
The bot automatically discovers and subscribes to all group channels using **delta-based discovery** for efficiency.
**How Auto-Discovery Works:**
1. **On startup:** Fetches changes from the last 5 days via `/groups-ui/v5/changes/~YYYY.M.D..20.19.51..9b9d.json`
2. **Periodic refresh:** Checks for new channels every 2 minutes
3. **Smart caching:** Only fetches deltas, not full state each time
**Benefits:**
- Reduced bandwidth usage
- Faster startup (especially for ships with many groups)
- Automatically picks up new channels you join
- Context of recent group activity
**Manual Configuration:**
To disable auto-discovery and use specific channels:
```json
{
"channels": {
"tlon": {
"enabled": true,
"ship": "your-ship-name",
"code": "your-ship-code",
"url": "https://your-ship-name.tlon.network",
"autoDiscoverChannels": false,
"groupChannels": [
"chat/~host-ship/channel-name",
"chat/~another-host/another-channel"
]
}
}
}
```
### Model Signatures
The bot can append the AI model name to each response for transparency. Enable this feature in your config:
```json
{
"channels": {
"tlon": {
"enabled": true,
"ship": "your-ship-name",
"code": "your-ship-code",
"url": "https://your-ship-name.tlon.network",
"showModelSignature": true
}
}
}
```
**Example output with signature enabled:**
```
User: ~sitrul-nacwyl explain quantum computing
Bot: Quantum computing uses quantum mechanics principles like superposition
and entanglement to perform calculations...
[Generated by Claude Sonnet 4.5]
```
**Supported model formats:**
- `Claude Opus 4.5`
- `Claude Sonnet 4.5`
- `GPT-4o`
- `GPT-4 Turbo`
- `Gemini 2.0 Flash`
When using the [AI fallback system](./FALLBACK.md), signatures automatically reflect which model generated the response (e.g., if Anthropic is rate limited and OpenAI is used, the signature will show `GPT-4o`).
### Channel History Summarization
The bot can summarize recent channel activity when asked. This is useful for catching up on conversations you missed.
**Trigger phrases:**
- `~bot-ship summarize this channel`
- `~bot-ship what did I miss?`
- `~bot-ship catch me up`
- `~bot-ship tldr`
- `~bot-ship channel summary`
**Example:**
```
User: ~sitrul-nacwyl what did I miss?
Bot: Here's a summary of the last 50 messages:
Main topics discussed:
1. Discussion about Urbit networking (Ames protocol)
2. Planning for next week's developer meetup
3. Bug reports for the new UI update
Key decisions:
- Meetup scheduled for Thursday at 3pm EST
- Priority on fixing the scrolling issue
Notable participants: ~malmur-halmex, ~bolbex-fogdys
```
**How it works:**
- Fetches the last 50 messages from the channel
- Sends them to the AI for summarization
- Returns a concise summary with main topics, decisions, and action items
### Thread Support
The bot automatically maintains context in threaded conversations. When you mention the bot in a reply thread, it will respond within that thread instead of posting to the main channel.
**Example:**
```
Main channel post:
User A: ~sitrul-nacwyl what's the capital of France?
Bot: Paris is the capital of France.
└─ User B (in thread): ~sitrul-nacwyl and what's its population?
└─ Bot (in thread): Paris has a population of approximately 2.2 million...
```
**Benefits:**
- Keeps conversations organized
- Reduces noise in main channel
- Maintains conversation context within threads
**Technical Details:**
The bot handles both top-level posts and thread replies with different data structures:
- Top-level posts: `response.post.r-post.set.essay`
- Thread replies: `response.post.r-post.reply.r-reply.set.memo`
When replying in a thread, the bot uses the `parent-id` from the incoming message to ensure the reply stays within the same thread.
**Note:** Thread support is automatic - no configuration needed.
### Link Summarization
The bot can fetch and summarize web content when you share links.
**Example:**
```
User: ~sitrul-nacwyl can you summarize this https://example.com/article
Bot: This article discusses... [summary of the content]
```
**How it works:**
- Bot extracts URLs from rich text messages (including inline links)
- Fetches the web page content
- Summarizes using the WebFetch tool
### Channel Authorization
Control which ships can invoke the bot in specific group channels. **New channels default to `restricted` mode** for security.
#### Default Behavior
**DMs:** Always open (no restrictions)
**Group Channels:** Restricted by default, only ships in `defaultAuthorizedShips` can invoke the bot
#### Configuration
```json
{
"channels": {
"tlon": {
"enabled": true,
"ship": "sitrul-nacwyl",
"code": "your-code",
"url": "https://sitrul-nacwyl.tlon.network",
"defaultAuthorizedShips": ["~malmur-halmex"],
"authorization": {
"channelRules": {
"chat/~bitpyx-dildus/core": {
"mode": "open"
},
"chat/~nocsyx-lassul/bongtable": {
"mode": "restricted",
"allowedShips": ["~malmur-halmex", "~sitrul-nacwyl"]
}
}
}
}
}
}
```
#### Authorization Modes
**`open`** - Any ship can invoke the bot when mentioned
- Good for public channels
- No `allowedShips` needed
**`restricted`** (default) - Only specific ships can invoke the bot
- Good for private/work channels
- Requires `allowedShips` list
- New channels use `defaultAuthorizedShips` if no rule exists
#### Examples
**Make a channel public:**
```json
"chat/~bitpyx-dildus/core": {
"mode": "open"
}
```
**Restrict to specific users:**
```json
"chat/~nocsyx-lassul/bongtable": {
"mode": "restricted",
"allowedShips": ["~malmur-halmex"]
}
```
**New channel (no config):**
- Mode: `restricted` (safe default)
- Allowed ships: `defaultAuthorizedShips` (e.g., `["~malmur-halmex"]`)
#### Behavior
**Authorized mention:**
```
~malmur-halmex: ~sitrul-nacwyl tell me about quantum computing
Bot: [Responds with answer]
```
**Unauthorized mention (silently ignored):**
```
~other-ship: ~sitrul-nacwyl tell me about quantum computing
Bot: [No response, logs show access denied]
```
**Check logs:**
```bash
tail -f /tmp/tlon-fallback.log | grep "Access"
```
You'll see:
```
[tlon] ✅ Access granted: ~malmur-halmex in chat/~host/channel (authorized user)
[tlon] ⛔ Access denied: ~other-ship in chat/~host/channel (restricted, allowed: ~malmur-halmex)
```
## Technical Deep Dive
### Urbit HTTP API Flow
1. **Login** (POST `/~/login`)
- Sends `password={code}`
- Returns authentication cookie in `set-cookie` header
2. **Channel Creation** (PUT `/~/channel/{channelId}`)
- Channel ID format: `{timestamp}-{random}`
- Body: array of subscription objects
- Response: 204 No Content
3. **Channel Activation** (PUT `/~/channel/{channelId}`)
- **Critical:** Must send helm-hi poke BEFORE opening SSE stream
- Poke structure:
```json
{
"id": timestamp,
"action": "poke",
"ship": "sitrul-nacwyl",
"app": "hood",
"mark": "helm-hi",
"json": "Opening API channel"
}
```
4. **SSE Stream** (GET `/~/channel/{channelId}`)
- Headers: `Accept: text/event-stream`
- Returns Server-Sent Events
- Format:
```
id: {event-id}
data: {json-payload}
```
### Subscription Paths
#### DMs (Chat App)
- **Path:** `/dm/{ship}`
- **App:** `chat`
- **Event Format:**
```json
{
"id": "~ship/timestamp",
"whom": "~other-ship",
"response": {
"add": {
"memo": {
"author": "~sender-ship",
"sent": 1768742460781,
"content": [
{
"inline": [
"text",
{"ship": "~mentioned-ship"},
"more text",
{"break": null}
]
}
]
}
}
}
}
```
#### Group Channels (Channels App)
- **Path:** `/{channelNest}`
- **Channel Nest Format:** `chat/~host-ship/channel-name`
- **App:** `channels`
- **Event Format:**
```json
{
"response": {
"post": {
"id": "message-id",
"r-post": {
"set": {
"essay": {
"author": "~sender-ship",
"sent": 1768742460781,
"kind": "/chat",
"content": [...]
}
}
}
}
}
}
```
### Text Extraction
Message content uses inline format with mixed types:
- Strings: plain text
- Objects with `ship`: mentions (e.g., `{"ship": "~sitrul-nacwyl"}`)
- Objects with `break`: line breaks (e.g., `{"break": null}`)
Example:
```json
{
"inline": [
"Hey ",
{"ship": "~sitrul-nacwyl"},
" how are you?",
{"break": null},
"This is a new line"
]
}
```
Extracts to: `"Hey ~sitrul-nacwyl how are you?\nThis is a new line"`
### Mention Detection
Simple includes check (case-insensitive):
```javascript
const normalizedBotShip = botShipName.startsWith("~")
? botShipName
: `~${botShipName}`;
return messageText.toLowerCase().includes(normalizedBotShip.toLowerCase());
```
Note: Word boundaries (`\b`) don't work with `~` character.
## Troubleshooting
### Issue: "Cannot read properties of undefined (reading 'href')"
**Cause:** Some clawdbot dependencies (axios, Slack SDK) expect browser globals
**Fix:** Window.location polyfill is already added to monitor.js (lines 1-18)
### Issue: "Unable to resolve Clawdbot root"
**Cause:** core-bridge.js can't find clawdbot installation
**Fix:** Set `CLAWDBOT_ROOT` environment variable:
```bash
export CLAWDBOT_ROOT=/opt/homebrew/lib/node_modules/clawdbot
```
### Issue: SSE Stream Returns 403 Forbidden
**Cause:** Trying to open SSE stream without activating channel first
**Fix:** Send helm-hi poke before opening stream (urbit-sse-client.js handles this)
### Issue: No Events Received After Subscribing
**Cause:** Wrong subscription path or app name
**Fix:**
- DMs: Use `/dm/{ship}` with `app: "chat"`
- Groups: Use `/{channelNest}` with `app: "channels"`
### Issue: Messages Show "[object Object]"
**Cause:** Not handling inline content objects properly
**Fix:** Text extraction handles mentions and breaks (monitor.js `extractMessageText()`)
### Issue: Bot Not Detecting Mentions
**Cause:** Message doesn't contain bot's ship name
**Debug:**
```bash
tail -f /tmp/clawdbot/clawdbot-*.log | grep "mentioned:"
```
Should show:
```
[tlon] Received DM from ~malmur-halmex: "~sitrul-nacwyl hello..." (mentioned: true)
```
### Issue: "No API key found for provider 'anthropic'"
**Cause:** AI authentication not configured
**Fix:** Run `clawdbot agents add main` and configure credentials
### Issue: Gateway Port Already in Use
**Fix:**
```bash
# Stop existing instance
clawdbot daemon stop
# Or force kill
lsof -ti:18789 | xargs kill -9
```
### Issue: Bot Stops Responding (SSE Disconnection)
**Cause:** Urbit SSE stream disconnected (sent "quit" event or stream ended)
**Symptoms:**
- Logs show: `[SSE] Received event: {"id":X,"response":"quit"}`
- No more incoming SSE events
- Bot appears online but doesn't respond to mentions
**Fix:** The bot now **automatically reconnects**! Look for these log messages:
```
[SSE] Stream ended, attempting reconnection...
[SSE] Reconnection attempt 1/10 in 1000ms...
[SSE] Reconnecting with new channel ID: xxx-yyy
[SSE] Reconnection successful!
```
**Manual restart if needed:**
```bash
kill $(pgrep -f "clawdbot gateway")
CLAWDBOT_ROOT=/opt/homebrew/lib/node_modules/clawdbot clawdbot gateway
```
**Configuration options** (in urbit-sse-client.js constructor):
```javascript
new UrbitSSEClient(url, cookie, {
autoReconnect: true, // Default: true
maxReconnectAttempts: 10, // Default: 10
reconnectDelay: 1000, // Initial delay: 1s
maxReconnectDelay: 30000, // Max delay: 30s
onReconnect: async (client) => {
// Optional callback for resubscription logic
}
})
```
## Development Notes
### Testing Without Clawdbot
You can test the Urbit API directly:
```javascript
import { UrbitSSEClient } from "./urbit-sse-client.js";
const api = new UrbitSSEClient(
"https://sitrul-nacwyl.tlon.network",
"your-cookie-here"
);
// Subscribe to DMs
await api.subscribe({
app: "chat",
path: "/dm/malmur-halmex",
event: (data) => console.log("DM:", data),
err: (e) => console.error("Error:", e),
quit: () => console.log("Quit")
});
// Connect
await api.connect();
// Send a DM
await api.poke({
app: "chat",
mark: "chat-dm-action",
json: {
ship: "~malmur-halmex",
diff: {
id: `~sitrul-nacwyl/${Date.now()}`,
delta: {
add: {
memo: {
content: [{ inline: ["Hello!"] }],
author: "~sitrul-nacwyl",
sent: Date.now()
},
kind: null,
time: null
}
}
}
}
});
```
### Debugging SSE Events
Enable verbose logging in urbit-sse-client.js:
```javascript
// Line 169-171
if (parsed.response !== "subscribe" && parsed.response !== "poke") {
console.log("[SSE] Received event:", JSON.stringify(parsed).substring(0, 500));
}
```
Remove the condition to see all events:
```javascript
console.log("[SSE] Received event:", JSON.stringify(parsed).substring(0, 500));
```
### Channel Nest Format
Format: `{type}/{host-ship}/{channel-name}`
Examples:
- `chat/~bitpyx-dildus/core`
- `chat/~malmur-halmex/v3aedb3s`
- `chat/~sitrul-nacwyl/tm-wayfinding-group-chat`
Parse with:
```javascript
const match = channelNest.match(/^([^/]+)\/([^/]+)\/(.+)$/);
const [, type, hostShip, channelName] = match;
```
### Auto-Discovery Endpoint
Query: `GET /~/scry/groups-ui/v6/init.json`
Response structure:
```json
{
"groups": {
"group-id": {
"channels": {
"chat/~host/name": { ... },
"diary/~host/name": { ... }
}
}
}
}
```
Filter for chat channels only:
```javascript
if (channelNest.startsWith("chat/")) {
channels.push(channelNest);
}
```
## Implementation Timeline
### Major Milestones
1. ✅ Plugin structure and registration
2. ✅ Authentication and cookie management
3. ✅ Channel creation and activation (helm-hi poke)
4. ✅ SSE stream connection
5. ✅ DM subscription and event parsing
6. ✅ Group channel support
7. ✅ Auto-discovery of channels
8. ✅ Per-conversation subscriptions
9. ✅ Text extraction (mentions and breaks)
10. ✅ Mention detection
11. ✅ Node.js polyfills (window.location)
12. ✅ Core module integration
13. ⏳ API authentication (user needs to configure)
### Key Discoveries
- **Helm-hi requirement:** Must send helm-hi poke before opening SSE stream
- **Subscription paths:** Frontend uses `/v3` globally, but individual `/dm/{ship}` and `/{channelNest}` paths work better
- **Event formats:** V3 API uses `essay` and `memo` structures (not older `writs` format)
- **Inline content:** Mixed array of strings and objects (mentions, breaks)
- **Tilde handling:** Ship mentions already include `~` prefix
- **Word boundaries:** `\b` regex doesn't work with `~` character
- **Browser globals:** axios and Slack SDK need window.location polyfill
- **Module resolution:** Need CLAWDBOT_ROOT for dynamic imports
## Resources
- **Tlon Apps GitHub:** https://github.com/tloncorp/tlon-apps
- **Urbit HTTP API:** @urbit/http-api package
- **Tlon Frontend Code:** `/tmp/tlon-apps/packages/shared/src/api/chatApi.ts`
- **Clawdbot Docs:** https://docs.clawd.bot/
- **Anthropic Provider:** https://docs.clawd.bot/providers/anthropic
## Future Enhancements
- [ ] Support for message reactions
- [ ] Support for message editing/deletion
- [ ] Support for attachments/images
- [ ] Typing indicators
- [ ] Read receipts
- [ ] Message threading
- [ ] Channel-specific bot personas
- [ ] Rate limiting
- [ ] Message queuing for offline ships
- [ ] Metrics and monitoring
## Credits
Built for integrating Clawdbot with Tlon messenger.
**Developer:** Claude (Sonnet 4.5)
**Platform:** Tlon Messenger built on Urbit