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>
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)
Ship: ~sitrul-nacwyl Test User: ~malmur-halmex
Architecture
Files
index.js- Plugin entry point, registers the Tlon channel adaptermonitor.js- Core monitoring logic, handles incoming messages and AI dispatchurbit-sse-client.js- Custom SSE client for Urbit HTTP APIcore-bridge.js- Dynamic loader for clawdbot core modulespackage.json- Plugin package definitionFALLBACK.md- AI model fallback system documentation
How It Works
- Authentication: Uses ship name + code to authenticate via
/~/loginendpoint - Channel Creation: Creates Tlon Messenger channel via PUT to
/~/channel/{uid} - Activation: Sends "helm-hi" poke to activate channel (required!)
- Subscriptions:
- DMs: Individual subscriptions to
/dm/{ship}for each conversation - Groups: Individual subscriptions to
/{channelNest}for each channel
- DMs: Individual subscriptions to
- SSE Stream: Opens server-sent events stream for real-time updates
- 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
- Auto-Discovery: Queries
/groups-ui/v6/init.jsonto find all available channels - Dynamic Refresh: Polls every 2 minutes for new conversations/channels
- Message Processing: When bot is mentioned, routes to AI via clawdbot core
- 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 for details
Configuration
1. Install Dependencies
cd ~/.clawdbot/extensions/tlon
npm install
2. Configure Credentials
Edit ~/.clawdbot/clawdbot.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
- When enabled, adds
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
restrictedmode using these ships
- New channels default to
authorization- Per-channel access control (optional)channelRules- Map of channel nest to authorization rulesmode:"open"(all ships) or"restricted"(allowedShips only)allowedShips: Array of authorized ships (only forrestrictedmode)
For localhost development:
"url": "http://localhost:8080"
For Tlon-hosted ships:
"url": "https://{ship-name}.tlon.network"
3. Set Environment Variable
The monitor needs to find clawdbot's core modules. Set the environment variable:
export CLAWDBOT_ROOT=/opt/homebrew/lib/node_modules/clawdbot
Or if clawdbot is installed elsewhere:
export CLAWDBOT_ROOT=$(dirname $(dirname $(readlink -f $(which clawdbot))))
Make it permanent (add to ~/.zshrc or ~/.bashrc):
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
clawdbot agents add main
# Select "Use Claude Code CLI credentials"
Option B: Use Anthropic API key
clawdbot agents add main
# Enter your API key from console.anthropic.com
5. Start the Gateway
CLAWDBOT_ROOT=/opt/homebrew/lib/node_modules/clawdbot clawdbot gateway
Or create a launch script:
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
- Send a DM from another ship to ~sitrul-nacwyl
- Mention the bot:
~sitrul-nacwyl hello there! - Bot should respond with AI-generated reply
Monitoring Logs
Check gateway logs:
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:
- On startup: Fetches changes from the last 5 days via
/groups-ui/v5/changes/~YYYY.M.D..20.19.51..9b9d.json - Periodic refresh: Checks for new channels every 2 minutes
- 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:
{
"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:
{
"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.5Claude Sonnet 4.5GPT-4oGPT-4 TurboGemini 2.0 Flash
When using the AI fallback system, 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
{
"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
allowedShipsneeded
restricted (default) - Only specific ships can invoke the bot
- Good for private/work channels
- Requires
allowedShipslist - New channels use
defaultAuthorizedShipsif no rule exists
Examples
Make a channel public:
"chat/~bitpyx-dildus/core": {
"mode": "open"
}
Restrict to specific users:
"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:
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
-
Login (POST
/~/login)- Sends
password={code} - Returns authentication cookie in
set-cookieheader
- Sends
-
Channel Creation (PUT
/~/channel/{channelId})- Channel ID format:
{timestamp}-{random} - Body: array of subscription objects
- Response: 204 No Content
- Channel ID format:
-
Channel Activation (PUT
/~/channel/{channelId})- Critical: Must send helm-hi poke BEFORE opening SSE stream
- Poke structure:
{ "id": timestamp, "action": "poke", "ship": "sitrul-nacwyl", "app": "hood", "mark": "helm-hi", "json": "Opening API channel" }
-
SSE Stream (GET
/~/channel/{channelId})- Headers:
Accept: text/event-stream - Returns Server-Sent Events
- Format:
id: {event-id} data: {json-payload}
- Headers:
Subscription Paths
DMs (Chat App)
- Path:
/dm/{ship} - App:
chat - Event Format:
{ "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:
{ "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:
{
"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):
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:
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}withapp: "chat" - Groups: Use
/{channelNest}withapp: "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:
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:
# 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:
kill $(pgrep -f "clawdbot gateway")
CLAWDBOT_ROOT=/opt/homebrew/lib/node_modules/clawdbot clawdbot gateway
Configuration options (in urbit-sse-client.js constructor):
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:
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:
// 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:
console.log("[SSE] Received event:", JSON.stringify(parsed).substring(0, 500));
Channel Nest Format
Format: {type}/{host-ship}/{channel-name}
Examples:
chat/~bitpyx-dildus/corechat/~malmur-halmex/v3aedb3schat/~sitrul-nacwyl/tm-wayfinding-group-chat
Parse with:
const match = channelNest.match(/^([^/]+)\/([^/]+)\/(.+)$/);
const [, type, hostShip, channelName] = match;
Auto-Discovery Endpoint
Query: GET /~/scry/groups-ui/v6/init.json
Response structure:
{
"groups": {
"group-id": {
"channels": {
"chat/~host/name": { ... },
"diary/~host/name": { ... }
}
}
}
}
Filter for chat channels only:
if (channelNest.startsWith("chat/")) {
channels.push(channelNest);
}
Implementation Timeline
Major Milestones
- ✅ Plugin structure and registration
- ✅ Authentication and cookie management
- ✅ Channel creation and activation (helm-hi poke)
- ✅ SSE stream connection
- ✅ DM subscription and event parsing
- ✅ Group channel support
- ✅ Auto-discovery of channels
- ✅ Per-conversation subscriptions
- ✅ Text extraction (mentions and breaks)
- ✅ Mention detection
- ✅ Node.js polyfills (window.location)
- ✅ Core module integration
- ⏳ API authentication (user needs to configure)
Key Discoveries
- Helm-hi requirement: Must send helm-hi poke before opening SSE stream
- Subscription paths: Frontend uses
/v3globally, but individual/dm/{ship}and/{channelNest}paths work better - Event formats: V3 API uses
essayandmemostructures (not olderwritsformat) - Inline content: Mixed array of strings and objects (mentions, breaks)
- Tilde handling: Ship mentions already include
~prefix - Word boundaries:
\bregex 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