Initial commit - LettaBot multi-channel AI assistant

Co-authored-by: Cameron Pfiffer <cameron@pfiffer.org>
Co-authored-by: Caren Thomas <carenthomas@gmail.com>
Co-authored-by: Charles Packer <packercharles@gmail.com>
Co-authored-by: Sarah Wooders <sarahwooders@gmail.com>
This commit is contained in:
Sarah Wooders
2026-01-28 18:02:51 -08:00
commit 22770e6e88
133 changed files with 21947 additions and 0 deletions

125
.env.example Normal file
View File

@@ -0,0 +1,125 @@
# ============================================
# LettaBot Configuration
# ============================================
# Letta API Key (from app.letta.com)
LETTA_API_KEY=your_letta_api_key
# Working directory for agent workspace
# WORKING_DIR=/tmp/lettabot
# Custom system prompt (optional)
# SYSTEM_PROMPT=You are a helpful assistant...
# Allowed tools (comma-separated)
# ALLOWED_TOOLS=Read,Glob,Grep,Task,web_search,conversation_search
# ============================================
# Telegram (required: at least one channel)
# ============================================
# Get token from @BotFather on Telegram
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
# DM access policy: pairing (default), allowlist, open
# - pairing: Unknown senders get a code, must be approved via CLI
# - allowlist: Only users in TELEGRAM_ALLOWED_USERS can message
# - open: Anyone can message
# TELEGRAM_DM_POLICY=pairing
# Restrict to specific Telegram user IDs (comma-separated)
# Used when dmPolicy is 'allowlist', or as pre-approved users for 'pairing'
# TELEGRAM_ALLOWED_USERS=123456789,987654321
# ============================================
# Slack (optional)
# ============================================
# Get tokens from api.slack.com/apps
# Bot Token: xoxb-... (OAuth & Permissions)
# App Token: xapp-... (Socket Mode, enable in app settings)
# SLACK_BOT_TOKEN=xoxb-your-bot-token
# SLACK_APP_TOKEN=xapp-your-app-token
# Restrict to specific Slack user IDs (e.g., U01234567)
# SLACK_ALLOWED_USERS=U01234567,U98765432
# ============================================
# WhatsApp (optional)
# ============================================
# Enable WhatsApp (will show QR code on first run)
# WHATSAPP_ENABLED=true
# ============================================
# Signal (optional)
# ============================================
# Requires signal-cli: brew install signal-cli
# Link with: signal-cli link -n "LettaBot" (scan QR in Signal app)
# Your Signal phone number (E.164 format)
# SIGNAL_PHONE_NUMBER=+15551234567
# Path to signal-cli binary (default: signal-cli)
# SIGNAL_CLI_PATH=signal-cli
# HTTP daemon settings (default: 127.0.0.1:8090)
# SIGNAL_HTTP_HOST=127.0.0.1
# SIGNAL_HTTP_PORT=8090
# DM access policy: pairing (default), allowlist, open
# - pairing: Unknown senders get a code, must be approved via CLI
# - allowlist: Only users in SIGNAL_ALLOWED_USERS can message
# - open: Anyone can message
# SIGNAL_DM_POLICY=pairing
# Restrict to specific phone numbers (used with allowlist, or pre-approved for pairing)
# SIGNAL_ALLOWED_USERS=+15559876543,+15551112222
# Enable/disable Note to Self messages (default: true)
# SIGNAL_SELF_CHAT_MODE=true
# ============================================
# Polling (system-level background checks)
# ============================================
# Polls every minute by default for new emails, etc.
# POLLING_INTERVAL_MS=60000
# Gmail - requires gog CLI
# Install: brew install steipete/tap/gogcli
# Setup: gog auth add you@gmail.com --services gmail
# GMAIL_ACCOUNT=you@gmail.com
# Session storage path
# WHATSAPP_SESSION_PATH=./data/whatsapp-session
# Restrict to specific phone numbers (with country code)
# WHATSAPP_ALLOWED_USERS=+15551234567,+15559876543
# ============================================
# Cron Jobs (optional)
# ============================================
# Enable scheduled tasks
# CRON_ENABLED=true
# ============================================
# Heartbeat (optional)
# ============================================
# Heartbeat interval in minutes (set to enable heartbeat)
# Agent checks HEARTBEAT.md for tasks. Responds HEARTBEAT_OK if nothing to do.
# HEARTBEAT_INTERVAL_MIN=30
# Delivery target (format: channel:chatId). Defaults to last messaged chat.
# HEARTBEAT_TARGET=telegram:123456789
# Custom heartbeat prompt (optional)
# HEARTBEAT_PROMPT=Read HEARTBEAT.md if it exists. If nothing needs attention, reply HEARTBEAT_OK.
# ============================================
# Gmail Integration (optional)
# ============================================
# GMAIL_ENABLED=true
# GMAIL_WEBHOOK_PORT=8788
# GMAIL_WEBHOOK_TOKEN=your_webhook_secret
# GMAIL_CLIENT_ID=your_client_id.apps.googleusercontent.com
# GMAIL_CLIENT_SECRET=your_client_secret
# GMAIL_REFRESH_TOKEN=your_refresh_token
# GMAIL_TELEGRAM_USER=123456789

13
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
allow:
- dependency-name: "@letta-ai/letta-code-sdk"
- dependency-name: "@letta-ai/letta-client"
labels:
- "dependencies"
commit-message:
prefix: "chore(deps):"

34
.github/workflows/letta.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: Letta Code
on:
issues:
types: [opened, labeled]
issue_comment:
types: [created]
pull_request:
types: [opened, labeled]
pull_request_review_comment:
types: [created]
jobs:
letta:
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: letta-ai/letta-code-action@v0
with:
letta_api_key: ${{ secrets.LETTA_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}
# Use my persistent agent ID for consistent memory
agent_id: agent-a7d61fda-62c3-44ae-90a0-c8359fae6e3d
# Default to opus model for best quality
model: opus
# Trigger phrase
trigger_phrase: "@letta-code"
# Label trigger
label_trigger: "letta-code"

43
.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# Dependencies
node_modules/
# Build output
dist/
# Environment files (contain secrets)
.env
.env.local
# Letta local data
.letta/
# User data
user-agents.json
# Reference repos (cloned for research)
*-reference/
# Test files
test-*.mjs
test-*.js
# OS files
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
# Runtime files
cron-log.jsonl
cron-jobs.json
lettabot-agent.json
PERSONA.md
# Related repos
moltbot/
letta-code-sdk/
# WhatsApp session (contains credentials)
data/whatsapp-session/

View File

@@ -0,0 +1,53 @@
---
name: 1password
description: Set up and use 1Password CLI (op). Use when installing the CLI, enabling desktop app integration, signing in (single or multi-account), or reading/injecting/running secrets via op.
homepage: https://developer.1password.com/docs/cli/get-started/
metadata: {"clawdbot":{"emoji":"🔐","requires":{"bins":["op"]},"install":[{"id":"brew","kind":"brew","formula":"1password-cli","bins":["op"],"label":"Install 1Password CLI (brew)"}]}}
---
# 1Password CLI
Follow the official CLI get-started steps. Don't guess install commands.
## References
- `references/get-started.md` (install + app integration + sign-in flow)
- `references/cli-examples.md` (real `op` examples)
## Workflow
1. Check OS + shell.
2. Verify CLI present: `op --version`.
3. Confirm desktop app integration is enabled (per get-started) and the app is unlocked.
4. REQUIRED: create a fresh tmux session for all `op` commands (no direct `op` calls outside tmux).
5. Sign in / authorize inside tmux: `op signin` (expect app prompt).
6. Verify access inside tmux: `op whoami` (must succeed before any secret read).
7. If multiple accounts: use `--account` or `OP_ACCOUNT`.
## REQUIRED tmux session (T-Max)
The shell tool uses a fresh TTY per command. To avoid re-prompts and failures, always run `op` inside a dedicated tmux session with a fresh socket/session name.
Example (see `tmux` skill for socket conventions, do not reuse old session names):
```bash
SOCKET_DIR="${CLAWDBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/clawdbot-tmux-sockets}"
mkdir -p "$SOCKET_DIR"
SOCKET="$SOCKET_DIR/clawdbot-op.sock"
SESSION="op-auth-$(date +%Y%m%d-%H%M%S)"
tmux -S "$SOCKET" new -d -s "$SESSION" -n shell
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op signin --account my.1password.com" Enter
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op whoami" Enter
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op vault list" Enter
tmux -S "$SOCKET" capture-pane -p -J -t "$SESSION":0.0 -S -200
tmux -S "$SOCKET" kill-session -t "$SESSION"
```
## Guardrails
- Never paste secrets into logs, chat, or code.
- Prefer `op run` / `op inject` over writing secrets to disk.
- If sign-in without app integration is needed, use `op account add`.
- If a command returns "account is not signed in", re-run `op signin` inside tmux and authorize in the app.
- Do not run `op` outside tmux; stop and ask if tmux is unavailable.

View File

@@ -0,0 +1,29 @@
# op CLI examples (from op help)
## Sign in
- `op signin`
- `op signin --account <shorthand|signin-address|account-id|user-id>`
## Read
- `op read op://app-prod/db/password`
- `op read "op://app-prod/db/one-time password?attribute=otp"`
- `op read "op://app-prod/ssh key/private key?ssh-format=openssh"`
- `op read --out-file ./key.pem op://app-prod/server/ssh/key.pem`
## Run
- `export DB_PASSWORD="op://app-prod/db/password"`
- `op run --no-masking -- printenv DB_PASSWORD`
- `op run --env-file="./.env" -- printenv DB_PASSWORD`
## Inject
- `echo "db_password: {{ op://app-prod/db/password }}" | op inject`
- `op inject -i config.yml.tpl -o config.yml`
## Whoami / accounts
- `op whoami`
- `op account list`

View File

@@ -0,0 +1,17 @@
# 1Password CLI get-started (summary)
- Works on macOS, Windows, and Linux.
- macOS/Linux shells: bash, zsh, sh, fish.
- Windows shell: PowerShell.
- Requires a 1Password subscription and the desktop app to use app integration.
- macOS requirement: Big Sur 11.0.0 or later.
- Linux app integration requires PolKit + an auth agent.
- Install the CLI per the official doc for your OS.
- Enable desktop app integration in the 1Password app:
- Open and unlock the app, then select your account/collection.
- macOS: Settings > Developer > Integrate with 1Password CLI (Touch ID optional).
- Windows: turn on Windows Hello, then Settings > Developer > Integrate.
- Linux: Settings > Security > Unlock using system authentication, then Settings > Developer > Integrate.
- After integration, run any command to sign in (example in docs: `op vault list`).
- If multiple accounts: use `op signin` to pick one, or `--account` / `OP_ACCOUNT`.
- For non-integration auth, use `op account add`.

View File

@@ -0,0 +1,50 @@
---
name: apple-notes
description: Manage Apple Notes via the `memo` CLI on macOS (create, view, edit, delete, search, move, and export notes). Use when a user asks Clawdbot to add a note, list notes, search notes, or manage note folders.
homepage: https://github.com/antoniorodr/memo
metadata: {"clawdbot":{"emoji":"📝","os":["darwin"],"requires":{"bins":["memo"]},"install":[{"id":"brew","kind":"brew","formula":"antoniorodr/memo/memo","bins":["memo"],"label":"Install memo via Homebrew"}]}}
---
# Apple Notes CLI
Use `memo notes` to manage Apple Notes directly from the terminal. Create, view, edit, delete, search, move notes between folders, and export to HTML/Markdown.
Setup
- Install (Homebrew): `brew tap antoniorodr/memo && brew install antoniorodr/memo/memo`
- Manual (pip): `pip install .` (after cloning the repo)
- macOS-only; if prompted, grant Automation access to Notes.app.
View Notes
- List all notes: `memo notes`
- Filter by folder: `memo notes -f "Folder Name"`
- Search notes (fuzzy): `memo notes -s "query"`
Create Notes
- Add a new note: `memo notes -a`
- Opens an interactive editor to compose the note.
- Quick add with title: `memo notes -a "Note Title"`
Edit Notes
- Edit existing note: `memo notes -e`
- Interactive selection of note to edit.
Delete Notes
- Delete a note: `memo notes -d`
- Interactive selection of note to delete.
Move Notes
- Move note to folder: `memo notes -m`
- Interactive selection of note and destination folder.
Export Notes
- Export to HTML/Markdown: `memo notes -ex`
- Exports selected note; uses Mistune for markdown processing.
Limitations
- Cannot edit notes containing images or attachments.
- Interactive prompts may require terminal access.
Notes
- macOS-only.
- Requires Apple Notes.app to be accessible.
- For automation, grant permissions in System Settings > Privacy & Security > Automation.

View File

@@ -0,0 +1,67 @@
---
name: apple-reminders
description: Manage Apple Reminders via the `remindctl` CLI on macOS (list, add, edit, complete, delete). Supports lists, date filters, and JSON/plain output.
homepage: https://github.com/steipete/remindctl
metadata: {"clawdbot":{"emoji":"⏰","os":["darwin"],"requires":{"bins":["remindctl"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/remindctl","bins":["remindctl"],"label":"Install remindctl via Homebrew"}]}}
---
# Apple Reminders CLI (remindctl)
Use `remindctl` to manage Apple Reminders directly from the terminal. It supports list filtering, date-based views, and scripting output.
Setup
- Install (Homebrew): `brew install steipete/tap/remindctl`
- From source: `pnpm install && pnpm build` (binary at `./bin/remindctl`)
- macOS-only; grant Reminders permission when prompted.
Permissions
- Check status: `remindctl status`
- Request access: `remindctl authorize`
View Reminders
- Default (today): `remindctl`
- Today: `remindctl today`
- Tomorrow: `remindctl tomorrow`
- Week: `remindctl week`
- Overdue: `remindctl overdue`
- Upcoming: `remindctl upcoming`
- Completed: `remindctl completed`
- All: `remindctl all`
- Specific date: `remindctl 2026-01-04`
Manage Lists
- List all lists: `remindctl list`
- Show list: `remindctl list Work`
- Create list: `remindctl list Projects --create`
- Rename list: `remindctl list Work --rename Office`
- Delete list: `remindctl list Work --delete`
Create Reminders
- Quick add: `remindctl add "Buy milk"`
- With list + due: `remindctl add --title "Call mom" --list Personal --due tomorrow`
Edit Reminders
- Edit title/due: `remindctl edit 1 --title "New title" --due 2026-01-04`
Complete Reminders
- Complete by id: `remindctl complete 1 2 3`
Delete Reminders
- Delete by id: `remindctl delete 4A83 --force`
Output Formats
- JSON (scripting): `remindctl today --json`
- Plain TSV: `remindctl today --plain`
- Counts only: `remindctl today --quiet`
Date Formats
Accepted by `--due` and date filters:
- `today`, `tomorrow`, `yesterday`
- `YYYY-MM-DD`
- `YYYY-MM-DD HH:mm`
- ISO 8601 (`2026-01-04T12:34:56Z`)
Notes
- macOS-only.
- If access is denied, enable Terminal/remindctl in System Settings → Privacy & Security → Reminders.
- If running over SSH, grant access on the Mac that runs the command.

View File

@@ -0,0 +1,79 @@
---
name: bear-notes
description: Create, search, and manage Bear notes via grizzly CLI.
homepage: https://bear.app
metadata: {"clawdbot":{"emoji":"🐻","os":["darwin"],"requires":{"bins":["grizzly"]},"install":[{"id":"go","kind":"go","module":"github.com/tylerwince/grizzly/cmd/grizzly@latest","bins":["grizzly"],"label":"Install grizzly (go)"}]}}
---
# Bear Notes
Use `grizzly` to create, read, and manage notes in Bear on macOS.
Requirements
- Bear app installed and running
- For some operations (add-text, tags, open-note --selected), a Bear app token (stored in `~/.config/grizzly/token`)
## Getting a Bear Token
For operations that require a token (add-text, tags, open-note --selected), you need an authentication token:
1. Open Bear → Help → API Token → Copy Token
2. Save it: `echo "YOUR_TOKEN" > ~/.config/grizzly/token`
## Common Commands
Create a note
```bash
echo "Note content here" | grizzly create --title "My Note" --tag work
grizzly create --title "Quick Note" --tag inbox < /dev/null
```
Open/read a note by ID
```bash
grizzly open-note --id "NOTE_ID" --enable-callback --json
```
Append text to a note
```bash
echo "Additional content" | grizzly add-text --id "NOTE_ID" --mode append --token-file ~/.config/grizzly/token
```
List all tags
```bash
grizzly tags --enable-callback --json --token-file ~/.config/grizzly/token
```
Search notes (via open-tag)
```bash
grizzly open-tag --name "work" --enable-callback --json
```
## Options
Common flags:
- `--dry-run` — Preview the URL without executing
- `--print-url` — Show the x-callback-url
- `--enable-callback` — Wait for Bear's response (needed for reading data)
- `--json` — Output as JSON (when using callbacks)
- `--token-file PATH` — Path to Bear API token file
## Configuration
Grizzly reads config from (in priority order):
1. CLI flags
2. Environment variables (`GRIZZLY_TOKEN_FILE`, `GRIZZLY_CALLBACK_URL`, `GRIZZLY_TIMEOUT`)
3. `.grizzly.toml` in current directory
4. `~/.config/grizzly/config.toml`
Example `~/.config/grizzly/config.toml`:
```toml
token_file = "~/.config/grizzly/token"
callback_url = "http://127.0.0.1:42123/success"
timeout = "5s"
```
## Notes
- Bear must be running for commands to work
- Note IDs are Bear's internal identifiers (visible in note info or via callbacks)
- Use `--enable-callback` when you need to read data back from Bear
- Some operations require a valid token (add-text, tags, open-note --selected)

197
.skills/bird/SKILL.md Normal file
View File

@@ -0,0 +1,197 @@
---
name: bird
description: X/Twitter CLI for reading, searching, posting, and engagement via cookies.
homepage: https://bird.fast
metadata: {"clawdbot":{"emoji":"🐦","requires":{"bins":["bird"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/bird","bins":["bird"],"label":"Install bird (brew)","os":["darwin"]},{"id":"npm","kind":"node","package":"@steipete/bird","bins":["bird"],"label":"Install bird (npm)"}]}}
---
# bird 🐦
Fast X/Twitter CLI using GraphQL + cookie auth.
## Install
```bash
# npm/pnpm/bun
npm install -g @steipete/bird
# Homebrew (macOS, prebuilt binary)
brew install steipete/tap/bird
# One-shot (no install)
bunx @steipete/bird whoami
```
## Authentication
`bird` uses cookie-based auth.
Use `--auth-token` / `--ct0` to pass cookies directly, or `--cookie-source` for browser cookies.
Run `bird check` to see which source is active. For Arc/Brave, use `--chrome-profile-dir <path>`.
## Commands
### Account & Auth
```bash
bird whoami # Show logged-in account
bird check # Show credential sources
bird query-ids --fresh # Refresh GraphQL query ID cache
```
### Reading Tweets
```bash
bird read <url-or-id> # Read a single tweet
bird <url-or-id> # Shorthand for read
bird thread <url-or-id> # Full conversation thread
bird replies <url-or-id> # List replies to a tweet
```
### Timelines
```bash
bird home # Home timeline (For You)
bird home --following # Following timeline
bird user-tweets @handle -n 20 # User's profile timeline
bird mentions # Tweets mentioning you
bird mentions --user @handle # Mentions of another user
```
### Search
```bash
bird search "query" -n 10
bird search "from:steipete" --all --max-pages 3
```
### News & Trending
```bash
bird news -n 10 # AI-curated from Explore tabs
bird news --ai-only # Filter to AI-curated only
bird news --sports # Sports tab
bird news --with-tweets # Include related tweets
bird trending # Alias for news
```
### Lists
```bash
bird lists # Your lists
bird lists --member-of # Lists you're a member of
bird list-timeline <id> -n 20 # Tweets from a list
```
### Bookmarks & Likes
```bash
bird bookmarks -n 10
bird bookmarks --folder-id <id> # Specific folder
bird bookmarks --include-parent # Include parent tweet
bird bookmarks --author-chain # Author's self-reply chain
bird bookmarks --full-chain-only # Full reply chain
bird unbookmark <url-or-id>
bird likes -n 10
```
### Social Graph
```bash
bird following -n 20 # Users you follow
bird followers -n 20 # Users following you
bird following --user <id> # Another user's following
bird about @handle # Account origin/location info
```
### Engagement Actions
```bash
bird follow @handle # Follow a user
bird unfollow @handle # Unfollow a user
```
### Posting
```bash
bird tweet "hello world"
bird reply <url-or-id> "nice thread!"
bird tweet "check this out" --media image.png --alt "description"
```
**⚠️ Posting risks**: Posting is more likely to be rate limited; if blocked, use the browser tool instead.
## Media Uploads
```bash
bird tweet "hi" --media img.png --alt "description"
bird tweet "pics" --media a.jpg --media b.jpg # Up to 4 images
bird tweet "video" --media clip.mp4 # Or 1 video
```
## Pagination
Commands supporting pagination: `replies`, `thread`, `search`, `bookmarks`, `likes`, `list-timeline`, `following`, `followers`, `user-tweets`
```bash
bird bookmarks --all # Fetch all pages
bird bookmarks --max-pages 3 # Limit pages
bird bookmarks --cursor <cursor> # Resume from cursor
bird replies <id> --all --delay 1000 # Delay between pages (ms)
```
## Output Options
```bash
--json # JSON output
--json-full # JSON with raw API response
--plain # No emoji, no color (script-friendly)
--no-emoji # Disable emoji
--no-color # Disable ANSI colors (or set NO_COLOR=1)
--quote-depth n # Max quoted tweet depth in JSON (default: 1)
```
## Global Options
```bash
--auth-token <token> # Set auth_token cookie
--ct0 <token> # Set ct0 cookie
--cookie-source <source> # Cookie source for browser cookies (repeatable)
--chrome-profile <name> # Chrome profile name
--chrome-profile-dir <path> # Chrome/Chromium profile dir or cookie DB path
--firefox-profile <name> # Firefox profile
--timeout <ms> # Request timeout
--cookie-timeout <ms> # Cookie extraction timeout
```
## Config File
`~/.config/bird/config.json5` (global) or `./.birdrc.json5` (project):
```json5
{
cookieSource: ["chrome"],
chromeProfileDir: "/path/to/Arc/Profile",
timeoutMs: 20000,
quoteDepth: 1
}
```
Environment variables: `BIRD_TIMEOUT_MS`, `BIRD_COOKIE_TIMEOUT_MS`, `BIRD_QUOTE_DEPTH`
## Troubleshooting
### Query IDs stale (404 errors)
```bash
bird query-ids --fresh
```
### Cookie extraction fails
- Check browser is logged into X
- Try different `--cookie-source`
- For Arc/Brave: use `--chrome-profile-dir`
---
**TL;DR**: Read/search/engage with CLI. Post carefully or use browser. 🐦

View File

@@ -0,0 +1,46 @@
---
name: blogwatcher
description: Monitor blogs and RSS/Atom feeds for updates using the blogwatcher CLI.
homepage: https://github.com/Hyaxia/blogwatcher
metadata: {"clawdbot":{"emoji":"📰","requires":{"bins":["blogwatcher"]},"install":[{"id":"go","kind":"go","module":"github.com/Hyaxia/blogwatcher/cmd/blogwatcher@latest","bins":["blogwatcher"],"label":"Install blogwatcher (go)"}]}}
---
# blogwatcher
Track blog and RSS/Atom feed updates with the `blogwatcher` CLI.
Install
- Go: `go install github.com/Hyaxia/blogwatcher/cmd/blogwatcher@latest`
Quick start
- `blogwatcher --help`
Common commands
- Add a blog: `blogwatcher add "My Blog" https://example.com`
- List blogs: `blogwatcher blogs`
- Scan for updates: `blogwatcher scan`
- List articles: `blogwatcher articles`
- Mark an article read: `blogwatcher read 1`
- Mark all articles read: `blogwatcher read-all`
- Remove a blog: `blogwatcher remove "My Blog"`
Example output
```
$ blogwatcher blogs
Tracked blogs (1):
xkcd
URL: https://xkcd.com
```
```
$ blogwatcher scan
Scanning 1 blog(s)...
xkcd
Source: RSS | Found: 4 | New: 4
Found 4 new article(s) total!
```
Notes
- Use `blogwatcher <command> --help` to discover flags and options.

27
.skills/blucli/SKILL.md Normal file
View File

@@ -0,0 +1,27 @@
---
name: blucli
description: BluOS CLI (blu) for discovery, playback, grouping, and volume.
homepage: https://blucli.sh
metadata: {"clawdbot":{"emoji":"🫐","requires":{"bins":["blu"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/blucli/cmd/blu@latest","bins":["blu"],"label":"Install blucli (go)"}]}}
---
# blucli (blu)
Use `blu` to control Bluesound/NAD players.
Quick start
- `blu devices` (pick target)
- `blu --device <id> status`
- `blu play|pause|stop`
- `blu volume set 15`
Target selection (in priority order)
- `--device <id|name|alias>`
- `BLU_DEVICE`
- config default (if set)
Common tasks
- Grouping: `blu group status|add|remove`
- TuneIn search/play: `blu tunein search "query"`, `blu tunein play "query"`
Prefer `--json` for scripts. Confirm the target device before changing playback.

25
.skills/camsnap/SKILL.md Normal file
View File

@@ -0,0 +1,25 @@
---
name: camsnap
description: Capture frames or clips from RTSP/ONVIF cameras.
homepage: https://camsnap.ai
metadata: {"clawdbot":{"emoji":"📸","requires":{"bins":["camsnap"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/camsnap","bins":["camsnap"],"label":"Install camsnap (brew)"}]}}
---
# camsnap
Use `camsnap` to grab snapshots, clips, or motion events from configured cameras.
Setup
- Config file: `~/.config/camsnap/config.yaml`
- Add camera: `camsnap add --name kitchen --host 192.168.0.10 --user user --pass pass`
Common commands
- Discover: `camsnap discover --info`
- Snapshot: `camsnap snap kitchen --out shot.jpg`
- Clip: `camsnap clip kitchen --dur 5s --out clip.mp4`
- Motion watch: `camsnap watch kitchen --threshold 0.2 --action '...'`
- Doctor: `camsnap doctor --probe`
Notes
- Requires `ffmpeg` on PATH.
- Prefer a short test capture before longer clips.

95
.skills/cron/SKILL.md Normal file
View File

@@ -0,0 +1,95 @@
---
name: cron
description: Create and manage scheduled tasks (cron jobs) that send you messages at specified times.
---
# Cron Jobs
Schedule tasks that send you messages at specified times. Jobs are scheduled immediately when created.
## Quick Reference
```bash
lettabot-cron list # List all jobs
lettabot-cron create [options] # Create job
lettabot-cron delete ID # Delete job
lettabot-cron enable ID # Enable job
lettabot-cron disable ID # Disable job
```
## Create a Job
```bash
lettabot-cron create \
--name "Morning Briefing" \
--schedule "0 8 * * *" \
--message "Good morning! Review tasks for today." \
--deliver telegram:123456789
```
**Options:**
- `-n, --name` - Job name (required)
- `-s, --schedule` - Cron expression (required)
- `-m, --message` - Message sent to you when job runs (required)
- `-d, --deliver` - Where to send response (format: `channel:chatId`). **Defaults to last messaged chat.**
- `--disabled` - Create disabled
## Message Format
When a cron job runs, you receive a message like:
```
[cron:cron-123abc Morning Briefing] Good morning! Review tasks for today.
Current time: 1/27/2026, 8:00:00 AM (America/Los_Angeles)
```
This tells you:
- The message came from a cron job (not a user)
- The job ID and name
- The current time
## Cron Schedule Syntax
```
┌───────── minute (0-59)
│ ┌─────── hour (0-23)
│ │ ┌───── day of month (1-31)
│ │ │ ┌─── month (1-12)
│ │ │ │ ┌─ day of week (0-6, Sun=0)
* * * * *
```
| Pattern | When |
|---------|------|
| `0 8 * * *` | Daily at 8:00 AM |
| `0 9 * * 1-5` | Weekdays at 9:00 AM |
| `0 */2 * * *` | Every 2 hours |
| `30 17 * * 5` | Fridays at 5:30 PM |
| `0 0 1 * *` | First of month at midnight |
## Examples
**Daily morning check-in (delivered to Telegram):**
```bash
lettabot-cron create \
-n "Morning" \
-s "0 8 * * *" \
-m "Good morning! What's on today's agenda?" \
-d telegram:123456789
```
**Weekly review (delivered to Slack):**
```bash
lettabot-cron create \
-n "Weekly Review" \
-s "0 17 * * 5" \
-m "Friday wrap-up: What did we accomplish?" \
-d slack:C1234567890
```
## Notes
- Jobs schedule immediately when created (no restart needed)
- Use `lettabot-cron list` to see next run times and last run status
- Jobs persist in `cron-jobs.json`
- Logs written to `cron-log.jsonl`

29
.skills/eightctl/SKILL.md Normal file
View File

@@ -0,0 +1,29 @@
---
name: eightctl
description: Control Eight Sleep pods (status, temperature, alarms, schedules).
homepage: https://eightctl.sh
metadata: {"clawdbot":{"emoji":"🎛️","requires":{"bins":["eightctl"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/eightctl/cmd/eightctl@latest","bins":["eightctl"],"label":"Install eightctl (go)"}]}}
---
# eightctl
Use `eightctl` for Eight Sleep pod control. Requires auth.
Auth
- Config: `~/.config/eightctl/config.yaml`
- Env: `EIGHTCTL_EMAIL`, `EIGHTCTL_PASSWORD`
Quick start
- `eightctl status`
- `eightctl on|off`
- `eightctl temp 20`
Common tasks
- Alarms: `eightctl alarm list|create|dismiss`
- Schedules: `eightctl schedule list|create|update`
- Audio: `eightctl audio state|play|pause`
- Base: `eightctl base info|angle`
Notes
- API is unofficial and rate-limited; avoid repeated logins.
- Confirm before changing temperature or alarms.

View File

@@ -0,0 +1,41 @@
---
name: food-order
description: "Reorder Foodora orders + track ETA/status with ordercli. Never confirm without explicit user approval. Triggers: order food, reorder, track ETA."
homepage: https://ordercli.sh
metadata: {"clawdbot":{"emoji":"🥡","requires":{"bins":["ordercli"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/ordercli/cmd/ordercli@latest","bins":["ordercli"],"label":"Install ordercli (go)"}]}}
---
# Food order (Foodora via ordercli)
Goal: reorder a previous Foodora order safely (preview first; confirm only on explicit user “yes/confirm/place the order”).
Hard safety rules
- Never run `ordercli foodora reorder ... --confirm` unless user explicitly confirms placing the order.
- Prefer preview-only steps first; show what will happen; ask for confirmation.
- If user is unsure: stop at preview and ask questions.
Setup (once)
- Country: `ordercli foodora countries``ordercli foodora config set --country AT`
- Login (password): `ordercli foodora login --email you@example.com --password-stdin`
- Login (no password, preferred): `ordercli foodora session chrome --url https://www.foodora.at/ --profile "Default"`
Find what to reorder
- Recent list: `ordercli foodora history --limit 10`
- Details: `ordercli foodora history show <orderCode>`
- If needed (machine-readable): `ordercli foodora history show <orderCode> --json`
Preview reorder (no cart changes)
- `ordercli foodora reorder <orderCode>`
Place reorder (cart change; explicit confirmation required)
- Confirm first, then run: `ordercli foodora reorder <orderCode> --confirm`
- Multiple addresses? Ask user for the right `--address-id` (take from their Foodora account / prior order data) and run:
- `ordercli foodora reorder <orderCode> --confirm --address-id <id>`
Track the order
- ETA/status (active list): `ordercli foodora orders`
- Live updates: `ordercli foodora orders --watch`
- Single order detail: `ordercli foodora order <orderCode>`
Debug / safe testing
- Use a throwaway config: `ordercli --config /tmp/ordercli.json ...`

23
.skills/gemini/SKILL.md Normal file
View File

@@ -0,0 +1,23 @@
---
name: gemini
description: Gemini CLI for one-shot Q&A, summaries, and generation.
homepage: https://ai.google.dev/
metadata: {"clawdbot":{"emoji":"♊️","requires":{"bins":["gemini"]},"install":[{"id":"brew","kind":"brew","formula":"gemini-cli","bins":["gemini"],"label":"Install Gemini CLI (brew)"}]}}
---
# Gemini CLI
Use Gemini in one-shot mode with a positional prompt (avoid interactive mode).
Quick start
- `gemini "Answer this question..."`
- `gemini --model <name> "Prompt..."`
- `gemini --output-format json "Return JSON"`
Extensions
- List: `gemini --list-extensions`
- Manage: `gemini extensions <command>`
Notes
- If auth is required, run `gemini` once interactively and follow the login flow.
- Avoid `--yolo` for safety.

47
.skills/gifgrep/SKILL.md Normal file
View File

@@ -0,0 +1,47 @@
---
name: gifgrep
description: Search GIF providers with CLI/TUI, download results, and extract stills/sheets.
homepage: https://gifgrep.com
metadata: {"clawdbot":{"emoji":"🧲","requires":{"bins":["gifgrep"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/gifgrep","bins":["gifgrep"],"label":"Install gifgrep (brew)"},{"id":"go","kind":"go","module":"github.com/steipete/gifgrep/cmd/gifgrep@latest","bins":["gifgrep"],"label":"Install gifgrep (go)"}]}}
---
# gifgrep
Use `gifgrep` to search GIF providers (Tenor/Giphy), browse in a TUI, download results, and extract stills or sheets.
GIF-Grab (gifgrep workflow)
- Search → preview → download → extract (still/sheet) for fast review and sharing.
Quick start
- `gifgrep cats --max 5`
- `gifgrep cats --format url | head -n 5`
- `gifgrep search --json cats | jq '.[0].url'`
- `gifgrep tui "office handshake"`
- `gifgrep cats --download --max 1 --format url`
TUI + previews
- TUI: `gifgrep tui "query"`
- CLI still previews: `--thumbs` (Kitty/Ghostty only; still frame)
Download + reveal
- `--download` saves to `~/Downloads`
- `--reveal` shows the last download in Finder
Stills + sheets
- `gifgrep still ./clip.gif --at 1.5s -o still.png`
- `gifgrep sheet ./clip.gif --frames 9 --cols 3 -o sheet.png`
- Sheets = single PNG grid of sampled frames (great for quick review, docs, PRs, chat).
- Tune: `--frames` (count), `--cols` (grid width), `--padding` (spacing).
Providers
- `--source auto|tenor|giphy`
- `GIPHY_API_KEY` required for `--source giphy`
- `TENOR_API_KEY` optional (Tenor demo key used if unset)
Output
- `--json` prints an array of results (`id`, `title`, `url`, `preview_url`, `tags`, `width`, `height`)
- `--format` for pipe-friendly fields (e.g., `url`)
Environment tweaks
- `GIFGREP_SOFTWARE_ANIM=1` to force software animation
- `GIFGREP_CELL_ASPECT=0.5` to tweak preview geometry

48
.skills/github/SKILL.md Normal file
View File

@@ -0,0 +1,48 @@
---
name: github
description: "Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries."
metadata: {"clawdbot":{"emoji":"🐙","requires":{"bins":["gh"]},"install":[{"id":"brew","kind":"brew","formula":"gh","bins":["gh"],"label":"Install GitHub CLI (brew)"},{"id":"apt","kind":"apt","package":"gh","bins":["gh"],"label":"Install GitHub CLI (apt)"}]}}
---
# GitHub Skill
Use the `gh` CLI to interact with GitHub. Always specify `--repo owner/repo` when not in a git directory, or use URLs directly.
## Pull Requests
Check CI status on a PR:
```bash
gh pr checks 55 --repo owner/repo
```
List recent workflow runs:
```bash
gh run list --repo owner/repo --limit 10
```
View a run and see which steps failed:
```bash
gh run view <run-id> --repo owner/repo
```
View logs for failed steps only:
```bash
gh run view <run-id> --repo owner/repo --log-failed
```
## API for Advanced Queries
The `gh api` command is useful for accessing data not available through other subcommands.
Get PR with specific fields:
```bash
gh api repos/owner/repo/pulls/55 --jq '.title, .state, .user.login'
```
## JSON Output
Most commands support `--json` for structured output. You can use `--jq` to filter:
```bash
gh issue list --repo owner/repo --json number,title --jq '.[] | "\(.number): \(.title)"'
```

92
.skills/gog/SKILL.md Normal file
View File

@@ -0,0 +1,92 @@
---
name: gog
description: Google Workspace CLI for Gmail, Calendar, Drive, Contacts, Sheets, and Docs.
homepage: https://gogcli.sh
metadata: {"clawdbot":{"emoji":"🎮","requires":{"bins":["gog"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/gogcli","bins":["gog"],"label":"Install gog (brew)"}]}}
---
# gog
Use `gog` for Gmail/Calendar/Drive/Contacts/Sheets/Docs. Requires OAuth setup.
Setup (once)
- `gog auth credentials /path/to/client_secret.json`
- `gog auth add you@gmail.com --services gmail,calendar,drive,contacts,docs,sheets`
- `gog auth list`
Common commands
- Gmail search: `gog gmail search 'newer_than:7d' --max 10`
- Gmail messages search (per email, ignores threading): `gog gmail messages search "in:inbox from:ryanair.com" --max 20 --account you@example.com`
- Gmail send (plain): `gog gmail send --to a@b.com --subject "Hi" --body "Hello"`
- Gmail send (multi-line): `gog gmail send --to a@b.com --subject "Hi" --body-file ./message.txt`
- Gmail send (stdin): `gog gmail send --to a@b.com --subject "Hi" --body-file -`
- Gmail send (HTML): `gog gmail send --to a@b.com --subject "Hi" --body-html "<p>Hello</p>"`
- Gmail draft: `gog gmail drafts create --to a@b.com --subject "Hi" --body-file ./message.txt`
- Gmail send draft: `gog gmail drafts send <draftId>`
- Gmail reply: `gog gmail send --to a@b.com --subject "Re: Hi" --body "Reply" --reply-to-message-id <msgId>`
- Calendar list events: `gog calendar events <calendarId> --from <iso> --to <iso>`
- Calendar create event: `gog calendar create <calendarId> --summary "Title" --from <iso> --to <iso>`
- Calendar create with color: `gog calendar create <calendarId> --summary "Title" --from <iso> --to <iso> --event-color 7`
- Calendar update event: `gog calendar update <calendarId> <eventId> --summary "New Title" --event-color 4`
- Calendar show colors: `gog calendar colors`
- Drive search: `gog drive search "query" --max 10`
- Contacts: `gog contacts list --max 20`
- Sheets get: `gog sheets get <sheetId> "Tab!A1:D10" --json`
- Sheets update: `gog sheets update <sheetId> "Tab!A1:B2" --values-json '[["A","B"],["1","2"]]' --input USER_ENTERED`
- Sheets append: `gog sheets append <sheetId> "Tab!A:C" --values-json '[["x","y","z"]]' --insert INSERT_ROWS`
- Sheets clear: `gog sheets clear <sheetId> "Tab!A2:Z"`
- Sheets metadata: `gog sheets metadata <sheetId> --json`
- Docs export: `gog docs export <docId> --format txt --out /tmp/doc.txt`
- Docs cat: `gog docs cat <docId>`
Calendar Colors
- Use `gog calendar colors` to see all available event colors (IDs 1-11)
- Add colors to events with `--event-color <id>` flag
- Event color IDs (from `gog calendar colors` output):
- 1: #a4bdfc
- 2: #7ae7bf
- 3: #dbadff
- 4: #ff887c
- 5: #fbd75b
- 6: #ffb878
- 7: #46d6db
- 8: #e1e1e1
- 9: #5484ed
- 10: #51b749
- 11: #dc2127
Email Formatting
- Prefer plain text. Use `--body-file` for multi-paragraph messages (or `--body-file -` for stdin).
- Same `--body-file` pattern works for drafts and replies.
- `--body` does not unescape `\n`. If you need inline newlines, use a heredoc or `$'Line 1\n\nLine 2'`.
- Use `--body-html` only when you need rich formatting.
- HTML tags: `<p>` for paragraphs, `<br>` for line breaks, `<strong>` for bold, `<em>` for italic, `<a href="url">` for links, `<ul>`/`<li>` for lists.
- Example (plain text via stdin):
```bash
gog gmail send --to recipient@example.com \
--subject "Meeting Follow-up" \
--body-file - <<'EOF'
Hi Name,
Thanks for meeting today. Next steps:
- Item one
- Item two
Best regards,
Your Name
EOF
```
- Example (HTML list):
```bash
gog gmail send --to recipient@example.com \
--subject "Meeting Follow-up" \
--body-html "<p>Hi Name,</p><p>Thanks for meeting today. Here are the next steps:</p><ul><li>Item one</li><li>Item two</li></ul><p>Best regards,<br>Your Name</p>"
```
Notes
- Set `GOG_ACCOUNT=you@gmail.com` to avoid repeating `--account`.
- For scripting, prefer `--json` plus `--no-input`.
- Sheets values can be passed via `--values-json` (recommended) or as inline rows.
- Docs supports export/cat/copy. In-place edits require a Docs API client (not in gog).
- Confirm before sending mail or creating events.
- `gog gmail search` returns one row per thread; use `gog gmail messages search` when you need every individual email returned separately.

128
.skills/google/SKILL.md Normal file
View File

@@ -0,0 +1,128 @@
---
name: google
description: Google Workspace CLI (gog) for Gmail, Calendar, Drive, Contacts, Sheets, and Docs.
---
# Google Workspace (gog)
Use `gog` CLI to interact with Google Workspace services.
## Setup
```bash
brew install steipete/tap/gogcli
gog auth credentials /path/to/credentials.json
gog auth add you@gmail.com --services gmail,calendar,drive,contacts,docs,sheets
gog auth list
```
## Gmail
```bash
# Search emails
gog gmail search 'newer_than:1h is:unread' --account EMAIL --max 10
gog gmail search 'from:someone@example.com' --account EMAIL --max 10
# Read email
gog gmail get MESSAGE_ID --account EMAIL
# Send email
gog gmail send --to recipient@example.com --subject "Subject" --body "Message" --account EMAIL
# Reply to thread
gog gmail send --to recipient@example.com --subject "Re: Original" --body "Reply" --reply-to-message-id MSG_ID --account EMAIL
# Create/send draft
gog gmail drafts create --to recipient@example.com --subject "Subject" --body "Draft" --account EMAIL
gog gmail drafts send DRAFT_ID --account EMAIL
# Manage labels
gog gmail labels --account EMAIL
gog gmail modify MESSAGE_ID --add-labels LABEL --account EMAIL
gog gmail modify MESSAGE_ID --remove-labels UNREAD --account EMAIL
```
## Calendar
```bash
# List events
gog calendar events CALENDAR_ID --from 2026-01-27T00:00:00Z --to 2026-01-28T00:00:00Z --account EMAIL
# Create event
gog calendar create CALENDAR_ID --summary "Meeting" --from 2026-01-27T10:00:00Z --to 2026-01-27T11:00:00Z --account EMAIL
# Create with color (1-11)
gog calendar create CALENDAR_ID --summary "Meeting" --from ISO --to ISO --event-color 7 --account EMAIL
# Update event
gog calendar update CALENDAR_ID EVENT_ID --summary "New Title" --account EMAIL
# Show available colors
gog calendar colors
```
## Drive
```bash
# Search files
gog drive search "query" --max 10 --account EMAIL
# List files in folder
gog drive list FOLDER_ID --account EMAIL
# Download file
gog drive download FILE_ID --out /path/to/file --account EMAIL
# Upload file
gog drive upload /path/to/file --parent FOLDER_ID --account EMAIL
```
## Contacts
```bash
# List contacts
gog contacts list --max 20 --account EMAIL
# Search contacts
gog contacts search "name" --account EMAIL
```
## Sheets
```bash
# Read range
gog sheets get SHEET_ID "Sheet1!A1:D10" --json --account EMAIL
# Update cells
gog sheets update SHEET_ID "Sheet1!A1:B2" --values-json '[["A","B"],["1","2"]]' --input USER_ENTERED --account EMAIL
# Append rows
gog sheets append SHEET_ID "Sheet1!A:C" --values-json '[["x","y","z"]]' --insert INSERT_ROWS --account EMAIL
# Clear range
gog sheets clear SHEET_ID "Sheet1!A2:Z" --account EMAIL
# Get metadata
gog sheets metadata SHEET_ID --json --account EMAIL
```
## Docs
```bash
# Read document
gog docs cat DOC_ID --account EMAIL
# Export to file
gog docs export DOC_ID --format txt --out /tmp/doc.txt --account EMAIL
```
## Environment
Set default account in `.env`:
```bash
GMAIL_ACCOUNT=you@gmail.com
```
## Email Polling
Emails are polled every 1 minute via cron. Use `ignore()` if nothing important.

30
.skills/goplaces/SKILL.md Normal file
View File

@@ -0,0 +1,30 @@
---
name: goplaces
description: Query Google Places API (New) via the goplaces CLI for text search, place details, resolve, and reviews. Use for human-friendly place lookup or JSON output for scripts.
homepage: https://github.com/steipete/goplaces
metadata: {"clawdbot":{"emoji":"📍","requires":{"bins":["goplaces"],"env":["GOOGLE_PLACES_API_KEY"]},"primaryEnv":"GOOGLE_PLACES_API_KEY","install":[{"id":"brew","kind":"brew","formula":"steipete/tap/goplaces","bins":["goplaces"],"label":"Install goplaces (brew)"}]}}
---
# goplaces
Modern Google Places API (New) CLI. Human output by default, `--json` for scripts.
Install
- Homebrew: `brew install steipete/tap/goplaces`
Config
- `GOOGLE_PLACES_API_KEY` required.
- Optional: `GOOGLE_PLACES_BASE_URL` for testing/proxying.
Common commands
- Search: `goplaces search "coffee" --open-now --min-rating 4 --limit 5`
- Bias: `goplaces search "pizza" --lat 40.8 --lng -73.9 --radius-m 3000`
- Pagination: `goplaces search "pizza" --page-token "NEXT_PAGE_TOKEN"`
- Resolve: `goplaces resolve "Soho, London" --limit 5`
- Details: `goplaces details <place_id> --reviews`
- JSON: `goplaces search "sushi" --json`
Notes
- `--no-color` or `NO_COLOR` disables ANSI color.
- Price levels: 0..4 (free → very expensive).
- Type filter sends only the first `--type` value (API accepts one).

217
.skills/himalaya/SKILL.md Normal file
View File

@@ -0,0 +1,217 @@
---
name: himalaya
description: "CLI to manage emails via IMAP/SMTP. Use `himalaya` to list, read, write, reply, forward, search, and organize emails from the terminal. Supports multiple accounts and message composition with MML (MIME Meta Language)."
homepage: https://github.com/pimalaya/himalaya
metadata: {"clawdbot":{"emoji":"📧","requires":{"bins":["himalaya"]},"install":[{"id":"brew","kind":"brew","formula":"himalaya","bins":["himalaya"],"label":"Install Himalaya (brew)"}]}}
---
# Himalaya Email CLI
Himalaya is a CLI email client that lets you manage emails from the terminal using IMAP, SMTP, Notmuch, or Sendmail backends.
## References
- `references/configuration.md` (config file setup + IMAP/SMTP authentication)
- `references/message-composition.md` (MML syntax for composing emails)
## Prerequisites
1. Himalaya CLI installed (`himalaya --version` to verify)
2. A configuration file at `~/.config/himalaya/config.toml`
3. IMAP/SMTP credentials configured (password stored securely)
## Configuration Setup
Run the interactive wizard to set up an account:
```bash
himalaya account configure
```
Or create `~/.config/himalaya/config.toml` manually:
```toml
[accounts.personal]
email = "you@example.com"
display-name = "Your Name"
default = true
backend.type = "imap"
backend.host = "imap.example.com"
backend.port = 993
backend.encryption.type = "tls"
backend.login = "you@example.com"
backend.auth.type = "password"
backend.auth.cmd = "pass show email/imap" # or use keyring
message.send.backend.type = "smtp"
message.send.backend.host = "smtp.example.com"
message.send.backend.port = 587
message.send.backend.encryption.type = "start-tls"
message.send.backend.login = "you@example.com"
message.send.backend.auth.type = "password"
message.send.backend.auth.cmd = "pass show email/smtp"
```
## Common Operations
### List Folders
```bash
himalaya folder list
```
### List Emails
List emails in INBOX (default):
```bash
himalaya envelope list
```
List emails in a specific folder:
```bash
himalaya envelope list --folder "Sent"
```
List with pagination:
```bash
himalaya envelope list --page 1 --page-size 20
```
### Search Emails
```bash
himalaya envelope list from john@example.com subject meeting
```
### Read an Email
Read email by ID (shows plain text):
```bash
himalaya message read 42
```
Export raw MIME:
```bash
himalaya message export 42 --full
```
### Reply to an Email
Interactive reply (opens $EDITOR):
```bash
himalaya message reply 42
```
Reply-all:
```bash
himalaya message reply 42 --all
```
### Forward an Email
```bash
himalaya message forward 42
```
### Write a New Email
Interactive compose (opens $EDITOR):
```bash
himalaya message write
```
Send directly using template:
```bash
cat << 'EOF' | himalaya template send
From: you@example.com
To: recipient@example.com
Subject: Test Message
Hello from Himalaya!
EOF
```
Or with headers flag:
```bash
himalaya message write -H "To:recipient@example.com" -H "Subject:Test" "Message body here"
```
### Move/Copy Emails
Move to folder:
```bash
himalaya message move 42 "Archive"
```
Copy to folder:
```bash
himalaya message copy 42 "Important"
```
### Delete an Email
```bash
himalaya message delete 42
```
### Manage Flags
Add flag:
```bash
himalaya flag add 42 --flag seen
```
Remove flag:
```bash
himalaya flag remove 42 --flag seen
```
## Multiple Accounts
List accounts:
```bash
himalaya account list
```
Use a specific account:
```bash
himalaya --account work envelope list
```
## Attachments
Save attachments from a message:
```bash
himalaya attachment download 42
```
Save to specific directory:
```bash
himalaya attachment download 42 --dir ~/Downloads
```
## Output Formats
Most commands support `--output` for structured output:
```bash
himalaya envelope list --output json
himalaya envelope list --output plain
```
## Debugging
Enable debug logging:
```bash
RUST_LOG=debug himalaya envelope list
```
Full trace with backtrace:
```bash
RUST_LOG=trace RUST_BACKTRACE=1 himalaya envelope list
```
## Tips
- Use `himalaya --help` or `himalaya <command> --help` for detailed usage.
- Message IDs are relative to the current folder; re-list after folder changes.
- For composing rich emails with attachments, use MML syntax (see `references/message-composition.md`).
- Store passwords securely using `pass`, system keyring, or a command that outputs the password.

View File

@@ -0,0 +1,174 @@
# Himalaya Configuration Reference
Configuration file location: `~/.config/himalaya/config.toml`
## Minimal IMAP + SMTP Setup
```toml
[accounts.default]
email = "user@example.com"
display-name = "Your Name"
default = true
# IMAP backend for reading emails
backend.type = "imap"
backend.host = "imap.example.com"
backend.port = 993
backend.encryption.type = "tls"
backend.login = "user@example.com"
backend.auth.type = "password"
backend.auth.raw = "your-password"
# SMTP backend for sending emails
message.send.backend.type = "smtp"
message.send.backend.host = "smtp.example.com"
message.send.backend.port = 587
message.send.backend.encryption.type = "start-tls"
message.send.backend.login = "user@example.com"
message.send.backend.auth.type = "password"
message.send.backend.auth.raw = "your-password"
```
## Password Options
### Raw password (testing only, not recommended)
```toml
backend.auth.raw = "your-password"
```
### Password from command (recommended)
```toml
backend.auth.cmd = "pass show email/imap"
# backend.auth.cmd = "security find-generic-password -a user@example.com -s imap -w"
```
### System keyring (requires keyring feature)
```toml
backend.auth.keyring = "imap-example"
```
Then run `himalaya account configure <account>` to store the password.
## Gmail Configuration
```toml
[accounts.gmail]
email = "you@gmail.com"
display-name = "Your Name"
default = true
backend.type = "imap"
backend.host = "imap.gmail.com"
backend.port = 993
backend.encryption.type = "tls"
backend.login = "you@gmail.com"
backend.auth.type = "password"
backend.auth.cmd = "pass show google/app-password"
message.send.backend.type = "smtp"
message.send.backend.host = "smtp.gmail.com"
message.send.backend.port = 587
message.send.backend.encryption.type = "start-tls"
message.send.backend.login = "you@gmail.com"
message.send.backend.auth.type = "password"
message.send.backend.auth.cmd = "pass show google/app-password"
```
**Note:** Gmail requires an App Password if 2FA is enabled.
## iCloud Configuration
```toml
[accounts.icloud]
email = "you@icloud.com"
display-name = "Your Name"
backend.type = "imap"
backend.host = "imap.mail.me.com"
backend.port = 993
backend.encryption.type = "tls"
backend.login = "you@icloud.com"
backend.auth.type = "password"
backend.auth.cmd = "pass show icloud/app-password"
message.send.backend.type = "smtp"
message.send.backend.host = "smtp.mail.me.com"
message.send.backend.port = 587
message.send.backend.encryption.type = "start-tls"
message.send.backend.login = "you@icloud.com"
message.send.backend.auth.type = "password"
message.send.backend.auth.cmd = "pass show icloud/app-password"
```
**Note:** Generate an app-specific password at appleid.apple.com
## Folder Aliases
Map custom folder names:
```toml
[accounts.default.folder.alias]
inbox = "INBOX"
sent = "Sent"
drafts = "Drafts"
trash = "Trash"
```
## Multiple Accounts
```toml
[accounts.personal]
email = "personal@example.com"
default = true
# ... backend config ...
[accounts.work]
email = "work@company.com"
# ... backend config ...
```
Switch accounts with `--account`:
```bash
himalaya --account work envelope list
```
## Notmuch Backend (local mail)
```toml
[accounts.local]
email = "user@example.com"
backend.type = "notmuch"
backend.db-path = "~/.mail/.notmuch"
```
## OAuth2 Authentication (for providers that support it)
```toml
backend.auth.type = "oauth2"
backend.auth.client-id = "your-client-id"
backend.auth.client-secret.cmd = "pass show oauth/client-secret"
backend.auth.access-token.cmd = "pass show oauth/access-token"
backend.auth.refresh-token.cmd = "pass show oauth/refresh-token"
backend.auth.auth-url = "https://provider.com/oauth/authorize"
backend.auth.token-url = "https://provider.com/oauth/token"
```
## Additional Options
### Signature
```toml
[accounts.default]
signature = "Best regards,\nYour Name"
signature-delim = "-- \n"
```
### Downloads directory
```toml
[accounts.default]
downloads-dir = "~/Downloads/himalaya"
```
### Editor for composing
Set via environment variable:
```bash
export EDITOR="vim"
```

View File

@@ -0,0 +1,182 @@
# Message Composition with MML (MIME Meta Language)
Himalaya uses MML for composing emails. MML is a simple XML-based syntax that compiles to MIME messages.
## Basic Message Structure
An email message is a list of **headers** followed by a **body**, separated by a blank line:
```
From: sender@example.com
To: recipient@example.com
Subject: Hello World
This is the message body.
```
## Headers
Common headers:
- `From`: Sender address
- `To`: Primary recipient(s)
- `Cc`: Carbon copy recipients
- `Bcc`: Blind carbon copy recipients
- `Subject`: Message subject
- `Reply-To`: Address for replies (if different from From)
- `In-Reply-To`: Message ID being replied to
### Address Formats
```
To: user@example.com
To: John Doe <john@example.com>
To: "John Doe" <john@example.com>
To: user1@example.com, user2@example.com, "Jane" <jane@example.com>
```
## Plain Text Body
Simple plain text email:
```
From: alice@localhost
To: bob@localhost
Subject: Plain Text Example
Hello, this is a plain text email.
No special formatting needed.
Best,
Alice
```
## MML for Rich Emails
### Multipart Messages
Alternative text/html parts:
```
From: alice@localhost
To: bob@localhost
Subject: Multipart Example
<#multipart type=alternative>
This is the plain text version.
<#part type=text/html>
<html><body><h1>This is the HTML version</h1></body></html>
<#/multipart>
```
### Attachments
Attach a file:
```
From: alice@localhost
To: bob@localhost
Subject: With Attachment
Here is the document you requested.
<#part filename=/path/to/document.pdf><#/part>
```
Attachment with custom name:
```
<#part filename=/path/to/file.pdf name=report.pdf><#/part>
```
Multiple attachments:
```
<#part filename=/path/to/doc1.pdf><#/part>
<#part filename=/path/to/doc2.pdf><#/part>
```
### Inline Images
Embed an image inline:
```
From: alice@localhost
To: bob@localhost
Subject: Inline Image
<#multipart type=related>
<#part type=text/html>
<html><body>
<p>Check out this image:</p>
<img src="cid:image1">
</body></html>
<#part disposition=inline id=image1 filename=/path/to/image.png><#/part>
<#/multipart>
```
### Mixed Content (Text + Attachments)
```
From: alice@localhost
To: bob@localhost
Subject: Mixed Content
<#multipart type=mixed>
<#part type=text/plain>
Please find the attached files.
Best,
Alice
<#part filename=/path/to/file1.pdf><#/part>
<#part filename=/path/to/file2.zip><#/part>
<#/multipart>
```
## MML Tag Reference
### `<#multipart>`
Groups multiple parts together.
- `type=alternative`: Different representations of same content
- `type=mixed`: Independent parts (text + attachments)
- `type=related`: Parts that reference each other (HTML + images)
### `<#part>`
Defines a message part.
- `type=<mime-type>`: Content type (e.g., `text/html`, `application/pdf`)
- `filename=<path>`: File to attach
- `name=<name>`: Display name for attachment
- `disposition=inline`: Display inline instead of as attachment
- `id=<cid>`: Content ID for referencing in HTML
## Composing from CLI
### Interactive compose
Opens your `$EDITOR`:
```bash
himalaya message write
```
### Reply (opens editor with quoted message)
```bash
himalaya message reply 42
himalaya message reply 42 --all # reply-all
```
### Forward
```bash
himalaya message forward 42
```
### Send from stdin
```bash
cat message.txt | himalaya template send
```
### Prefill headers from CLI
```bash
himalaya message write \
-H "To:recipient@example.com" \
-H "Subject:Quick Message" \
"Message body here"
```
## Tips
- The editor opens with a template; fill in headers and body.
- Save and exit the editor to send; exit without saving to cancel.
- MML parts are compiled to proper MIME when sending.
- Use `himalaya message export --full` to inspect the raw MIME structure of received emails.

25
.skills/imsg/SKILL.md Normal file
View File

@@ -0,0 +1,25 @@
---
name: imsg
description: iMessage/SMS CLI for listing chats, history, watch, and sending.
homepage: https://imsg.to
metadata: {"clawdbot":{"emoji":"📨","os":["darwin"],"requires":{"bins":["imsg"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/imsg","bins":["imsg"],"label":"Install imsg (brew)"}]}}
---
# imsg
Use `imsg` to read and send Messages.app iMessage/SMS on macOS.
Requirements
- Messages.app signed in
- Full Disk Access for your terminal
- Automation permission to control Messages.app (for sending)
Common commands
- List chats: `imsg chats --limit 10 --json`
- History: `imsg history --chat-id 1 --limit 20 --attachments --json`
- Watch: `imsg watch --chat-id 1 --attachments`
- Send: `imsg send --to "+14155551212" --text "hi" --file /path/pic.jpg`
Notes
- `--service imessage|sms|auto` controls delivery.
- Confirm recipient + message before sending.

View File

@@ -0,0 +1,101 @@
# Local Places
This repo is a fusion of two pieces:
- A FastAPI server that exposes endpoints for searching and resolving places via the Google Maps Places API.
- A companion agent skill that explains how to use the API and can call it to find places efficiently.
Together, the skill and server let an agent turn natural-language place queries into structured results quickly.
## Run locally
```bash
# copy skill definition into the relevant folder (where the agent looks for it)
# then run the server
uv venv
uv pip install -e ".[dev]"
uv run --env-file .env uvicorn local_places.main:app --host 0.0.0.0 --reload
```
Open the API docs at http://127.0.0.1:8000/docs.
## Places API
Set the Google Places API key before running:
```bash
export GOOGLE_PLACES_API_KEY="your-key"
```
Endpoints:
- `POST /places/search` (free-text query + filters)
- `GET /places/{place_id}` (place details)
- `POST /locations/resolve` (resolve a user-provided location string)
Example search request:
```json
{
"query": "italian restaurant",
"filters": {
"types": ["restaurant"],
"open_now": true,
"min_rating": 4.0,
"price_levels": [1, 2]
},
"limit": 10
}
```
Notes:
- `filters.types` supports a single type (mapped to Google `includedType`).
Example search request (curl):
```bash
curl -X POST http://127.0.0.1:8000/places/search \
-H "Content-Type: application/json" \
-d '{
"query": "italian restaurant",
"location_bias": {
"lat": 40.8065,
"lng": -73.9719,
"radius_m": 3000
},
"filters": {
"types": ["restaurant"],
"open_now": true,
"min_rating": 4.0,
"price_levels": [1, 2, 3]
},
"limit": 10
}'
```
Example resolve request (curl):
```bash
curl -X POST http://127.0.0.1:8000/locations/resolve \
-H "Content-Type: application/json" \
-d '{
"location_text": "Riverside Park, New York",
"limit": 5
}'
```
## Test
```bash
uv run pytest
```
## OpenAPI
Generate the OpenAPI schema:
```bash
uv run python scripts/generate_openapi.py
```

View File

@@ -0,0 +1,91 @@
---
name: local-places
description: Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
homepage: https://github.com/Hyaxia/local_places
metadata: {"clawdbot":{"emoji":"📍","requires":{"bins":["uv"],"env":["GOOGLE_PLACES_API_KEY"]},"primaryEnv":"GOOGLE_PLACES_API_KEY"}}
---
# 📍 Local Places
*Find places, Go fast*
Search for nearby places using a local Google Places API proxy. Two-step flow: resolve location first, then search.
## Setup
```bash
cd {baseDir}
echo "GOOGLE_PLACES_API_KEY=your-key" > .env
uv venv && uv pip install -e ".[dev]"
uv run --env-file .env uvicorn local_places.main:app --host 127.0.0.1 --port 8000
```
Requires `GOOGLE_PLACES_API_KEY` in `.env` or environment.
## Quick Start
1. **Check server:** `curl http://127.0.0.1:8000/ping`
2. **Resolve location:**
```bash
curl -X POST http://127.0.0.1:8000/locations/resolve \
-H "Content-Type: application/json" \
-d '{"location_text": "Soho, London", "limit": 5}'
```
3. **Search places:**
```bash
curl -X POST http://127.0.0.1:8000/places/search \
-H "Content-Type: application/json" \
-d '{
"query": "coffee shop",
"location_bias": {"lat": 51.5137, "lng": -0.1366, "radius_m": 1000},
"filters": {"open_now": true, "min_rating": 4.0},
"limit": 10
}'
```
4. **Get details:**
```bash
curl http://127.0.0.1:8000/places/{place_id}
```
## Conversation Flow
1. If user says "near me" or gives vague location → resolve it first
2. If multiple results → show numbered list, ask user to pick
3. Ask for preferences: type, open now, rating, price level
4. Search with `location_bias` from chosen location
5. Present results with name, rating, address, open status
6. Offer to fetch details or refine search
## Filter Constraints
- `filters.types`: exactly ONE type (e.g., "restaurant", "cafe", "gym")
- `filters.price_levels`: integers 0-4 (0=free, 4=very expensive)
- `filters.min_rating`: 0-5 in 0.5 increments
- `filters.open_now`: boolean
- `limit`: 1-20 for search, 1-10 for resolve
- `location_bias.radius_m`: must be > 0
## Response Format
```json
{
"results": [
{
"place_id": "ChIJ...",
"name": "Coffee Shop",
"address": "123 Main St",
"location": {"lat": 51.5, "lng": -0.1},
"rating": 4.6,
"price_level": 2,
"types": ["cafe", "food"],
"open_now": true
}
],
"next_page_token": "..."
}
```
Use `next_page_token` as `page_token` in next request for more results.

View File

@@ -0,0 +1,27 @@
[project]
name = "my-api"
version = "0.1.0"
description = "FastAPI server"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.110.0",
"httpx>=0.27.0",
"uvicorn[standard]>=0.29.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/local_places"]
[tool.pytest.ini_options]
addopts = "-q"
testpaths = ["tests"]

View File

@@ -0,0 +1,2 @@
__all__ = ["__version__"]
__version__ = "0.1.0"

View File

@@ -0,0 +1,314 @@
from __future__ import annotations
import logging
import os
from typing import Any
import httpx
from fastapi import HTTPException
from local_places.schemas import (
LatLng,
LocationResolveRequest,
LocationResolveResponse,
PlaceDetails,
PlaceSummary,
ResolvedLocation,
SearchRequest,
SearchResponse,
)
GOOGLE_PLACES_BASE_URL = os.getenv(
"GOOGLE_PLACES_BASE_URL", "https://places.googleapis.com/v1"
)
logger = logging.getLogger("local_places.google_places")
_PRICE_LEVEL_TO_ENUM = {
0: "PRICE_LEVEL_FREE",
1: "PRICE_LEVEL_INEXPENSIVE",
2: "PRICE_LEVEL_MODERATE",
3: "PRICE_LEVEL_EXPENSIVE",
4: "PRICE_LEVEL_VERY_EXPENSIVE",
}
_ENUM_TO_PRICE_LEVEL = {value: key for key, value in _PRICE_LEVEL_TO_ENUM.items()}
_SEARCH_FIELD_MASK = (
"places.id,"
"places.displayName,"
"places.formattedAddress,"
"places.location,"
"places.rating,"
"places.priceLevel,"
"places.types,"
"places.currentOpeningHours,"
"nextPageToken"
)
_DETAILS_FIELD_MASK = (
"id,"
"displayName,"
"formattedAddress,"
"location,"
"rating,"
"priceLevel,"
"types,"
"regularOpeningHours,"
"currentOpeningHours,"
"nationalPhoneNumber,"
"websiteUri"
)
_RESOLVE_FIELD_MASK = (
"places.id,"
"places.displayName,"
"places.formattedAddress,"
"places.location,"
"places.types"
)
class _GoogleResponse:
def __init__(self, response: httpx.Response):
self.status_code = response.status_code
self._response = response
def json(self) -> dict[str, Any]:
return self._response.json()
@property
def text(self) -> str:
return self._response.text
def _api_headers(field_mask: str) -> dict[str, str]:
api_key = os.getenv("GOOGLE_PLACES_API_KEY")
if not api_key:
raise HTTPException(
status_code=500,
detail="GOOGLE_PLACES_API_KEY is not set.",
)
return {
"Content-Type": "application/json",
"X-Goog-Api-Key": api_key,
"X-Goog-FieldMask": field_mask,
}
def _request(
method: str, url: str, payload: dict[str, Any] | None, field_mask: str
) -> _GoogleResponse:
try:
with httpx.Client(timeout=10.0) as client:
response = client.request(
method=method,
url=url,
headers=_api_headers(field_mask),
json=payload,
)
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail="Google Places API unavailable.") from exc
return _GoogleResponse(response)
def _build_text_query(request: SearchRequest) -> str:
keyword = request.filters.keyword if request.filters else None
if keyword:
return f"{request.query} {keyword}".strip()
return request.query
def _build_search_body(request: SearchRequest) -> dict[str, Any]:
body: dict[str, Any] = {
"textQuery": _build_text_query(request),
"pageSize": request.limit,
}
if request.page_token:
body["pageToken"] = request.page_token
if request.location_bias:
body["locationBias"] = {
"circle": {
"center": {
"latitude": request.location_bias.lat,
"longitude": request.location_bias.lng,
},
"radius": request.location_bias.radius_m,
}
}
if request.filters:
filters = request.filters
if filters.types:
body["includedType"] = filters.types[0]
if filters.open_now is not None:
body["openNow"] = filters.open_now
if filters.min_rating is not None:
body["minRating"] = filters.min_rating
if filters.price_levels:
body["priceLevels"] = [
_PRICE_LEVEL_TO_ENUM[level] for level in filters.price_levels
]
return body
def _parse_lat_lng(raw: dict[str, Any] | None) -> LatLng | None:
if not raw:
return None
latitude = raw.get("latitude")
longitude = raw.get("longitude")
if latitude is None or longitude is None:
return None
return LatLng(lat=latitude, lng=longitude)
def _parse_display_name(raw: dict[str, Any] | None) -> str | None:
if not raw:
return None
return raw.get("text")
def _parse_open_now(raw: dict[str, Any] | None) -> bool | None:
if not raw:
return None
return raw.get("openNow")
def _parse_hours(raw: dict[str, Any] | None) -> list[str] | None:
if not raw:
return None
return raw.get("weekdayDescriptions")
def _parse_price_level(raw: str | None) -> int | None:
if not raw:
return None
return _ENUM_TO_PRICE_LEVEL.get(raw)
def search_places(request: SearchRequest) -> SearchResponse:
url = f"{GOOGLE_PLACES_BASE_URL}/places:searchText"
response = _request("POST", url, _build_search_body(request), _SEARCH_FIELD_MASK)
if response.status_code >= 400:
logger.error(
"Google Places API error %s. response=%s",
response.status_code,
response.text,
)
raise HTTPException(
status_code=502,
detail=f"Google Places API error ({response.status_code}).",
)
try:
payload = response.json()
except ValueError as exc:
logger.error(
"Google Places API returned invalid JSON. response=%s",
response.text,
)
raise HTTPException(status_code=502, detail="Invalid Google response.") from exc
places = payload.get("places", [])
results = []
for place in places:
results.append(
PlaceSummary(
place_id=place.get("id", ""),
name=_parse_display_name(place.get("displayName")),
address=place.get("formattedAddress"),
location=_parse_lat_lng(place.get("location")),
rating=place.get("rating"),
price_level=_parse_price_level(place.get("priceLevel")),
types=place.get("types"),
open_now=_parse_open_now(place.get("currentOpeningHours")),
)
)
return SearchResponse(
results=results,
next_page_token=payload.get("nextPageToken"),
)
def get_place_details(place_id: str) -> PlaceDetails:
url = f"{GOOGLE_PLACES_BASE_URL}/places/{place_id}"
response = _request("GET", url, None, _DETAILS_FIELD_MASK)
if response.status_code >= 400:
logger.error(
"Google Places API error %s. response=%s",
response.status_code,
response.text,
)
raise HTTPException(
status_code=502,
detail=f"Google Places API error ({response.status_code}).",
)
try:
payload = response.json()
except ValueError as exc:
logger.error(
"Google Places API returned invalid JSON. response=%s",
response.text,
)
raise HTTPException(status_code=502, detail="Invalid Google response.") from exc
return PlaceDetails(
place_id=payload.get("id", place_id),
name=_parse_display_name(payload.get("displayName")),
address=payload.get("formattedAddress"),
location=_parse_lat_lng(payload.get("location")),
rating=payload.get("rating"),
price_level=_parse_price_level(payload.get("priceLevel")),
types=payload.get("types"),
phone=payload.get("nationalPhoneNumber"),
website=payload.get("websiteUri"),
hours=_parse_hours(payload.get("regularOpeningHours")),
open_now=_parse_open_now(payload.get("currentOpeningHours")),
)
def resolve_locations(request: LocationResolveRequest) -> LocationResolveResponse:
url = f"{GOOGLE_PLACES_BASE_URL}/places:searchText"
body = {"textQuery": request.location_text, "pageSize": request.limit}
response = _request("POST", url, body, _RESOLVE_FIELD_MASK)
if response.status_code >= 400:
logger.error(
"Google Places API error %s. response=%s",
response.status_code,
response.text,
)
raise HTTPException(
status_code=502,
detail=f"Google Places API error ({response.status_code}).",
)
try:
payload = response.json()
except ValueError as exc:
logger.error(
"Google Places API returned invalid JSON. response=%s",
response.text,
)
raise HTTPException(status_code=502, detail="Invalid Google response.") from exc
places = payload.get("places", [])
results = []
for place in places:
results.append(
ResolvedLocation(
place_id=place.get("id", ""),
name=_parse_display_name(place.get("displayName")),
address=place.get("formattedAddress"),
location=_parse_lat_lng(place.get("location")),
types=place.get("types"),
)
)
return LocationResolveResponse(results=results)

View File

@@ -0,0 +1,65 @@
import logging
import os
from fastapi import FastAPI, Request
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from local_places.google_places import get_place_details, resolve_locations, search_places
from local_places.schemas import (
LocationResolveRequest,
LocationResolveResponse,
PlaceDetails,
SearchRequest,
SearchResponse,
)
app = FastAPI(
title="My API",
servers=[{"url": os.getenv("OPENAPI_SERVER_URL", "http://maxims-macbook-air:8000")}],
)
logger = logging.getLogger("local_places.validation")
@app.get("/ping")
def ping() -> dict[str, str]:
return {"message": "pong"}
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
logger.error(
"Validation error on %s %s. body=%s errors=%s",
request.method,
request.url.path,
exc.body,
exc.errors(),
)
return JSONResponse(
status_code=422,
content=jsonable_encoder({"detail": exc.errors()}),
)
@app.post("/places/search", response_model=SearchResponse)
def places_search(request: SearchRequest) -> SearchResponse:
return search_places(request)
@app.get("/places/{place_id}", response_model=PlaceDetails)
def places_details(place_id: str) -> PlaceDetails:
return get_place_details(place_id)
@app.post("/locations/resolve", response_model=LocationResolveResponse)
def locations_resolve(request: LocationResolveRequest) -> LocationResolveResponse:
return resolve_locations(request)
if __name__ == "__main__":
import uvicorn
uvicorn.run("local_places.main:app", host="0.0.0.0", port=8000)

View File

@@ -0,0 +1,107 @@
from __future__ import annotations
from pydantic import BaseModel, Field, field_validator
class LatLng(BaseModel):
lat: float = Field(ge=-90, le=90)
lng: float = Field(ge=-180, le=180)
class LocationBias(BaseModel):
lat: float = Field(ge=-90, le=90)
lng: float = Field(ge=-180, le=180)
radius_m: float = Field(gt=0)
class Filters(BaseModel):
types: list[str] | None = None
open_now: bool | None = None
min_rating: float | None = Field(default=None, ge=0, le=5)
price_levels: list[int] | None = None
keyword: str | None = Field(default=None, min_length=1)
@field_validator("types")
@classmethod
def validate_types(cls, value: list[str] | None) -> list[str] | None:
if value is None:
return value
if len(value) > 1:
raise ValueError(
"Only one type is supported. Use query/keyword for additional filtering."
)
return value
@field_validator("price_levels")
@classmethod
def validate_price_levels(cls, value: list[int] | None) -> list[int] | None:
if value is None:
return value
invalid = [level for level in value if level not in range(0, 5)]
if invalid:
raise ValueError("price_levels must be integers between 0 and 4.")
return value
@field_validator("min_rating")
@classmethod
def validate_min_rating(cls, value: float | None) -> float | None:
if value is None:
return value
if (value * 2) % 1 != 0:
raise ValueError("min_rating must be in 0.5 increments.")
return value
class SearchRequest(BaseModel):
query: str = Field(min_length=1)
location_bias: LocationBias | None = None
filters: Filters | None = None
limit: int = Field(default=10, ge=1, le=20)
page_token: str | None = None
class PlaceSummary(BaseModel):
place_id: str
name: str | None = None
address: str | None = None
location: LatLng | None = None
rating: float | None = None
price_level: int | None = None
types: list[str] | None = None
open_now: bool | None = None
class SearchResponse(BaseModel):
results: list[PlaceSummary]
next_page_token: str | None = None
class LocationResolveRequest(BaseModel):
location_text: str = Field(min_length=1)
limit: int = Field(default=5, ge=1, le=10)
class ResolvedLocation(BaseModel):
place_id: str
name: str | None = None
address: str | None = None
location: LatLng | None = None
types: list[str] | None = None
class LocationResolveResponse(BaseModel):
results: list[ResolvedLocation]
class PlaceDetails(BaseModel):
place_id: str
name: str | None = None
address: str | None = None
location: LatLng | None = None
rating: float | None = None
price_level: int | None = None
types: list[str] | None = None
phone: str | None = None
website: str | None = None
hours: list[str] | None = None
open_now: bool | None = None

38
.skills/mcporter/SKILL.md Normal file
View File

@@ -0,0 +1,38 @@
---
name: mcporter
description: Use the mcporter CLI to list, configure, auth, and call MCP servers/tools directly (HTTP or stdio), including ad-hoc servers, config edits, and CLI/type generation.
homepage: http://mcporter.dev
metadata: {"clawdbot":{"emoji":"📦","requires":{"bins":["mcporter"]},"install":[{"id":"node","kind":"node","package":"mcporter","bins":["mcporter"],"label":"Install mcporter (node)"}]}}
---
# mcporter
Use `mcporter` to work with MCP servers directly.
Quick start
- `mcporter list`
- `mcporter list <server> --schema`
- `mcporter call <server.tool> key=value`
Call tools
- Selector: `mcporter call linear.list_issues team=ENG limit:5`
- Function syntax: `mcporter call "linear.create_issue(title: \"Bug\")"`
- Full URL: `mcporter call https://api.example.com/mcp.fetch url:https://example.com`
- Stdio: `mcporter call --stdio "bun run ./server.ts" scrape url=https://example.com`
- JSON payload: `mcporter call <server.tool> --args '{"limit":5}'`
Auth + config
- OAuth: `mcporter auth <server | url> [--reset]`
- Config: `mcporter config list|get|add|remove|import|login|logout`
Daemon
- `mcporter daemon start|status|stop|restart`
Codegen
- CLI: `mcporter generate-cli --server <name>` or `--command <url>`
- Inspect: `mcporter inspect-cli <path> [--json]`
- TS: `mcporter emit-ts <server> --mode client|types`
Notes
- Config default: `./config/mcporter.json` (override with `--config`).
- Prefer `--output json` for machine-readable results.

View File

@@ -0,0 +1,35 @@
---
name: nano-banana-pro
description: Generate or edit images via Gemini 3 Pro Image (Nano Banana Pro).
homepage: https://ai.google.dev/
metadata: {"clawdbot":{"emoji":"🍌","requires":{"bins":["uv"],"env":["GEMINI_API_KEY"]},"primaryEnv":"GEMINI_API_KEY","install":[{"id":"uv-brew","kind":"brew","formula":"uv","bins":["uv"],"label":"Install uv (brew)"}]}}
---
# Nano Banana Pro (Gemini 3 Pro Image)
Use the bundled script to generate or edit images.
Generate
```bash
uv run {baseDir}/scripts/generate_image.py --prompt "your image description" --filename "output.png" --resolution 1K
```
Edit (single image)
```bash
uv run {baseDir}/scripts/generate_image.py --prompt "edit instructions" --filename "output.png" -i "/path/in.png" --resolution 2K
```
Multi-image composition (up to 14 images)
```bash
uv run {baseDir}/scripts/generate_image.py --prompt "combine these into one scene" --filename "output.png" -i img1.png -i img2.png -i img3.png
```
API key
- `GEMINI_API_KEY` env var
- Or set `skills."nano-banana-pro".apiKey` / `skills."nano-banana-pro".env.GEMINI_API_KEY` in `~/.clawdbot/clawdbot.json`
Notes
- Resolutions: `1K` (default), `2K`, `4K`.
- Use timestamps in filenames: `yyyy-mm-dd-hh-mm-ss-name.png`.
- The script prints a `MEDIA:` line for Clawdbot to auto-attach on supported chat providers.
- Do not read the image back; report the saved path only.

View File

@@ -0,0 +1,184 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "google-genai>=1.0.0",
# "pillow>=10.0.0",
# ]
# ///
"""
Generate images using Google's Nano Banana Pro (Gemini 3 Pro Image) API.
Usage:
uv run generate_image.py --prompt "your image description" --filename "output.png" [--resolution 1K|2K|4K] [--api-key KEY]
Multi-image editing (up to 14 images):
uv run generate_image.py --prompt "combine these images" --filename "output.png" -i img1.png -i img2.png -i img3.png
"""
import argparse
import os
import sys
from pathlib import Path
def get_api_key(provided_key: str | None) -> str | None:
"""Get API key from argument first, then environment."""
if provided_key:
return provided_key
return os.environ.get("GEMINI_API_KEY")
def main():
parser = argparse.ArgumentParser(
description="Generate images using Nano Banana Pro (Gemini 3 Pro Image)"
)
parser.add_argument(
"--prompt", "-p",
required=True,
help="Image description/prompt"
)
parser.add_argument(
"--filename", "-f",
required=True,
help="Output filename (e.g., sunset-mountains.png)"
)
parser.add_argument(
"--input-image", "-i",
action="append",
dest="input_images",
metavar="IMAGE",
help="Input image path(s) for editing/composition. Can be specified multiple times (up to 14 images)."
)
parser.add_argument(
"--resolution", "-r",
choices=["1K", "2K", "4K"],
default="1K",
help="Output resolution: 1K (default), 2K, or 4K"
)
parser.add_argument(
"--api-key", "-k",
help="Gemini API key (overrides GEMINI_API_KEY env var)"
)
args = parser.parse_args()
# Get API key
api_key = get_api_key(args.api_key)
if not api_key:
print("Error: No API key provided.", file=sys.stderr)
print("Please either:", file=sys.stderr)
print(" 1. Provide --api-key argument", file=sys.stderr)
print(" 2. Set GEMINI_API_KEY environment variable", file=sys.stderr)
sys.exit(1)
# Import here after checking API key to avoid slow import on error
from google import genai
from google.genai import types
from PIL import Image as PILImage
# Initialise client
client = genai.Client(api_key=api_key)
# Set up output path
output_path = Path(args.filename)
output_path.parent.mkdir(parents=True, exist_ok=True)
# Load input images if provided (up to 14 supported by Nano Banana Pro)
input_images = []
output_resolution = args.resolution
if args.input_images:
if len(args.input_images) > 14:
print(f"Error: Too many input images ({len(args.input_images)}). Maximum is 14.", file=sys.stderr)
sys.exit(1)
max_input_dim = 0
for img_path in args.input_images:
try:
img = PILImage.open(img_path)
input_images.append(img)
print(f"Loaded input image: {img_path}")
# Track largest dimension for auto-resolution
width, height = img.size
max_input_dim = max(max_input_dim, width, height)
except Exception as e:
print(f"Error loading input image '{img_path}': {e}", file=sys.stderr)
sys.exit(1)
# Auto-detect resolution from largest input if not explicitly set
if args.resolution == "1K" and max_input_dim > 0: # Default value
if max_input_dim >= 3000:
output_resolution = "4K"
elif max_input_dim >= 1500:
output_resolution = "2K"
else:
output_resolution = "1K"
print(f"Auto-detected resolution: {output_resolution} (from max input dimension {max_input_dim})")
# Build contents (images first if editing, prompt only if generating)
if input_images:
contents = [*input_images, args.prompt]
img_count = len(input_images)
print(f"Processing {img_count} image{'s' if img_count > 1 else ''} with resolution {output_resolution}...")
else:
contents = args.prompt
print(f"Generating image with resolution {output_resolution}...")
try:
response = client.models.generate_content(
model="gemini-3-pro-image-preview",
contents=contents,
config=types.GenerateContentConfig(
response_modalities=["TEXT", "IMAGE"],
image_config=types.ImageConfig(
image_size=output_resolution
)
)
)
# Process response and convert to PNG
image_saved = False
for part in response.parts:
if part.text is not None:
print(f"Model response: {part.text}")
elif part.inline_data is not None:
# Convert inline data to PIL Image and save as PNG
from io import BytesIO
# inline_data.data is already bytes, not base64
image_data = part.inline_data.data
if isinstance(image_data, str):
# If it's a string, it might be base64
import base64
image_data = base64.b64decode(image_data)
image = PILImage.open(BytesIO(image_data))
# Ensure RGB mode for PNG (convert RGBA to RGB with white background if needed)
if image.mode == 'RGBA':
rgb_image = PILImage.new('RGB', image.size, (255, 255, 255))
rgb_image.paste(image, mask=image.split()[3])
rgb_image.save(str(output_path), 'PNG')
elif image.mode == 'RGB':
image.save(str(output_path), 'PNG')
else:
image.convert('RGB').save(str(output_path), 'PNG')
image_saved = True
if image_saved:
full_path = output_path.resolve()
print(f"\nImage saved: {full_path}")
# Clawdbot parses MEDIA tokens and will attach the file on supported providers.
print(f"MEDIA: {full_path}")
else:
print("Error: No image was generated in the response.", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Error generating image: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

20
.skills/nano-pdf/SKILL.md Normal file
View File

@@ -0,0 +1,20 @@
---
name: nano-pdf
description: Edit PDFs with natural-language instructions using the nano-pdf CLI.
homepage: https://pypi.org/project/nano-pdf/
metadata: {"clawdbot":{"emoji":"📄","requires":{"bins":["nano-pdf"]},"install":[{"id":"uv","kind":"uv","package":"nano-pdf","bins":["nano-pdf"],"label":"Install nano-pdf (uv)"}]}}
---
# nano-pdf
Use `nano-pdf` to apply edits to a specific page in a PDF using a natural-language instruction.
## Quick start
```bash
nano-pdf edit deck.pdf 1 "Change the title to 'Q3 Results' and fix the typo in the subtitle"
```
Notes:
- Page numbers are 0-based or 1-based depending on the tools version/config; if the result looks off by one, retry with the other.
- Always sanity-check the output PDF before sending it out.

156
.skills/notion/SKILL.md Normal file
View File

@@ -0,0 +1,156 @@
---
name: notion
description: Notion API for creating and managing pages, databases, and blocks.
homepage: https://developers.notion.com
metadata: {"clawdbot":{"emoji":"📝","requires":{"env":["NOTION_API_KEY"]},"primaryEnv":"NOTION_API_KEY"}}
---
# notion
Use the Notion API to create/read/update pages, data sources (databases), and blocks.
## Setup
1. Create an integration at https://notion.so/my-integrations
2. Copy the API key (starts with `ntn_` or `secret_`)
3. Store it:
```bash
mkdir -p ~/.config/notion
echo "ntn_your_key_here" > ~/.config/notion/api_key
```
4. Share target pages/databases with your integration (click "..." → "Connect to" → your integration name)
## API Basics
All requests need:
```bash
NOTION_KEY=$(cat ~/.config/notion/api_key)
curl -X GET "https://api.notion.com/v1/..." \
-H "Authorization: Bearer $NOTION_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json"
```
> **Note:** The `Notion-Version` header is required. This skill uses `2025-09-03` (latest). In this version, databases are called "data sources" in the API.
## Common Operations
**Search for pages and data sources:**
```bash
curl -X POST "https://api.notion.com/v1/search" \
-H "Authorization: Bearer $NOTION_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json" \
-d '{"query": "page title"}'
```
**Get page:**
```bash
curl "https://api.notion.com/v1/pages/{page_id}" \
-H "Authorization: Bearer $NOTION_KEY" \
-H "Notion-Version: 2025-09-03"
```
**Get page content (blocks):**
```bash
curl "https://api.notion.com/v1/blocks/{page_id}/children" \
-H "Authorization: Bearer $NOTION_KEY" \
-H "Notion-Version: 2025-09-03"
```
**Create page in a data source:**
```bash
curl -X POST "https://api.notion.com/v1/pages" \
-H "Authorization: Bearer $NOTION_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json" \
-d '{
"parent": {"database_id": "xxx"},
"properties": {
"Name": {"title": [{"text": {"content": "New Item"}}]},
"Status": {"select": {"name": "Todo"}}
}
}'
```
**Query a data source (database):**
```bash
curl -X POST "https://api.notion.com/v1/data_sources/{data_source_id}/query" \
-H "Authorization: Bearer $NOTION_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json" \
-d '{
"filter": {"property": "Status", "select": {"equals": "Active"}},
"sorts": [{"property": "Date", "direction": "descending"}]
}'
```
**Create a data source (database):**
```bash
curl -X POST "https://api.notion.com/v1/data_sources" \
-H "Authorization: Bearer $NOTION_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json" \
-d '{
"parent": {"page_id": "xxx"},
"title": [{"text": {"content": "My Database"}}],
"properties": {
"Name": {"title": {}},
"Status": {"select": {"options": [{"name": "Todo"}, {"name": "Done"}]}},
"Date": {"date": {}}
}
}'
```
**Update page properties:**
```bash
curl -X PATCH "https://api.notion.com/v1/pages/{page_id}" \
-H "Authorization: Bearer $NOTION_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json" \
-d '{"properties": {"Status": {"select": {"name": "Done"}}}}'
```
**Add blocks to page:**
```bash
curl -X PATCH "https://api.notion.com/v1/blocks/{page_id}/children" \
-H "Authorization: Bearer $NOTION_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json" \
-d '{
"children": [
{"object": "block", "type": "paragraph", "paragraph": {"rich_text": [{"text": {"content": "Hello"}}]}}
]
}'
```
## Property Types
Common property formats for database items:
- **Title:** `{"title": [{"text": {"content": "..."}}]}`
- **Rich text:** `{"rich_text": [{"text": {"content": "..."}}]}`
- **Select:** `{"select": {"name": "Option"}}`
- **Multi-select:** `{"multi_select": [{"name": "A"}, {"name": "B"}]}`
- **Date:** `{"date": {"start": "2024-01-15", "end": "2024-01-16"}}`
- **Checkbox:** `{"checkbox": true}`
- **Number:** `{"number": 42}`
- **URL:** `{"url": "https://..."}`
- **Email:** `{"email": "a@b.com"}`
- **Relation:** `{"relation": [{"id": "page_id"}]}`
## Key Differences in 2025-09-03
- **Databases → Data Sources:** Use `/data_sources/` endpoints for queries and retrieval
- **Two IDs:** Each database now has both a `database_id` and a `data_source_id`
- Use `database_id` when creating pages (`parent: {"database_id": "..."}`)
- Use `data_source_id` when querying (`POST /v1/data_sources/{id}/query`)
- **Search results:** Databases return as `"object": "data_source"` with their `data_source_id`
- **Parent in responses:** Pages show `parent.data_source_id` alongside `parent.database_id`
- **Finding the data_source_id:** Search for the database, or call `GET /v1/data_sources/{data_source_id}`
## Notes
- Page/database IDs are UUIDs (with or without dashes)
- The API cannot set database view filters — that's UI-only
- Rate limit: ~3 requests/second average
- Use `is_inline: true` when creating data sources to embed them in pages

55
.skills/obsidian/SKILL.md Normal file
View File

@@ -0,0 +1,55 @@
---
name: obsidian
description: Work with Obsidian vaults (plain Markdown notes) and automate via obsidian-cli.
homepage: https://help.obsidian.md
metadata: {"clawdbot":{"emoji":"💎","requires":{"bins":["obsidian-cli"]},"install":[{"id":"brew","kind":"brew","formula":"yakitrak/yakitrak/obsidian-cli","bins":["obsidian-cli"],"label":"Install obsidian-cli (brew)"}]}}
---
# Obsidian
Obsidian vault = a normal folder on disk.
Vault structure (typical)
- Notes: `*.md` (plain text Markdown; edit with any editor)
- Config: `.obsidian/` (workspace + plugin settings; usually dont touch from scripts)
- Canvases: `*.canvas` (JSON)
- Attachments: whatever folder you chose in Obsidian settings (images/PDFs/etc.)
## Find the active vault(s)
Obsidian desktop tracks vaults here (source of truth):
- `~/Library/Application Support/obsidian/obsidian.json`
`obsidian-cli` resolves vaults from that file; vault name is typically the **folder name** (path suffix).
Fast “what vault is active / where are the notes?”
- If youve already set a default: `obsidian-cli print-default --path-only`
- Otherwise, read `~/Library/Application Support/obsidian/obsidian.json` and use the vault entry with `"open": true`.
Notes
- Multiple vaults common (iCloud vs `~/Documents`, work/personal, etc.). Dont guess; read config.
- Avoid writing hardcoded vault paths into scripts; prefer reading the config or using `print-default`.
## obsidian-cli quick start
Pick a default vault (once):
- `obsidian-cli set-default "<vault-folder-name>"`
- `obsidian-cli print-default` / `obsidian-cli print-default --path-only`
Search
- `obsidian-cli search "query"` (note names)
- `obsidian-cli search-content "query"` (inside notes; shows snippets + lines)
Create
- `obsidian-cli create "Folder/New note" --content "..." --open`
- Requires Obsidian URI handler (`obsidian://…`) working (Obsidian installed).
- Avoid creating notes under “hidden” dot-folders (e.g. `.something/...`) via URI; Obsidian may refuse.
Move/rename (safe refactor)
- `obsidian-cli move "old/path/note" "new/path/note"`
- Updates `[[wikilinks]]` and common Markdown links across the vault (this is the main win vs `mv`).
Delete
- `obsidian-cli delete "path/note"`
Prefer direct edits when appropriate: open the `.md` file and change it; Obsidian will pick it up.

View File

@@ -0,0 +1,71 @@
---
name: openai-image-gen
description: Batch-generate images via OpenAI Images API. Random prompt sampler + `index.html` gallery.
homepage: https://platform.openai.com/docs/api-reference/images
metadata: {"clawdbot":{"emoji":"🖼️","requires":{"bins":["python3"],"env":["OPENAI_API_KEY"]},"primaryEnv":"OPENAI_API_KEY","install":[{"id":"python-brew","kind":"brew","formula":"python","bins":["python3"],"label":"Install Python (brew)"}]}}
---
# OpenAI Image Gen
Generate a handful of “random but structured” prompts and render them via the OpenAI Images API.
## Run
```bash
python3 {baseDir}/scripts/gen.py
open ~/Projects/tmp/openai-image-gen-*/index.html # if ~/Projects/tmp exists; else ./tmp/...
```
Useful flags:
```bash
# GPT image models with various options
python3 {baseDir}/scripts/gen.py --count 16 --model gpt-image-1
python3 {baseDir}/scripts/gen.py --prompt "ultra-detailed studio photo of a lobster astronaut" --count 4
python3 {baseDir}/scripts/gen.py --size 1536x1024 --quality high --out-dir ./out/images
python3 {baseDir}/scripts/gen.py --model gpt-image-1.5 --background transparent --output-format webp
# DALL-E 3 (note: count is automatically limited to 1)
python3 {baseDir}/scripts/gen.py --model dall-e-3 --quality hd --size 1792x1024 --style vivid
python3 {baseDir}/scripts/gen.py --model dall-e-3 --style natural --prompt "serene mountain landscape"
# DALL-E 2
python3 {baseDir}/scripts/gen.py --model dall-e-2 --size 512x512 --count 4
```
## Model-Specific Parameters
Different models support different parameter values. The script automatically selects appropriate defaults based on the model.
### Size
- **GPT image models** (`gpt-image-1`, `gpt-image-1-mini`, `gpt-image-1.5`): `1024x1024`, `1536x1024` (landscape), `1024x1536` (portrait), or `auto`
- Default: `1024x1024`
- **dall-e-3**: `1024x1024`, `1792x1024`, or `1024x1792`
- Default: `1024x1024`
- **dall-e-2**: `256x256`, `512x512`, or `1024x1024`
- Default: `1024x1024`
### Quality
- **GPT image models**: `auto`, `high`, `medium`, or `low`
- Default: `high`
- **dall-e-3**: `hd` or `standard`
- Default: `standard`
- **dall-e-2**: `standard` only
- Default: `standard`
### Other Notable Differences
- **dall-e-3** only supports generating 1 image at a time (`n=1`). The script automatically limits count to 1 when using this model.
- **GPT image models** support additional parameters:
- `--background`: `transparent`, `opaque`, or `auto` (default)
- `--output-format`: `png` (default), `jpeg`, or `webp`
- Note: `stream` and `moderation` are available via API but not yet implemented in this script
- **dall-e-3** has a `--style` parameter: `vivid` (hyper-real, dramatic) or `natural` (more natural looking)
## Output
- `*.png`, `*.jpeg`, or `*.webp` images (output format depends on model + `--output-format`)
- `prompts.json` (prompt → file mapping)
- `index.html` (thumbnail gallery)

View File

@@ -0,0 +1,240 @@
#!/usr/bin/env python3
import argparse
import base64
import datetime as dt
import json
import os
import random
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path
def slugify(text: str) -> str:
text = text.lower().strip()
text = re.sub(r"[^a-z0-9]+", "-", text)
text = re.sub(r"-{2,}", "-", text).strip("-")
return text or "image"
def default_out_dir() -> Path:
now = dt.datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
preferred = Path.home() / "Projects" / "tmp"
base = preferred if preferred.is_dir() else Path("./tmp")
base.mkdir(parents=True, exist_ok=True)
return base / f"openai-image-gen-{now}"
def pick_prompts(count: int) -> list[str]:
subjects = [
"a lobster astronaut",
"a brutalist lighthouse",
"a cozy reading nook",
"a cyberpunk noodle shop",
"a Vienna street at dusk",
"a minimalist product photo",
"a surreal underwater library",
]
styles = [
"ultra-detailed studio photo",
"35mm film still",
"isometric illustration",
"editorial photography",
"soft watercolor",
"architectural render",
"high-contrast monochrome",
]
lighting = [
"golden hour",
"overcast soft light",
"neon lighting",
"dramatic rim light",
"candlelight",
"foggy atmosphere",
]
prompts: list[str] = []
for _ in range(count):
prompts.append(
f"{random.choice(styles)} of {random.choice(subjects)}, {random.choice(lighting)}"
)
return prompts
def get_model_defaults(model: str) -> tuple[str, str]:
"""Return (default_size, default_quality) for the given model."""
if model == "dall-e-2":
# quality will be ignored
return ("1024x1024", "standard")
elif model == "dall-e-3":
return ("1024x1024", "standard")
else:
# GPT image or future models
return ("1024x1024", "high")
def request_images(
api_key: str,
prompt: str,
model: str,
size: str,
quality: str,
background: str = "",
output_format: str = "",
style: str = "",
) -> dict:
url = "https://api.openai.com/v1/images/generations"
args = {
"model": model,
"prompt": prompt,
"size": size,
"n": 1,
}
# Quality parameter - dall-e-2 doesn't accept this parameter
if model != "dall-e-2":
args["quality"] = quality
# Note: response_format no longer supported by OpenAI Images API
# dall-e models now return URLs by default
if model.startswith("gpt-image"):
if background:
args["background"] = background
if output_format:
args["output_format"] = output_format
if model == "dall-e-3" and style:
args["style"] = style
body = json.dumps(args).encode("utf-8")
req = urllib.request.Request(
url,
method="POST",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
data=body,
)
try:
with urllib.request.urlopen(req, timeout=300) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
payload = e.read().decode("utf-8", errors="replace")
raise RuntimeError(f"OpenAI Images API failed ({e.code}): {payload}") from e
def write_gallery(out_dir: Path, items: list[dict]) -> None:
thumbs = "\n".join(
[
f"""
<figure>
<a href="{it["file"]}"><img src="{it["file"]}" loading="lazy" /></a>
<figcaption>{it["prompt"]}</figcaption>
</figure>
""".strip()
for it in items
]
)
html = f"""<!doctype html>
<meta charset="utf-8" />
<title>openai-image-gen</title>
<style>
:root {{ color-scheme: dark; }}
body {{ margin: 24px; font: 14px/1.4 ui-sans-serif, system-ui; background: #0b0f14; color: #e8edf2; }}
h1 {{ font-size: 18px; margin: 0 0 16px; }}
.grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; }}
figure {{ margin: 0; padding: 12px; border: 1px solid #1e2a36; border-radius: 14px; background: #0f1620; }}
img {{ width: 100%; height: auto; border-radius: 10px; display: block; }}
figcaption {{ margin-top: 10px; color: #b7c2cc; }}
code {{ color: #9cd1ff; }}
</style>
<h1>openai-image-gen</h1>
<p>Output: <code>{out_dir.as_posix()}</code></p>
<div class="grid">
{thumbs}
</div>
"""
(out_dir / "index.html").write_text(html, encoding="utf-8")
def main() -> int:
ap = argparse.ArgumentParser(description="Generate images via OpenAI Images API.")
ap.add_argument("--prompt", help="Single prompt. If omitted, random prompts are generated.")
ap.add_argument("--count", type=int, default=8, help="How many images to generate.")
ap.add_argument("--model", default="gpt-image-1", help="Image model id.")
ap.add_argument("--size", default="", help="Image size (e.g. 1024x1024, 1536x1024). Defaults based on model if not specified.")
ap.add_argument("--quality", default="", help="Image quality (e.g. high, standard). Defaults based on model if not specified.")
ap.add_argument("--background", default="", help="Background transparency (GPT models only): transparent, opaque, or auto.")
ap.add_argument("--output-format", default="", help="Output format (GPT models only): png, jpeg, or webp.")
ap.add_argument("--style", default="", help="Image style (dall-e-3 only): vivid or natural.")
ap.add_argument("--out-dir", default="", help="Output directory (default: ./tmp/openai-image-gen-<ts>).")
args = ap.parse_args()
api_key = (os.environ.get("OPENAI_API_KEY") or "").strip()
if not api_key:
print("Missing OPENAI_API_KEY", file=sys.stderr)
return 2
# Apply model-specific defaults if not specified
default_size, default_quality = get_model_defaults(args.model)
size = args.size or default_size
quality = args.quality or default_quality
count = args.count
if args.model == "dall-e-3" and count > 1:
print(f"Warning: dall-e-3 only supports generating 1 image at a time. Reducing count from {count} to 1.", file=sys.stderr)
count = 1
out_dir = Path(args.out_dir).expanduser() if args.out_dir else default_out_dir()
out_dir.mkdir(parents=True, exist_ok=True)
prompts = [args.prompt] * count if args.prompt else pick_prompts(count)
# Determine file extension based on output format
if args.model.startswith("gpt-image") and args.output_format:
file_ext = args.output_format
else:
file_ext = "png"
items: list[dict] = []
for idx, prompt in enumerate(prompts, start=1):
print(f"[{idx}/{len(prompts)}] {prompt}")
res = request_images(
api_key,
prompt,
args.model,
size,
quality,
args.background,
args.output_format,
args.style,
)
data = res.get("data", [{}])[0]
image_b64 = data.get("b64_json")
image_url = data.get("url")
if not image_b64 and not image_url:
raise RuntimeError(f"Unexpected response: {json.dumps(res)[:400]}")
filename = f"{idx:03d}-{slugify(prompt)[:40]}.{file_ext}"
filepath = out_dir / filename
if image_b64:
filepath.write_bytes(base64.b64decode(image_b64))
else:
try:
urllib.request.urlretrieve(image_url, filepath)
except urllib.error.URLError as e:
raise RuntimeError(f"Failed to download image from {image_url}: {e}") from e
items.append({"prompt": prompt, "file": filename})
(out_dir / "prompts.json").write_text(json.dumps(items, indent=2), encoding="utf-8")
write_gallery(out_dir, items)
print(f"\nWrote: {(out_dir / 'index.html').as_posix()}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,43 @@
---
name: openai-whisper-api
description: Transcribe audio via OpenAI Audio Transcriptions API (Whisper).
homepage: https://platform.openai.com/docs/guides/speech-to-text
metadata: {"clawdbot":{"emoji":"☁️","requires":{"bins":["curl"],"env":["OPENAI_API_KEY"]},"primaryEnv":"OPENAI_API_KEY"}}
---
# OpenAI Whisper API (curl)
Transcribe an audio file via OpenAIs `/v1/audio/transcriptions` endpoint.
## Quick start
```bash
{baseDir}/scripts/transcribe.sh /path/to/audio.m4a
```
Defaults:
- Model: `whisper-1`
- Output: `<input>.txt`
## Useful flags
```bash
{baseDir}/scripts/transcribe.sh /path/to/audio.ogg --model whisper-1 --out /tmp/transcript.txt
{baseDir}/scripts/transcribe.sh /path/to/audio.m4a --language en
{baseDir}/scripts/transcribe.sh /path/to/audio.m4a --prompt "Speaker names: Peter, Daniel"
{baseDir}/scripts/transcribe.sh /path/to/audio.m4a --json --out /tmp/transcript.json
```
## API key
Set `OPENAI_API_KEY`, or configure it in `~/.clawdbot/clawdbot.json`:
```json5
{
skills: {
"openai-whisper-api": {
apiKey: "OPENAI_KEY_HERE"
}
}
}
```

View File

@@ -0,0 +1,85 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat >&2 <<'EOF'
Usage:
transcribe.sh <audio-file> [--model whisper-1] [--out /path/to/out.txt] [--language en] [--prompt "hint"] [--json]
EOF
exit 2
}
if [[ "${1:-}" == "" || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
fi
in="${1:-}"
shift || true
model="whisper-1"
out=""
language=""
prompt=""
response_format="text"
while [[ $# -gt 0 ]]; do
case "$1" in
--model)
model="${2:-}"
shift 2
;;
--out)
out="${2:-}"
shift 2
;;
--language)
language="${2:-}"
shift 2
;;
--prompt)
prompt="${2:-}"
shift 2
;;
--json)
response_format="json"
shift 1
;;
*)
echo "Unknown arg: $1" >&2
usage
;;
esac
done
if [[ ! -f "$in" ]]; then
echo "File not found: $in" >&2
exit 1
fi
if [[ "${OPENAI_API_KEY:-}" == "" ]]; then
echo "Missing OPENAI_API_KEY" >&2
exit 1
fi
if [[ "$out" == "" ]]; then
base="${in%.*}"
if [[ "$response_format" == "json" ]]; then
out="${base}.json"
else
out="${base}.txt"
fi
fi
mkdir -p "$(dirname "$out")"
curl -sS https://api.openai.com/v1/audio/transcriptions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Accept: application/json" \
-F "file=@${in}" \
-F "model=${model}" \
-F "response_format=${response_format}" \
${language:+-F "language=${language}"} \
${prompt:+-F "prompt=${prompt}"} \
>"$out"
echo "$out"

View File

@@ -0,0 +1,19 @@
---
name: openai-whisper
description: Local speech-to-text with the Whisper CLI (no API key).
homepage: https://openai.com/research/whisper
metadata: {"clawdbot":{"emoji":"🎙️","requires":{"bins":["whisper"]},"install":[{"id":"brew","kind":"brew","formula":"openai-whisper","bins":["whisper"],"label":"Install OpenAI Whisper (brew)"}]}}
---
# Whisper (CLI)
Use `whisper` to transcribe audio locally.
Quick start
- `whisper /path/audio.mp3 --model medium --output_format txt --output_dir .`
- `whisper /path/audio.m4a --task translate --output_format srt`
Notes
- Models download to `~/.cache/whisper` on first run.
- `--model` defaults to `turbo` on this install.
- Use smaller models for speed, larger for accuracy.

30
.skills/openhue/SKILL.md Normal file
View File

@@ -0,0 +1,30 @@
---
name: openhue
description: Control Philips Hue lights/scenes via the OpenHue CLI.
homepage: https://www.openhue.io/cli
metadata: {"clawdbot":{"emoji":"💡","requires":{"bins":["openhue"]},"install":[{"id":"brew","kind":"brew","formula":"openhue/cli/openhue-cli","bins":["openhue"],"label":"Install OpenHue CLI (brew)"}]}}
---
# OpenHue CLI
Use `openhue` to control Hue lights and scenes via a Hue Bridge.
Setup
- Discover bridges: `openhue discover`
- Guided setup: `openhue setup`
Read
- `openhue get light --json`
- `openhue get room --json`
- `openhue get scene --json`
Write
- Turn on: `openhue set light <id-or-name> --on`
- Turn off: `openhue set light <id-or-name> --off`
- Brightness: `openhue set light <id> --on --brightness 50`
- Color: `openhue set light <id> --on --rgb #3399FF`
- Scene: `openhue set scene <scene-id>`
Notes
- You may need to press the Hue Bridge button during setup.
- Use `--room "Room Name"` when light names are ambiguous.

105
.skills/oracle/SKILL.md Normal file
View File

@@ -0,0 +1,105 @@
---
name: oracle
description: Best practices for using the oracle CLI (prompt + file bundling, engines, sessions, and file attachment patterns).
homepage: https://askoracle.dev
metadata: {"clawdbot":{"emoji":"🧿","requires":{"bins":["oracle"]},"install":[{"id":"node","kind":"node","package":"@steipete/oracle","bins":["oracle"],"label":"Install oracle (node)"}]}}
---
# oracle — best use
Oracle bundles your prompt + selected files into one “one-shot” request so another model can answer with real repo context (API or browser automation). Treat output as advisory: verify against code + tests.
## Main use case (browser, GPT5.2 Pro)
Default workflow here: `--engine browser` with GPT5.2 Pro in ChatGPT. This is the common “long think” path: ~10 minutes to ~1 hour is normal; expect a stored session you can reattach to.
Recommended defaults:
- Engine: browser (`--engine browser`)
- Model: GPT5.2 Pro (`--model gpt-5.2-pro` or `--model "5.2 Pro"`)
## Golden path
1. Pick a tight file set (fewest files that still contain the truth).
2. Preview payload + token spend (`--dry-run` + `--files-report`).
3. Use browser mode for the usual GPT5.2 Pro workflow; use API only when you explicitly want it.
4. If the run detaches/timeouts: reattach to the stored session (dont re-run).
## Commands (preferred)
- Help:
- `oracle --help`
- If the binary isnt installed: `npx -y @steipete/oracle --help` (avoid `pnpx` here; sqlite bindings).
- Preview (no tokens):
- `oracle --dry-run summary -p "<task>" --file "src/**" --file "!**/*.test.*"`
- `oracle --dry-run full -p "<task>" --file "src/**"`
- Token sanity:
- `oracle --dry-run summary --files-report -p "<task>" --file "src/**"`
- Browser run (main path; long-running is normal):
- `oracle --engine browser --model gpt-5.2-pro -p "<task>" --file "src/**"`
- Manual paste fallback:
- `oracle --render --copy -p "<task>" --file "src/**"`
- Note: `--copy` is a hidden alias for `--copy-markdown`.
## Attaching files (`--file`)
`--file` accepts files, directories, and globs. You can pass it multiple times; entries can be comma-separated.
- Include:
- `--file "src/**"`
- `--file src/index.ts`
- `--file docs --file README.md`
- Exclude:
- `--file "src/**" --file "!src/**/*.test.ts" --file "!**/*.snap"`
- Defaults (implementation behavior):
- Default-ignored dirs: `node_modules`, `dist`, `coverage`, `.git`, `.turbo`, `.next`, `build`, `tmp` (skipped unless explicitly passed as literal dirs/files).
- Honors `.gitignore` when expanding globs.
- Does not follow symlinks.
- Dotfiles filtered unless opted in via pattern (e.g. `--file ".github/**"`).
- Files > 1 MB rejected.
## Engines (API vs browser)
- Auto-pick: `api` when `OPENAI_API_KEY` is set; otherwise `browser`.
- Browser supports GPT + Gemini only; use `--engine api` for Claude/Grok/Codex or multi-model runs.
- Browser attachments:
- `--browser-attachments auto|never|always` (auto pastes inline up to ~60k chars then uploads).
- Remote browser host:
- Host: `oracle serve --host 0.0.0.0 --port 9473 --token <secret>`
- Client: `oracle --engine browser --remote-host <host:port> --remote-token <secret> -p "<task>" --file "src/**"`
## Sessions + slugs
- Stored under `~/.oracle/sessions` (override with `ORACLE_HOME_DIR`).
- Runs may detach or take a long time (browser + GPT5.2 Pro often does). If the CLI times out: dont re-run; reattach.
- List: `oracle status --hours 72`
- Attach: `oracle session <id> --render`
- Use `--slug "<3-5 words>"` to keep session IDs readable.
- Duplicate prompt guard exists; use `--force` only when you truly want a fresh run.
## Prompt template (high signal)
Oracle starts with **zero** project knowledge. Assume the model cannot infer your stack, build tooling, conventions, or “obvious” paths. Include:
- Project briefing (stack + build/test commands + platform constraints).
- “Where things live” (key directories, entrypoints, config files, boundaries).
- Exact question + what you tried + the error text (verbatim).
- Constraints (“dont change X”, “must keep public API”, etc).
- Desired output (“return patch plan + tests”, “give 3 options with tradeoffs”).
## Safety
- Dont attach secrets by default (`.env`, key files, auth tokens). Redact aggressively; share only whats required.
## “Exhaustive prompt” restoration pattern
For long investigations, write a standalone prompt + file set so you can rerun days later:
- 630 sentence project briefing + the goal.
- Repro steps + exact errors + what you tried.
- Attach all context files needed (entrypoints, configs, key modules, docs).
Oracle runs are one-shot; the model doesnt remember prior runs. “Restoring context” means re-running with the same prompt + `--file …` set (or reattaching a still-running stored session).

47
.skills/ordercli/SKILL.md Normal file
View File

@@ -0,0 +1,47 @@
---
name: ordercli
description: Foodora-only CLI for checking past orders and active order status (Deliveroo WIP).
homepage: https://ordercli.sh
metadata: {"clawdbot":{"emoji":"🛵","requires":{"bins":["ordercli"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/ordercli","bins":["ordercli"],"label":"Install ordercli (brew)"},{"id":"go","kind":"go","module":"github.com/steipete/ordercli/cmd/ordercli@latest","bins":["ordercli"],"label":"Install ordercli (go)"}]}}
---
# ordercli
Use `ordercli` to check past orders and track active order status (Foodora only right now).
Quick start (Foodora)
- `ordercli foodora countries`
- `ordercli foodora config set --country AT`
- `ordercli foodora login --email you@example.com --password-stdin`
- `ordercli foodora orders`
- `ordercli foodora history --limit 20`
- `ordercli foodora history show <orderCode>`
Orders
- Active list (arrival/status): `ordercli foodora orders`
- Watch: `ordercli foodora orders --watch`
- Active order detail: `ordercli foodora order <orderCode>`
- History detail JSON: `ordercli foodora history show <orderCode> --json`
Reorder (adds to cart)
- Preview: `ordercli foodora reorder <orderCode>`
- Confirm: `ordercli foodora reorder <orderCode> --confirm`
- Address: `ordercli foodora reorder <orderCode> --confirm --address-id <id>`
Cloudflare / bot protection
- Browser login: `ordercli foodora login --email you@example.com --password-stdin --browser`
- Reuse profile: `--browser-profile "$HOME/Library/Application Support/ordercli/browser-profile"`
- Import Chrome cookies: `ordercli foodora cookies chrome --profile "Default"`
Session import (no password)
- `ordercli foodora session chrome --url https://www.foodora.at/ --profile "Default"`
- `ordercli foodora session refresh --client-id android`
Deliveroo (WIP, not working yet)
- Requires `DELIVEROO_BEARER_TOKEN` (optional `DELIVEROO_COOKIE`).
- `ordercli deliveroo config set --market uk`
- `ordercli deliveroo history`
Notes
- Use `--config /tmp/ordercli.json` for testing.
- Confirm before any reorder or cart-changing action.

153
.skills/peekaboo/SKILL.md Normal file
View File

@@ -0,0 +1,153 @@
---
name: peekaboo
description: Capture and automate macOS UI with the Peekaboo CLI.
homepage: https://peekaboo.boo
metadata: {"clawdbot":{"emoji":"👀","os":["darwin"],"requires":{"bins":["peekaboo"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/peekaboo","bins":["peekaboo"],"label":"Install Peekaboo (brew)"}]}}
---
# Peekaboo
Peekaboo is a full macOS UI automation CLI: capture/inspect screens, target UI
elements, drive input, and manage apps/windows/menus. Commands share a snapshot
cache and support `--json`/`-j` for scripting. Run `peekaboo` or
`peekaboo <cmd> --help` for flags; `peekaboo --version` prints build metadata.
Tip: run via `polter peekaboo` to ensure fresh builds.
## Features (all CLI capabilities, excluding agent/MCP)
Core
- `bridge`: inspect Peekaboo Bridge host connectivity
- `capture`: live capture or video ingest + frame extraction
- `clean`: prune snapshot cache and temp files
- `config`: init/show/edit/validate, providers, models, credentials
- `image`: capture screenshots (screen/window/menu bar regions)
- `learn`: print the full agent guide + tool catalog
- `list`: apps, windows, screens, menubar, permissions
- `permissions`: check Screen Recording/Accessibility status
- `run`: execute `.peekaboo.json` scripts
- `sleep`: pause execution for a duration
- `tools`: list available tools with filtering/display options
Interaction
- `click`: target by ID/query/coords with smart waits
- `drag`: drag & drop across elements/coords/Dock
- `hotkey`: modifier combos like `cmd,shift,t`
- `move`: cursor positioning with optional smoothing
- `paste`: set clipboard -> paste -> restore
- `press`: special-key sequences with repeats
- `scroll`: directional scrolling (targeted + smooth)
- `swipe`: gesture-style drags between targets
- `type`: text + control keys (`--clear`, delays)
System
- `app`: launch/quit/relaunch/hide/unhide/switch/list apps
- `clipboard`: read/write clipboard (text/images/files)
- `dialog`: click/input/file/dismiss/list system dialogs
- `dock`: launch/right-click/hide/show/list Dock items
- `menu`: click/list application menus + menu extras
- `menubar`: list/click status bar items
- `open`: enhanced `open` with app targeting + JSON payloads
- `space`: list/switch/move-window (Spaces)
- `visualizer`: exercise Peekaboo visual feedback animations
- `window`: close/minimize/maximize/move/resize/focus/list
Vision
- `see`: annotated UI maps, snapshot IDs, optional analysis
Global runtime flags
- `--json`/`-j`, `--verbose`/`-v`, `--log-level <level>`
- `--no-remote`, `--bridge-socket <path>`
## Quickstart (happy path)
```bash
peekaboo permissions
peekaboo list apps --json
peekaboo see --annotate --path /tmp/peekaboo-see.png
peekaboo click --on B1
peekaboo type "Hello" --return
```
## Common targeting parameters (most interaction commands)
- App/window: `--app`, `--pid`, `--window-title`, `--window-id`, `--window-index`
- Snapshot targeting: `--snapshot` (ID from `see`; defaults to latest)
- Element/coords: `--on`/`--id` (element ID), `--coords x,y`
- Focus control: `--no-auto-focus`, `--space-switch`, `--bring-to-current-space`,
`--focus-timeout-seconds`, `--focus-retry-count`
## Common capture parameters
- Output: `--path`, `--format png|jpg`, `--retina`
- Targeting: `--mode screen|window|frontmost`, `--screen-index`,
`--window-title`, `--window-id`
- Analysis: `--analyze "prompt"`, `--annotate`
- Capture engine: `--capture-engine auto|classic|cg|modern|sckit`
## Common motion/typing parameters
- Timing: `--duration` (drag/swipe), `--steps`, `--delay` (type/scroll/press)
- Human-ish movement: `--profile human|linear`, `--wpm` (typing)
- Scroll: `--direction up|down|left|right`, `--amount <ticks>`, `--smooth`
## Examples
### See -> click -> type (most reliable flow)
```bash
peekaboo see --app Safari --window-title "Login" --annotate --path /tmp/see.png
peekaboo click --on B3 --app Safari
peekaboo type "user@example.com" --app Safari
peekaboo press tab --count 1 --app Safari
peekaboo type "supersecret" --app Safari --return
```
### Target by window id
```bash
peekaboo list windows --app "Visual Studio Code" --json
peekaboo click --window-id 12345 --coords 120,160
peekaboo type "Hello from Peekaboo" --window-id 12345
```
### Capture screenshots + analyze
```bash
peekaboo image --mode screen --screen-index 0 --retina --path /tmp/screen.png
peekaboo image --app Safari --window-title "Dashboard" --analyze "Summarize KPIs"
peekaboo see --mode screen --screen-index 0 --analyze "Summarize the dashboard"
```
### Live capture (motion-aware)
```bash
peekaboo capture live --mode region --region 100,100,800,600 --duration 30 \
--active-fps 8 --idle-fps 2 --highlight-changes --path /tmp/capture
```
### App + window management
```bash
peekaboo app launch "Safari" --open https://example.com
peekaboo window focus --app Safari --window-title "Example"
peekaboo window set-bounds --app Safari --x 50 --y 50 --width 1200 --height 800
peekaboo app quit --app Safari
```
### Menus, menubar, dock
```bash
peekaboo menu click --app Safari --item "New Window"
peekaboo menu click --app TextEdit --path "Format > Font > Show Fonts"
peekaboo menu click-extra --title "WiFi"
peekaboo dock launch Safari
peekaboo menubar list --json
```
### Mouse + gesture input
```bash
peekaboo move 500,300 --smooth
peekaboo drag --from B1 --to T2
peekaboo swipe --from-coords 100,500 --to-coords 100,200 --duration 800
peekaboo scroll --direction down --amount 6 --smooth
```
### Keyboard input
```bash
peekaboo hotkey --keys "cmd,shift,t"
peekaboo press escape
peekaboo type "Line 1\nLine 2" --delay 10
```
Notes
- Requires Screen Recording + Accessibility permissions.
- Use `peekaboo see --annotate` to identify targets before clicking.

62
.skills/sag/SKILL.md Normal file
View File

@@ -0,0 +1,62 @@
---
name: sag
description: ElevenLabs text-to-speech with mac-style say UX.
homepage: https://sag.sh
metadata: {"clawdbot":{"emoji":"🗣️","requires":{"bins":["sag"],"env":["ELEVENLABS_API_KEY"]},"primaryEnv":"ELEVENLABS_API_KEY","install":[{"id":"brew","kind":"brew","formula":"steipete/tap/sag","bins":["sag"],"label":"Install sag (brew)"}]}}
---
# sag
Use `sag` for ElevenLabs TTS with local playback.
API key (required)
- `ELEVENLABS_API_KEY` (preferred)
- `SAG_API_KEY` also supported by the CLI
Quick start
- `sag "Hello there"`
- `sag speak -v "Roger" "Hello"`
- `sag voices`
- `sag prompting` (model-specific tips)
Model notes
- Default: `eleven_v3` (expressive)
- Stable: `eleven_multilingual_v2`
- Fast: `eleven_flash_v2_5`
Pronunciation + delivery rules
- First fix: respell (e.g. "key-note"), add hyphens, adjust casing.
- Numbers/units/URLs: `--normalize auto` (or `off` if it harms names).
- Language bias: `--lang en|de|fr|...` to guide normalization.
- v3: SSML `<break>` not supported; use `[pause]`, `[short pause]`, `[long pause]`.
- v2/v2.5: SSML `<break time="1.5s" />` supported; `<phoneme>` not exposed in `sag`.
v3 audio tags (put at the entrance of a line)
- `[whispers]`, `[shouts]`, `[sings]`
- `[laughs]`, `[starts laughing]`, `[sighs]`, `[exhales]`
- `[sarcastic]`, `[curious]`, `[excited]`, `[crying]`, `[mischievously]`
- Example: `sag "[whispers] keep this quiet. [short pause] ok?"`
Voice defaults
- `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID`
Confirm voice + speaker before long output.
## Chat voice responses
When Peter asks for a "voice" reply (e.g., "crazy scientist voice", "explain in voice"), generate audio and send it:
```bash
# Generate audio file
sag -v Clawd -o /tmp/voice-reply.mp3 "Your message here"
# Then include in reply:
# MEDIA:/tmp/voice-reply.mp3
```
Voice character tips:
- Crazy scientist: Use `[excited]` tags, dramatic pauses `[short pause]`, vary intensity
- Calm: Use `[whispers]` or slower pacing
- Dramatic: Use `[sings]` or `[shouts]` sparingly
Default voice for Clawd: `lj2rcrvANS3gaWWnczSX` (or just `-v Clawd`)

View File

@@ -0,0 +1,49 @@
---
name: sherpa-onnx-tts
description: Local text-to-speech via sherpa-onnx (offline, no cloud)
metadata: {"clawdbot":{"emoji":"🗣️","os":["darwin","linux","win32"],"requires":{"env":["SHERPA_ONNX_RUNTIME_DIR","SHERPA_ONNX_MODEL_DIR"]},"install":[{"id":"download-runtime-macos","kind":"download","os":["darwin"],"url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.12.23/sherpa-onnx-v1.12.23-osx-universal2-shared.tar.bz2","archive":"tar.bz2","extract":true,"stripComponents":1,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/runtime","label":"Download sherpa-onnx runtime (macOS)"},{"id":"download-runtime-linux-x64","kind":"download","os":["linux"],"url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.12.23/sherpa-onnx-v1.12.23-linux-x64-shared.tar.bz2","archive":"tar.bz2","extract":true,"stripComponents":1,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/runtime","label":"Download sherpa-onnx runtime (Linux x64)"},{"id":"download-runtime-win-x64","kind":"download","os":["win32"],"url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.12.23/sherpa-onnx-v1.12.23-win-x64-shared.tar.bz2","archive":"tar.bz2","extract":true,"stripComponents":1,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/runtime","label":"Download sherpa-onnx runtime (Windows x64)"},{"id":"download-model-lessac","kind":"download","url":"https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-en_US-lessac-high.tar.bz2","archive":"tar.bz2","extract":true,"targetDir":"~/.clawdbot/tools/sherpa-onnx-tts/models","label":"Download Piper en_US lessac (high)"}]}}
---
# sherpa-onnx-tts
Local TTS using the sherpa-onnx offline CLI.
## Install
1) Download the runtime for your OS (extracts into `~/.clawdbot/tools/sherpa-onnx-tts/runtime`)
2) Download a voice model (extracts into `~/.clawdbot/tools/sherpa-onnx-tts/models`)
Update `~/.clawdbot/clawdbot.json`:
```json5
{
skills: {
entries: {
"sherpa-onnx-tts": {
env: {
SHERPA_ONNX_RUNTIME_DIR: "~/.clawdbot/tools/sherpa-onnx-tts/runtime",
SHERPA_ONNX_MODEL_DIR: "~/.clawdbot/tools/sherpa-onnx-tts/models/vits-piper-en_US-lessac-high"
}
}
}
}
}
```
The wrapper lives in this skill folder. Run it directly, or add the wrapper to PATH:
```bash
export PATH="{baseDir}/bin:$PATH"
```
## Usage
```bash
{baseDir}/bin/sherpa-onnx-tts -o ./tts.wav "Hello from local TTS."
```
Notes:
- Pick a different model from the sherpa-onnx `tts-models` release if you want another voice.
- If the model dir has multiple `.onnx` files, set `SHERPA_ONNX_MODEL_FILE` or pass `--model-file`.
- You can also pass `--tokens-file` or `--data-dir` to override the defaults.
- Windows: run `node {baseDir}\\bin\\sherpa-onnx-tts -o tts.wav "Hello from local TTS."`

View File

@@ -0,0 +1,178 @@
#!/usr/bin/env node
const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
function usage(message) {
if (message) {
console.error(message);
}
console.error(
"\nUsage: sherpa-onnx-tts [--runtime-dir <dir>] [--model-dir <dir>] [--model-file <file>] [--tokens-file <file>] [--data-dir <dir>] [--output <file>] \"text\"",
);
console.error("\nRequired env (or flags):\n SHERPA_ONNX_RUNTIME_DIR\n SHERPA_ONNX_MODEL_DIR");
process.exit(1);
}
function resolveRuntimeDir(explicit) {
const value = explicit || process.env.SHERPA_ONNX_RUNTIME_DIR || "";
return value.trim();
}
function resolveModelDir(explicit) {
const value = explicit || process.env.SHERPA_ONNX_MODEL_DIR || "";
return value.trim();
}
function resolveModelFile(modelDir, explicitFlag) {
const explicit = (explicitFlag || process.env.SHERPA_ONNX_MODEL_FILE || "").trim();
if (explicit) return explicit;
try {
const candidates = fs
.readdirSync(modelDir)
.filter((entry) => entry.endsWith(".onnx"))
.map((entry) => path.join(modelDir, entry));
if (candidates.length === 1) return candidates[0];
} catch {
return "";
}
return "";
}
function resolveTokensFile(modelDir, explicitFlag) {
const explicit = (explicitFlag || process.env.SHERPA_ONNX_TOKENS_FILE || "").trim();
if (explicit) return explicit;
const candidate = path.join(modelDir, "tokens.txt");
return fs.existsSync(candidate) ? candidate : "";
}
function resolveDataDir(modelDir, explicitFlag) {
const explicit = (explicitFlag || process.env.SHERPA_ONNX_DATA_DIR || "").trim();
if (explicit) return explicit;
const candidate = path.join(modelDir, "espeak-ng-data");
return fs.existsSync(candidate) ? candidate : "";
}
function resolveBinary(runtimeDir) {
const binName = process.platform === "win32" ? "sherpa-onnx-offline-tts.exe" : "sherpa-onnx-offline-tts";
return path.join(runtimeDir, "bin", binName);
}
function prependEnvPath(current, next) {
if (!next) return current;
if (!current) return next;
return `${next}${path.delimiter}${current}`;
}
const args = process.argv.slice(2);
let runtimeDir = "";
let modelDir = "";
let modelFile = "";
let tokensFile = "";
let dataDir = "";
let output = "tts.wav";
const textParts = [];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--runtime-dir") {
runtimeDir = args[i + 1] || "";
i += 1;
continue;
}
if (arg === "--model-dir") {
modelDir = args[i + 1] || "";
i += 1;
continue;
}
if (arg === "--model-file") {
modelFile = args[i + 1] || "";
i += 1;
continue;
}
if (arg === "--tokens-file") {
tokensFile = args[i + 1] || "";
i += 1;
continue;
}
if (arg === "--data-dir") {
dataDir = args[i + 1] || "";
i += 1;
continue;
}
if (arg === "-o" || arg === "--output") {
output = args[i + 1] || output;
i += 1;
continue;
}
if (arg === "--text") {
textParts.push(args[i + 1] || "");
i += 1;
continue;
}
textParts.push(arg);
}
runtimeDir = resolveRuntimeDir(runtimeDir);
modelDir = resolveModelDir(modelDir);
if (!runtimeDir || !modelDir) {
usage("Missing runtime/model directory.");
}
modelFile = resolveModelFile(modelDir, modelFile);
tokensFile = resolveTokensFile(modelDir, tokensFile);
dataDir = resolveDataDir(modelDir, dataDir);
if (!modelFile || !tokensFile || !dataDir) {
usage(
"Model directory is missing required files. Set SHERPA_ONNX_MODEL_FILE, SHERPA_ONNX_TOKENS_FILE, SHERPA_ONNX_DATA_DIR or pass --model-file/--tokens-file/--data-dir.",
);
}
const text = textParts.join(" ").trim();
if (!text) {
usage("Missing text.");
}
const bin = resolveBinary(runtimeDir);
if (!fs.existsSync(bin)) {
usage(`TTS binary not found: ${bin}`);
}
const env = { ...process.env };
const libDir = path.join(runtimeDir, "lib");
if (process.platform === "darwin") {
env.DYLD_LIBRARY_PATH = prependEnvPath(env.DYLD_LIBRARY_PATH || "", libDir);
} else if (process.platform === "win32") {
env.PATH = prependEnvPath(env.PATH || "", [path.join(runtimeDir, "bin"), libDir].join(path.delimiter));
} else {
env.LD_LIBRARY_PATH = prependEnvPath(env.LD_LIBRARY_PATH || "", libDir);
}
const outputPath = path.isAbsolute(output) ? output : path.join(process.cwd(), output);
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
const child = spawnSync(
bin,
[
`--vits-model=${modelFile}`,
`--vits-tokens=${tokensFile}`,
`--vits-data-dir=${dataDir}`,
`--output-filename=${outputPath}`,
text,
],
{
stdio: "inherit",
env,
},
);
if (typeof child.status === "number") {
process.exit(child.status);
}
if (child.error) {
console.error(child.error.message || String(child.error));
}
process.exit(1);

29
.skills/songsee/SKILL.md Normal file
View File

@@ -0,0 +1,29 @@
---
name: songsee
description: Generate spectrograms and feature-panel visualizations from audio with the songsee CLI.
homepage: https://github.com/steipete/songsee
metadata: {"clawdbot":{"emoji":"🌊","requires":{"bins":["songsee"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/songsee","bins":["songsee"],"label":"Install songsee (brew)"}]}}
---
# songsee
Generate spectrograms + feature panels from audio.
Quick start
- Spectrogram: `songsee track.mp3`
- Multi-panel: `songsee track.mp3 --viz spectrogram,mel,chroma,hpss,selfsim,loudness,tempogram,mfcc,flux`
- Time slice: `songsee track.mp3 --start 12.5 --duration 8 -o slice.jpg`
- Stdin: `cat track.mp3 | songsee - --format png -o out.png`
Common flags
- `--viz` list (repeatable or comma-separated)
- `--style` palette (classic, magma, inferno, viridis, gray)
- `--width` / `--height` output size
- `--window` / `--hop` FFT settings
- `--min-freq` / `--max-freq` frequency range
- `--start` / `--duration` time slice
- `--format` jpg|png
Notes
- WAV/MP3 decode native; other formats use ffmpeg if available.
- Multiple `--viz` renders a grid.

26
.skills/sonoscli/SKILL.md Normal file
View File

@@ -0,0 +1,26 @@
---
name: sonoscli
description: Control Sonos speakers (discover/status/play/volume/group).
homepage: https://sonoscli.sh
metadata: {"clawdbot":{"emoji":"🔊","requires":{"bins":["sonos"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/sonoscli/cmd/sonos@latest","bins":["sonos"],"label":"Install sonoscli (go)"}]}}
---
# Sonos CLI
Use `sonos` to control Sonos speakers on the local network.
Quick start
- `sonos discover`
- `sonos status --name "Kitchen"`
- `sonos play|pause|stop --name "Kitchen"`
- `sonos volume set 15 --name "Kitchen"`
Common tasks
- Grouping: `sonos group status|join|unjoin|party|solo`
- Favorites: `sonos favorites list|open`
- Queue: `sonos queue list|play|clear`
- Spotify search (via SMAPI): `sonos smapi search --service "Spotify" --category tracks "query"`
Notes
- If SSDP fails, specify `--ip <speaker-ip>`.
- Spotify Web API search is optional and requires `SPOTIFY_CLIENT_ID/SECRET`.

View File

@@ -0,0 +1,34 @@
---
name: spotify-player
description: Terminal Spotify playback/search via spogo (preferred) or spotify_player.
homepage: https://www.spotify.com
metadata: {"clawdbot":{"emoji":"🎵","requires":{"anyBins":["spogo","spotify_player"]},"install":[{"id":"brew","kind":"brew","formula":"spogo","tap":"steipete/tap","bins":["spogo"],"label":"Install spogo (brew)"},{"id":"brew","kind":"brew","formula":"spotify_player","bins":["spotify_player"],"label":"Install spotify_player (brew)"}]}}
---
# spogo / spotify_player
Use `spogo` **(preferred)** for Spotify playback/search. Fall back to `spotify_player` if needed.
Requirements
- Spotify Premium account.
- Either `spogo` or `spotify_player` installed.
spogo setup
- Import cookies: `spogo auth import --browser chrome`
Common CLI commands
- Search: `spogo search track "query"`
- Playback: `spogo play|pause|next|prev`
- Devices: `spogo device list`, `spogo device set "<name|id>"`
- Status: `spogo status`
spotify_player commands (fallback)
- Search: `spotify_player search "query"`
- Playback: `spotify_player playback play|pause|next|previous`
- Connect device: `spotify_player connect`
- Like track: `spotify_player like`
Notes
- Config folder: `~/.config/spotify-player` (e.g., `app.toml`).
- For Spotify Connect integration, set a user `client_id` in config.
- TUI shortcuts are available via `?` in the app.

View File

@@ -0,0 +1,67 @@
---
name: summarize
description: Summarize or extract text/transcripts from URLs, podcasts, and local files (great fallback for “transcribe this YouTube/video”).
homepage: https://summarize.sh
metadata: {"clawdbot":{"emoji":"🧾","requires":{"bins":["summarize"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/summarize","bins":["summarize"],"label":"Install summarize (brew)"}]}}
---
# Summarize
Fast CLI to summarize URLs, local files, and YouTube links.
## When to use (trigger phrases)
Use this skill immediately when the user asks any of:
- “use summarize.sh”
- “whats this link/video about?”
- “summarize this URL/article”
- “transcribe this YouTube/video” (best-effort transcript extraction; no `yt-dlp` needed)
## Quick start
```bash
summarize "https://example.com" --model google/gemini-3-flash-preview
summarize "/path/to/file.pdf" --model google/gemini-3-flash-preview
summarize "https://youtu.be/dQw4w9WgXcQ" --youtube auto
```
## YouTube: summary vs transcript
Best-effort transcript (URLs only):
```bash
summarize "https://youtu.be/dQw4w9WgXcQ" --youtube auto --extract-only
```
If the user asked for a transcript but its huge, return a tight summary first, then ask which section/time range to expand.
## Model + keys
Set the API key for your chosen provider:
- OpenAI: `OPENAI_API_KEY`
- Anthropic: `ANTHROPIC_API_KEY`
- xAI: `XAI_API_KEY`
- Google: `GEMINI_API_KEY` (aliases: `GOOGLE_GENERATIVE_AI_API_KEY`, `GOOGLE_API_KEY`)
Default model is `google/gemini-3-flash-preview` if none is set.
## Useful flags
- `--length short|medium|long|xl|xxl|<chars>`
- `--max-output-tokens <count>`
- `--extract-only` (URLs only)
- `--json` (machine readable)
- `--firecrawl auto|off|always` (fallback extraction)
- `--youtube auto` (Apify fallback if `APIFY_API_TOKEN` set)
## Config
Optional config file: `~/.summarize/config.json`
```json
{ "model": "openai/gpt-5.2" }
```
Optional services:
- `FIRECRAWL_API_KEY` for blocked sites
- `APIFY_API_TOKEN` for YouTube fallback

View File

@@ -0,0 +1,61 @@
---
name: things-mac
description: Manage Things 3 via the `things` CLI on macOS (add/update projects+todos via URL scheme; read/search/list from the local Things database). Use when a user asks Clawdbot to add a task to Things, list inbox/today/upcoming, search tasks, or inspect projects/areas/tags.
homepage: https://github.com/ossianhempel/things3-cli
metadata: {"clawdbot":{"emoji":"✅","os":["darwin"],"requires":{"bins":["things"]},"install":[{"id":"go","kind":"go","module":"github.com/ossianhempel/things3-cli/cmd/things@latest","bins":["things"],"label":"Install things3-cli (go)"}]}}
---
# Things 3 CLI
Use `things` to read your local Things database (inbox/today/search/projects/areas/tags) and to add/update todos via the Things URL scheme.
Setup
- Install (recommended, Apple Silicon): `GOBIN=/opt/homebrew/bin go install github.com/ossianhempel/things3-cli/cmd/things@latest`
- If DB reads fail: grant **Full Disk Access** to the calling app (Terminal for manual runs; `Clawdbot.app` for gateway runs).
- Optional: set `THINGSDB` (or pass `--db`) to point at your `ThingsData-*` folder.
- Optional: set `THINGS_AUTH_TOKEN` to avoid passing `--auth-token` for update ops.
Read-only (DB)
- `things inbox --limit 50`
- `things today`
- `things upcoming`
- `things search "query"`
- `things projects` / `things areas` / `things tags`
Write (URL scheme)
- Prefer safe preview: `things --dry-run add "Title"`
- Add: `things add "Title" --notes "..." --when today --deadline 2026-01-02`
- Bring Things to front: `things --foreground add "Title"`
Examples: add a todo
- Basic: `things add "Buy milk"`
- With notes: `things add "Buy milk" --notes "2% + bananas"`
- Into a project/area: `things add "Book flights" --list "Travel"`
- Into a project heading: `things add "Pack charger" --list "Travel" --heading "Before"`
- With tags: `things add "Call dentist" --tags "health,phone"`
- Checklist: `things add "Trip prep" --checklist-item "Passport" --checklist-item "Tickets"`
- From STDIN (multi-line => title + notes):
- `cat <<'EOF' | things add -`
- `Title line`
- `Notes line 1`
- `Notes line 2`
- `EOF`
Examples: modify a todo (needs auth token)
- First: get the ID (UUID column): `things search "milk" --limit 5`
- Auth: set `THINGS_AUTH_TOKEN` or pass `--auth-token <TOKEN>`
- Title: `things update --id <UUID> --auth-token <TOKEN> "New title"`
- Notes replace: `things update --id <UUID> --auth-token <TOKEN> --notes "New notes"`
- Notes append/prepend: `things update --id <UUID> --auth-token <TOKEN> --append-notes "..."` / `--prepend-notes "..."`
- Move lists: `things update --id <UUID> --auth-token <TOKEN> --list "Travel" --heading "Before"`
- Tags replace/add: `things update --id <UUID> --auth-token <TOKEN> --tags "a,b"` / `things update --id <UUID> --auth-token <TOKEN> --add-tags "a,b"`
- Complete/cancel (soft-delete-ish): `things update --id <UUID> --auth-token <TOKEN> --completed` / `--canceled`
- Safe preview: `things --dry-run update --id <UUID> --auth-token <TOKEN> --completed`
Delete a todo?
- Not supported by `things3-cli` right now (no “delete/move-to-trash” write command; `things trash` is read-only listing).
- Options: use Things UI to delete/trash, or mark as `--completed` / `--canceled` via `things update`.
Notes
- macOS-only.
- `--dry-run` prints the URL and does not open Things.

121
.skills/tmux/SKILL.md Normal file
View File

@@ -0,0 +1,121 @@
---
name: tmux
description: Remote-control tmux sessions for interactive CLIs by sending keystrokes and scraping pane output.
metadata: {"clawdbot":{"emoji":"🧵","os":["darwin","linux"],"requires":{"bins":["tmux"]}}}
---
# tmux Skill (Clawdbot)
Use tmux only when you need an interactive TTY. Prefer exec background mode for long-running, non-interactive tasks.
## Quickstart (isolated socket, exec tool)
```bash
SOCKET_DIR="${CLAWDBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/clawdbot-tmux-sockets}"
mkdir -p "$SOCKET_DIR"
SOCKET="$SOCKET_DIR/clawdbot.sock"
SESSION=clawdbot-python
tmux -S "$SOCKET" new -d -s "$SESSION" -n shell
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- 'PYTHON_BASIC_REPL=1 python3 -q' Enter
tmux -S "$SOCKET" capture-pane -p -J -t "$SESSION":0.0 -S -200
```
After starting a session, always print monitor commands:
```
To monitor:
tmux -S "$SOCKET" attach -t "$SESSION"
tmux -S "$SOCKET" capture-pane -p -J -t "$SESSION":0.0 -S -200
```
## Socket convention
- Use `CLAWDBOT_TMUX_SOCKET_DIR` (default `${TMPDIR:-/tmp}/clawdbot-tmux-sockets`).
- Default socket path: `"$CLAWDBOT_TMUX_SOCKET_DIR/clawdbot.sock"`.
## Targeting panes and naming
- Target format: `session:window.pane` (defaults to `:0.0`).
- Keep names short; avoid spaces.
- Inspect: `tmux -S "$SOCKET" list-sessions`, `tmux -S "$SOCKET" list-panes -a`.
## Finding sessions
- List sessions on your socket: `{baseDir}/scripts/find-sessions.sh -S "$SOCKET"`.
- Scan all sockets: `{baseDir}/scripts/find-sessions.sh --all` (uses `CLAWDBOT_TMUX_SOCKET_DIR`).
## Sending input safely
- Prefer literal sends: `tmux -S "$SOCKET" send-keys -t target -l -- "$cmd"`.
- Control keys: `tmux -S "$SOCKET" send-keys -t target C-c`.
## Watching output
- Capture recent history: `tmux -S "$SOCKET" capture-pane -p -J -t target -S -200`.
- Wait for prompts: `{baseDir}/scripts/wait-for-text.sh -t session:0.0 -p 'pattern'`.
- Attaching is OK; detach with `Ctrl+b d`.
## Spawning processes
- For python REPLs, set `PYTHON_BASIC_REPL=1` (non-basic REPL breaks send-keys flows).
## Windows / WSL
- tmux is supported on macOS/Linux. On Windows, use WSL and install tmux inside WSL.
- This skill is gated to `darwin`/`linux` and requires `tmux` on PATH.
## Orchestrating Coding Agents (Codex, Claude Code)
tmux excels at running multiple coding agents in parallel:
```bash
SOCKET="${TMPDIR:-/tmp}/codex-army.sock"
# Create multiple sessions
for i in 1 2 3 4 5; do
tmux -S "$SOCKET" new-session -d -s "agent-$i"
done
# Launch agents in different workdirs
tmux -S "$SOCKET" send-keys -t agent-1 "cd /tmp/project1 && codex --yolo 'Fix bug X'" Enter
tmux -S "$SOCKET" send-keys -t agent-2 "cd /tmp/project2 && codex --yolo 'Fix bug Y'" Enter
# Poll for completion (check if prompt returned)
for sess in agent-1 agent-2; do
if tmux -S "$SOCKET" capture-pane -p -t "$sess" -S -3 | grep -q ""; then
echo "$sess: DONE"
else
echo "$sess: Running..."
fi
done
# Get full output from completed session
tmux -S "$SOCKET" capture-pane -p -t agent-1 -S -500
```
**Tips:**
- Use separate git worktrees for parallel fixes (no branch conflicts)
- `pnpm install` first before running codex in fresh clones
- Check for shell prompt (`` or `$`) to detect completion
- Codex needs `--yolo` or `--full-auto` for non-interactive fixes
## Cleanup
- Kill a session: `tmux -S "$SOCKET" kill-session -t "$SESSION"`.
- Kill all sessions on a socket: `tmux -S "$SOCKET" list-sessions -F '#{session_name}' | xargs -r -n1 tmux -S "$SOCKET" kill-session -t`.
- Remove everything on the private socket: `tmux -S "$SOCKET" kill-server`.
## Helper: wait-for-text.sh
`{baseDir}/scripts/wait-for-text.sh` polls a pane for a regex (or fixed string) with a timeout.
```bash
{baseDir}/scripts/wait-for-text.sh -t session:0.0 -p 'pattern' [-F] [-T 20] [-i 0.5] [-l 2000]
```
- `-t`/`--target` pane target (required)
- `-p`/`--pattern` regex to match (required); add `-F` for fixed string
- `-T` timeout seconds (integer, default 15)
- `-i` poll interval seconds (default 0.5)
- `-l` history lines to search (integer, default 1000)

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage: find-sessions.sh [-L socket-name|-S socket-path|-A] [-q pattern]
List tmux sessions on a socket (default tmux socket if none provided).
Options:
-L, --socket tmux socket name (passed to tmux -L)
-S, --socket-path tmux socket path (passed to tmux -S)
-A, --all scan all sockets under CLAWDBOT_TMUX_SOCKET_DIR
-q, --query case-insensitive substring to filter session names
-h, --help show this help
USAGE
}
socket_name=""
socket_path=""
query=""
scan_all=false
socket_dir="${CLAWDBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/clawdbot-tmux-sockets}"
while [[ $# -gt 0 ]]; do
case "$1" in
-L|--socket) socket_name="${2-}"; shift 2 ;;
-S|--socket-path) socket_path="${2-}"; shift 2 ;;
-A|--all) scan_all=true; shift ;;
-q|--query) query="${2-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
esac
done
if [[ "$scan_all" == true && ( -n "$socket_name" || -n "$socket_path" ) ]]; then
echo "Cannot combine --all with -L or -S" >&2
exit 1
fi
if [[ -n "$socket_name" && -n "$socket_path" ]]; then
echo "Use either -L or -S, not both" >&2
exit 1
fi
if ! command -v tmux >/dev/null 2>&1; then
echo "tmux not found in PATH" >&2
exit 1
fi
list_sessions() {
local label="$1"; shift
local tmux_cmd=(tmux "$@")
if ! sessions="$("${tmux_cmd[@]}" list-sessions -F '#{session_name}\t#{session_attached}\t#{session_created_string}' 2>/dev/null)"; then
echo "No tmux server found on $label" >&2
return 1
fi
if [[ -n "$query" ]]; then
sessions="$(printf '%s\n' "$sessions" | grep -i -- "$query" || true)"
fi
if [[ -z "$sessions" ]]; then
echo "No sessions found on $label"
return 0
fi
echo "Sessions on $label:"
printf '%s\n' "$sessions" | while IFS=$'\t' read -r name attached created; do
attached_label=$([[ "$attached" == "1" ]] && echo "attached" || echo "detached")
printf ' - %s (%s, started %s)\n' "$name" "$attached_label" "$created"
done
}
if [[ "$scan_all" == true ]]; then
if [[ ! -d "$socket_dir" ]]; then
echo "Socket directory not found: $socket_dir" >&2
exit 1
fi
shopt -s nullglob
sockets=("$socket_dir"/*)
shopt -u nullglob
if [[ "${#sockets[@]}" -eq 0 ]]; then
echo "No sockets found under $socket_dir" >&2
exit 1
fi
exit_code=0
for sock in "${sockets[@]}"; do
if [[ ! -S "$sock" ]]; then
continue
fi
list_sessions "socket path '$sock'" -S "$sock" || exit_code=$?
done
exit "$exit_code"
fi
tmux_cmd=(tmux)
socket_label="default socket"
if [[ -n "$socket_name" ]]; then
tmux_cmd+=(-L "$socket_name")
socket_label="socket name '$socket_name'"
elif [[ -n "$socket_path" ]]; then
tmux_cmd+=(-S "$socket_path")
socket_label="socket path '$socket_path'"
fi
list_sessions "$socket_label" "${tmux_cmd[@]:1}"

View File

@@ -0,0 +1,83 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage: wait-for-text.sh -t target -p pattern [options]
Poll a tmux pane for text and exit when found.
Options:
-t, --target tmux target (session:window.pane), required
-p, --pattern regex pattern to look for, required
-F, --fixed treat pattern as a fixed string (grep -F)
-T, --timeout seconds to wait (integer, default: 15)
-i, --interval poll interval in seconds (default: 0.5)
-l, --lines number of history lines to inspect (integer, default: 1000)
-h, --help show this help
USAGE
}
target=""
pattern=""
grep_flag="-E"
timeout=15
interval=0.5
lines=1000
while [[ $# -gt 0 ]]; do
case "$1" in
-t|--target) target="${2-}"; shift 2 ;;
-p|--pattern) pattern="${2-}"; shift 2 ;;
-F|--fixed) grep_flag="-F"; shift ;;
-T|--timeout) timeout="${2-}"; shift 2 ;;
-i|--interval) interval="${2-}"; shift 2 ;;
-l|--lines) lines="${2-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
esac
done
if [[ -z "$target" || -z "$pattern" ]]; then
echo "target and pattern are required" >&2
usage
exit 1
fi
if ! [[ "$timeout" =~ ^[0-9]+$ ]]; then
echo "timeout must be an integer number of seconds" >&2
exit 1
fi
if ! [[ "$lines" =~ ^[0-9]+$ ]]; then
echo "lines must be an integer" >&2
exit 1
fi
if ! command -v tmux >/dev/null 2>&1; then
echo "tmux not found in PATH" >&2
exit 1
fi
# End time in epoch seconds (integer, good enough for polling)
start_epoch=$(date +%s)
deadline=$((start_epoch + timeout))
while true; do
# -J joins wrapped lines, -S uses negative index to read last N lines
pane_text="$(tmux capture-pane -p -J -t "$target" -S "-${lines}" 2>/dev/null || true)"
if printf '%s\n' "$pane_text" | grep $grep_flag -- "$pattern" >/dev/null 2>&1; then
exit 0
fi
now=$(date +%s)
if (( now >= deadline )); then
echo "Timed out after ${timeout}s waiting for pattern: $pattern" >&2
echo "Last ${lines} lines from $target:" >&2
printf '%s\n' "$pane_text" >&2
exit 1
fi
sleep "$interval"
done

84
.skills/trello/SKILL.md Normal file
View File

@@ -0,0 +1,84 @@
---
name: trello
description: Manage Trello boards, lists, and cards via the Trello REST API.
homepage: https://developer.atlassian.com/cloud/trello/rest/
metadata: {"clawdbot":{"emoji":"📋","requires":{"bins":["jq"],"env":["TRELLO_API_KEY","TRELLO_TOKEN"]}}}
---
# Trello Skill
Manage Trello boards, lists, and cards directly from Clawdbot.
## Setup
1. Get your API key: https://trello.com/app-key
2. Generate a token (click "Token" link on that page)
3. Set environment variables:
```bash
export TRELLO_API_KEY="your-api-key"
export TRELLO_TOKEN="your-token"
```
## Usage
All commands use curl to hit the Trello REST API.
### List boards
```bash
curl -s "https://api.trello.com/1/members/me/boards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" | jq '.[] | {name, id}'
```
### List lists in a board
```bash
curl -s "https://api.trello.com/1/boards/{boardId}/lists?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" | jq '.[] | {name, id}'
```
### List cards in a list
```bash
curl -s "https://api.trello.com/1/lists/{listId}/cards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" | jq '.[] | {name, id, desc}'
```
### Create a card
```bash
curl -s -X POST "https://api.trello.com/1/cards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" \
-d "idList={listId}" \
-d "name=Card Title" \
-d "desc=Card description"
```
### Move a card to another list
```bash
curl -s -X PUT "https://api.trello.com/1/cards/{cardId}?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" \
-d "idList={newListId}"
```
### Add a comment to a card
```bash
curl -s -X POST "https://api.trello.com/1/cards/{cardId}/actions/comments?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" \
-d "text=Your comment here"
```
### Archive a card
```bash
curl -s -X PUT "https://api.trello.com/1/cards/{cardId}?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" \
-d "closed=true"
```
## Notes
- Board/List/Card IDs can be found in the Trello URL or via the list commands
- The API key and token provide full access to your Trello account - keep them secret!
- Rate limits: 300 requests per 10 seconds per API key; 100 requests per 10 seconds per token; `/1/members` endpoints are limited to 100 requests per 900 seconds
## Examples
```bash
# Get all boards
curl -s "https://api.trello.com/1/members/me/boards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN&fields=name,id" | jq
# Find a specific board by name
curl -s "https://api.trello.com/1/members/me/boards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" | jq '.[] | select(.name | contains("Work"))'
# Get all cards on a board
curl -s "https://api.trello.com/1/boards/{boardId}/cards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" | jq '.[] | {name, list: .idList}'
```

View File

@@ -0,0 +1,29 @@
---
name: video-frames
description: Extract frames or short clips from videos using ffmpeg.
homepage: https://ffmpeg.org
metadata: {"clawdbot":{"emoji":"🎞️","requires":{"bins":["ffmpeg"]},"install":[{"id":"brew","kind":"brew","formula":"ffmpeg","bins":["ffmpeg"],"label":"Install ffmpeg (brew)"}]}}
---
# Video Frames (ffmpeg)
Extract a single frame from a video, or create quick thumbnails for inspection.
## Quick start
First frame:
```bash
{baseDir}/scripts/frame.sh /path/to/video.mp4 --out /tmp/frame.jpg
```
At a timestamp:
```bash
{baseDir}/scripts/frame.sh /path/to/video.mp4 --time 00:00:10 --out /tmp/frame-10s.jpg
```
## Notes
- Prefer `--time` for “what is happening around here?”.
- Use a `.jpg` for quick share; use `.png` for crisp UI frames.

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat >&2 <<'EOF'
Usage:
frame.sh <video-file> [--time HH:MM:SS] [--index N] --out /path/to/frame.jpg
Examples:
frame.sh video.mp4 --out /tmp/frame.jpg
frame.sh video.mp4 --time 00:00:10 --out /tmp/frame-10s.jpg
frame.sh video.mp4 --index 0 --out /tmp/frame0.png
EOF
exit 2
}
if [[ "${1:-}" == "" || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
fi
in="${1:-}"
shift || true
time=""
index=""
out=""
while [[ $# -gt 0 ]]; do
case "$1" in
--time)
time="${2:-}"
shift 2
;;
--index)
index="${2:-}"
shift 2
;;
--out)
out="${2:-}"
shift 2
;;
*)
echo "Unknown arg: $1" >&2
usage
;;
esac
done
if [[ ! -f "$in" ]]; then
echo "File not found: $in" >&2
exit 1
fi
if [[ "$out" == "" ]]; then
echo "Missing --out" >&2
usage
fi
mkdir -p "$(dirname "$out")"
if [[ "$index" != "" ]]; then
ffmpeg -hide_banner -loglevel error -y \
-i "$in" \
-vf "select=eq(n\\,${index})" \
-vframes 1 \
"$out"
elif [[ "$time" != "" ]]; then
ffmpeg -hide_banner -loglevel error -y \
-ss "$time" \
-i "$in" \
-frames:v 1 \
"$out"
else
ffmpeg -hide_banner -loglevel error -y \
-i "$in" \
-vf "select=eq(n\\,0)" \
-vframes 1 \
"$out"
fi
echo "$out"

42
.skills/wacli/SKILL.md Normal file
View File

@@ -0,0 +1,42 @@
---
name: wacli
description: Send WhatsApp messages to other people or search/sync WhatsApp history via the wacli CLI (not for normal user chats).
homepage: https://wacli.sh
metadata: {"clawdbot":{"emoji":"📱","requires":{"bins":["wacli"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/wacli","bins":["wacli"],"label":"Install wacli (brew)"},{"id":"go","kind":"go","module":"github.com/steipete/wacli/cmd/wacli@latest","bins":["wacli"],"label":"Install wacli (go)"}]}}
---
# wacli
Use `wacli` only when the user explicitly asks you to message someone else on WhatsApp or when they ask to sync/search WhatsApp history.
Do NOT use `wacli` for normal user chats; Clawdbot routes WhatsApp conversations automatically.
If the user is chatting with you on WhatsApp, you should not reach for this tool unless they ask you to contact a third party.
Safety
- Require explicit recipient + message text.
- Confirm recipient + message before sending.
- If anything is ambiguous, ask a clarifying question.
Auth + sync
- `wacli auth` (QR login + initial sync)
- `wacli sync --follow` (continuous sync)
- `wacli doctor`
Find chats + messages
- `wacli chats list --limit 20 --query "name or number"`
- `wacli messages search "query" --limit 20 --chat <jid>`
- `wacli messages search "invoice" --after 2025-01-01 --before 2025-12-31`
History backfill
- `wacli history backfill --chat <jid> --requests 2 --count 50`
Send
- Text: `wacli send text --to "+14155551212" --message "Hello! Are you free at 3pm?"`
- Group: `wacli send text --to "1234567890-123456789@g.us" --message "Running 5 min late."`
- File: `wacli send file --to "+14155551212" --file /path/agenda.pdf --caption "Agenda"`
Notes
- Store dir: `~/.wacli` (override with `--store`).
- Use `--json` for machine-readable output when parsing.
- Backfill requires your phone online; results are best-effort.
- WhatsApp CLI is not needed for routine user chats; its for messaging other people.
- JIDs: direct chats look like `<number>@s.whatsapp.net`; groups look like `<id>@g.us` (use `wacli chats list` to find).

49
.skills/weather/SKILL.md Normal file
View File

@@ -0,0 +1,49 @@
---
name: weather
description: Get current weather and forecasts (no API key required).
homepage: https://wttr.in/:help
metadata: {"clawdbot":{"emoji":"🌤️","requires":{"bins":["curl"]}}}
---
# Weather
Two free services, no API keys needed.
## wttr.in (primary)
Quick one-liner:
```bash
curl -s "wttr.in/London?format=3"
# Output: London: ⛅️ +8°C
```
Compact format:
```bash
curl -s "wttr.in/London?format=%l:+%c+%t+%h+%w"
# Output: London: ⛅️ +8°C 71% ↙5km/h
```
Full forecast:
```bash
curl -s "wttr.in/London?T"
```
Format codes: `%c` condition · `%t` temp · `%h` humidity · `%w` wind · `%l` location · `%m` moon
Tips:
- URL-encode spaces: `wttr.in/New+York`
- Airport codes: `wttr.in/JFK`
- Units: `?m` (metric) `?u` (USCS)
- Today only: `?1` · Current only: `?0`
- PNG: `curl -s "wttr.in/Berlin.png" -o /tmp/weather.png`
## Open-Meteo (fallback, JSON)
Free, no key, good for programmatic use:
```bash
curl -s "https://api.open-meteo.com/v1/forecast?latitude=51.5&longitude=-0.12&current_weather=true"
```
Find coordinates for a city, then query. Returns JSON with temp, windspeed, weathercode.
Docs: https://open-meteo.com/en/docs

450
README.md Normal file
View File

@@ -0,0 +1,450 @@
# LettaBot 👾
Your personal AI assistant that remembers everything across **Telegram, Slack, WhatsApp, and Signal**. Powered by [Letta Code](https://github.com/letta-ai/letta-code).
<img width="750" alt="lettabot-preview" src="https://github.com/user-attachments/assets/9f01b845-d5b0-447b-927d-ae15f9ec7511" />
## Features
- **Multi-Channel** - Chat seamlessly across Telegram, Slack, WhatsApp, and Signal
- **Unified Memory** - Single agent remembers everything from all channels
- **Persistent Memory** - Agent remembers conversations across sessions (days/weeks/months)
- **Local Tool Execution** - Agent can read files, search code, run commands on your machine
- **Heartbeat** - Periodic check-ins where the agent reviews tasks
- **Cron Jobs** - Agent can create its own scheduled tasks
- **Streaming Responses** - Real-time message updates as the agent thinks
## Quick Start
### Prerequisites
- Node.js 18+
- A Letta API key from [app.letta.com](https://app.letta.com)
- A Telegram bot token from [@BotFather](https://t.me/BotFather)
### Install from Source
```bash
# Clone the repository
git clone https://github.com/letta-ai/lettabot.git
cd lettabot
# Install dependencies
npm install
# Build
npm run build
# Link the CLI globally (optional)
npm link
```
### Onboard
Run the interactive onboarding wizard:
```bash
lettabot onboard
```
This will guide you through:
1. Setting up your Letta API key
2. Configuring Telegram (and optionally Slack)
3. Enabling heartbeat and cron jobs
4. Installing skills
### Run the Server
```bash
lettabot server
```
Or in development mode (auto-reload):
```bash
npm run dev
```
That's it! Message your bot on Telegram.
## CLI Commands
| Command | Description |
|---------|-------------|
| `lettabot onboard` | Interactive setup wizard |
| `lettabot server` | Start the bot server |
| `lettabot configure` | View and edit configuration |
| `lettabot skills` | Configure which skills are enabled |
| `lettabot skills status` | Show skills status |
| `lettabot help` | Show help |
## Multi-Channel Architecture
LettaBot uses a **single agent with a single conversation** across all channels:
```
Telegram ──┐
├──→ ONE AGENT ──→ ONE CONVERSATION
Slack ─────┤ (memory) (chat history)
WhatsApp ──┘
```
- Start a conversation on Telegram
- Continue it on Slack
- Pick it up on WhatsApp
- The agent remembers everything!
## Channel Setup
| Channel | Guide | Requirements |
|---------|-------|--------------|
| Telegram | [Setup Guide](docs/getting-started.md) | Bot token from @BotFather |
| Slack | [Setup Guide](docs/slack-setup.md) | Slack app with Socket Mode |
| WhatsApp | [Setup Guide](docs/whatsapp-setup.md) | Phone with WhatsApp |
| Signal | [Setup Guide](docs/signal-setup.md) | signal-cli + phone number |
At least one channel is required. Telegram is the easiest to start with.
## Configuration
### Basic (.env)
```bash
# Required: Letta API Key
LETTA_API_KEY=your_letta_api_key
# Telegram (easiest to start)
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
# Slack (optional)
SLACK_BOT_TOKEN=xoxb-your-bot-token
SLACK_APP_TOKEN=xapp-your-app-token
# WhatsApp (optional)
WHATSAPP_ENABLED=true
# Signal (optional)
SIGNAL_PHONE_NUMBER=+1XXXXXXXXXX
# Cron jobs (optional)
CRON_ENABLED=true
# Heartbeat - periodic check-ins (optional)
HEARTBEAT_INTERVAL_MIN=30
# HEARTBEAT_TARGET=telegram:123456789 # defaults to last messaged
```
### Full Configuration Reference
| Variable | Required | Description |
|----------|----------|-------------|
| `LETTA_API_KEY` | Yes | API key from app.letta.com |
| `TELEGRAM_BOT_TOKEN` | * | Bot token from @BotFather |
| `TELEGRAM_ALLOWED_USERS` | No | Comma-separated Telegram user IDs |
| `SLACK_BOT_TOKEN` | * | Slack bot token (xoxb-...) |
| `SLACK_APP_TOKEN` | * | Slack app token (xapp-...) |
| `SLACK_ALLOWED_USERS` | No | Comma-separated Slack user IDs |
| `WHATSAPP_ENABLED` | No | Set to `true` to enable WhatsApp |
| `WHATSAPP_SESSION_PATH` | No | Path to store WhatsApp session |
| `WHATSAPP_ALLOWED_USERS` | No | Comma-separated phone numbers (+1...) |
| `SIGNAL_PHONE_NUMBER` | * | Phone number registered with signal-cli |
| `SIGNAL_CLI_PATH` | No | Path to signal-cli binary |
| `SIGNAL_DM_POLICY` | No | pairing/allowlist/open (default: pairing) |
| `WORKING_DIR` | No | Agent workspace (default: `/tmp/lettabot`) |
| `CRON_ENABLED` | No | Enable scheduled tasks |
| `HEARTBEAT_INTERVAL_MIN` | No | Heartbeat interval in minutes (e.g., `30`) |
| `HEARTBEAT_TARGET` | No | Where to deliver (e.g., `telegram:123456789`) |
\* At least one channel must be configured
## Commands
| Command | Description |
|---------|-------------|
| `/start` | Welcome message and help |
| `/new` | New conversation (keeps agent memory) |
| `/reset` | Fresh agent (clears all memory) |
| `/status` | Show current session info |
| `/skills` | List installed skills |
## Skills
LettaBot comes with 40+ builtin skills and supports installing more from [ClawdHub](https://clawdhub.com).
### Builtin Skills
Skills are copied to `{WORKING_DIR}/.skills/` on server startup (where Letta Code discovers them):
| Category | Skills |
|----------|--------|
| **Productivity** | 1password, apple-notes, apple-reminders, bear-notes, notion, obsidian, things-mac, trello |
| **Communication** | bird (Twitter), himalaya (email), imsg (iMessage), wacli (WhatsApp) |
| **Google** | google (Gmail, Calendar, Drive, Sheets, Docs), goplaces |
| **Media** | camsnap, gifgrep, nano-pdf, openai-image-gen, openai-whisper, summarize, video-frames |
| **Smart Home** | blucli, eightctl, openhue, sonoscli, spotify-player |
| **Dev Tools** | gemini, github, mcporter, oracle, peekaboo, tmux |
| **Utilities** | blogwatcher, cron, weather |
### Install from ClawdHub
```bash
# Search for skills
npm run skill:search weather
# Install a skill
npm run skill:install gog # Google Workspace (Gmail, Calendar, Drive)
npm run skill:install bird # Twitter/X
npm run skill:install obsidian # Obsidian notes
npm run skill:install sonoscli # Sonos speakers
# List installed skills
npm run skill:list
```
Browse all skills: https://clawdhub.com
### Install from skills.sh
LettaBot is also compatible with [skills.sh](https://skills.sh) - the open agent skills ecosystem with 29K+ installs.
```bash
# Interactive search
npm run skills:find
# Install popular skill packs
npm run skills:add vercel-labs/agent-skills # React/Next.js best practices
npm run skills:add anthropics/skills # Frontend design, docs, PDFs
npm run skills:add expo/skills # React Native/Expo
npm run skills:add supabase/agent-skills # Supabase/Postgres
# Or use npx directly
npx skills add coreyhaines31/marketingskills --global --yes
```
Browse all skills: https://skills.sh
### Configuring Skills
Skills installed via ClawdHub or skills.sh are not automatically available to LettaBot. You need to configure which skills to enable:
```bash
lettabot skills
```
This shows an interactive checklist of all available skills (from ClawdHub, skills.sh, and builtin). Select which skills to enable for your agent - checked skills will be available, unchecked skills will be removed.
**Workflow:**
1. Install skills via ClawdHub or skills.sh (see above)
2. Run `lettabot skills` to configure which ones to enable
3. Restart the server (or the agent will pick them up on next interaction)
## Heartbeat & Cron Jobs
### Heartbeat
LettaBot can periodically check in with you, reviewing a `HEARTBEAT.md` file for tasks:
```bash
# Enable heartbeat (every 30 minutes)
HEARTBEAT_INTERVAL_MIN=30
# Optional: specify delivery target (defaults to last messaged chat)
HEARTBEAT_TARGET=telegram:123456789
```
How it works:
1. Every N minutes, the system reads `HEARTBEAT.md` and sends its contents to the agent
2. The agent reviews the tasks and decides what to do
3. If nothing to do → agent calls `ignore()` tool → **response not delivered to you** (no spam!)
4. If there's something to report → agent's response is delivered to your chat
### The `ignore()` Tool
The agent has an `ignore()` tool for skipping responses:
```python
ignore(reason="Nothing to report") # Skips delivery
```
This is useful for:
- Heartbeat check-ins with nothing to report
- Messages not directed at the agent
- Any situation where no response is needed
Setup the tool:
```bash
npm run tools:setup # Upserts ignore() tool to Letta API
```
Edit `HEARTBEAT.md` in your working directory to add tasks you want to be reminded about.
### Cron Jobs
The agent can create its own scheduled tasks:
```bash
# Enable cron service
CRON_ENABLED=true
```
The agent uses the `lettabot-cron` CLI to manage jobs:
```bash
# Create a daily morning briefing
lettabot-cron create -n "Morning" -s "0 8 * * *" -m "Good morning! What's on the agenda?"
# List all jobs
lettabot-cron list
# Delete a job
lettabot-cron delete <job-id>
```
Jobs are stored in `cron-jobs.json` and auto-reload when changed. Responses are delivered to the last messaged chat (or specify `--deliver telegram:123456789`).
## Security
### Network Architecture
**LettaBot uses outbound connections only** - no public URL or gateway required:
| Channel | Connection Type | Exposed Ports |
|---------|-----------------|---------------|
| Telegram | Long-polling (outbound HTTP) | None |
| Slack | Socket Mode (outbound WebSocket) | None |
| WhatsApp | Outbound WebSocket via Baileys | None |
| Signal | Local daemon on 127.0.0.1 | None (localhost only) |
This is safer than webhook-based architectures that require exposing a public HTTP server.
### Tool Execution
LettaBot can execute tools on your machine. By default, it's restricted to **read-only** operations:
- `Read`, `Glob`, `Grep` - File exploration
- `web_search` - Internet queries
- `conversation_search` - Search past messages
### Restricting Access
Set allowed users per channel in `.env`:
```bash
TELEGRAM_ALLOWED_USERS=123456789,987654321
SLACK_ALLOWED_USERS=U01234567,U98765432
WHATSAPP_ALLOWED_USERS=+15551234567
SIGNAL_ALLOWED_USERS=+15551234567
```
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ LettaBot Core │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Telegram │ │ Slack │ │ WhatsApp │ │ Signal │ │
│ │ (grammY) │ │ (Bolt) │ │ (Baileys)│ │(signal-cli)│ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ └─────────────┼─────────────┼─────────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Letta Agent │ ◄── Single agent, │
│ │ (Memory) │ single conversation │
│ └────────┬────────┘ │
└───────────────────────┼─────────────────────────────────────┘
Letta Code SDK → CLI → Local Tools
Letta Server (Memory + LLM)
```
## Development
```bash
# Run in development mode (uses Letta Cloud)
npm run dev
# Build for production
npm run build
npm start
# Run setup wizard
npm run setup
```
### Local Letta Server
To use a local Letta server instead of Letta Cloud:
```bash
# Point to local server
LETTA_BASE_URL=http://localhost:8283 npm run dev
# With a specific agent ID (useful if agent already exists)
LETTA_BASE_URL=http://localhost:8283 LETTA_AGENT_ID=agent-xxx npm run dev
```
The `LETTA_AGENT_ID` env var overrides the stored agent ID, useful for testing with different agents or servers.
## Troubleshooting
### WhatsApp
**Session errors / "Bad MAC" messages**
These are normal Signal Protocol renegotiation messages. They're noisy but harmless - WhatsApp will still work.
**Messages going to wrong chat**
If using selfChatMode and messages go to the wrong place, clear the session and re-link:
```bash
rm -rf ./data/whatsapp-session
npm run dev # Scan QR again
```
**Competing with another WhatsApp client**
If you have clawdbot/moltbot running, it will compete for WhatsApp. Stop it first:
```bash
launchctl unload ~/Library/LaunchAgents/com.clawdbot.gateway.plist
```
### Signal
**Port 8090 already in use**
Change the port in `.env`:
```bash
SIGNAL_HTTP_PORT=8091
```
**Daemon won't start**
Make sure signal-cli is in your PATH or set the full path:
```bash
SIGNAL_CLI_PATH=/opt/homebrew/bin/signal-cli
```
### General
**Agent not responding**
Check if the agent ID is correct in `lettabot-agent.json`. If corrupted, delete it to create a fresh agent:
```bash
rm lettabot-agent.json
npm run dev
```
**Messages queuing up / slow responses**
Messages are processed one at a time to prevent SDK issues. If many messages arrive at once, they'll queue.
## Documentation
- [Slack Setup](docs/slack-setup.md)
- [WhatsApp Setup](docs/whatsapp-setup.md)
- [Signal Setup](docs/signal-setup.md)
- [Architecture](ARCHITECTURE.md)
- [Implementation Plan](PLAN.md)
## Acknowledgments
Some builtin skills are adapted from [ClawdBot](https://github.com/anthropics/clawdbot) (MIT License, Copyright © 2025 Peter Steinberger).
## License
Apache-2.0

61
docs/README.md Normal file
View File

@@ -0,0 +1,61 @@
# LettaBot Documentation
LettaBot is a Telegram bot powered by [Letta](https://letta.com) that provides persistent memory and local tool execution.
## Guides
- [Getting Started](./getting-started.md) - Installation and basic setup
- [Gmail Pub/Sub](./gmail-pubsub.md) - Email notifications integration
- [Commands](./commands.md) - Bot commands reference
## Architecture
```
┌──────────────────────────────────────────────────────────────────────┐
│ Your Server / Machine │
│ │
│ ┌────────────────┐ ┌───────────────────────────────────────┐ │
│ │ Telegram │ │ LettaBot Core │ │
│ │ Bot API │◀──────▶│ (TypeScript/Node) │ │
│ │ (grammY) │ │ │ │
│ └────────────────┘ │ ┌─────────────────────────────────┐ │ │
│ │ │ Session Manager │ │ │
│ │ │ userId → agentId (persisted) │ │ │
│ │ └─────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────┐ │ │
│ │ │ Letta Code SDK │ │ │
│ │ │ createSession/resumeSession │ │ │
│ │ └──────────────┬──────────────────┘ │ │
│ └─────────────────┼─────────────────────┘ │
│ │ spawn subprocess │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ Letta Code CLI │ │
│ │ (--input-format stream-json) │ │
│ │ │ │
│ │ Local Tool Execution: │ │
│ │ • Read/Glob/Grep - file ops │ │
│ │ • Task - spawn subagents │ │
│ │ • web_search - internet queries │ │
│ └──────────────────┬───────────────────┘ │
└───────────────────────────────────────────────┼───────────────────────┘
│ Letta API
┌──────────────────────────────────┐
│ Letta Server │
│ (api.letta.com or self-hosted) │
│ │
│ • Agent Memory (persistent) │
│ • LLM Inference │
│ • Conversation History │
└──────────────────────────────────┘
```
## Key Features
- **Persistent Memory** - Your agent remembers conversations across days/weeks/months
- **Local Tool Execution** - Agent can search files, run commands on your machine
- **Multi-user Support** - Each Telegram user gets their own persistent agent
- **Streaming Responses** - Real-time message updates as the agent thinks
- **Gmail Integration** - Get email summaries delivered to Telegram

91
docs/commands.md Normal file
View File

@@ -0,0 +1,91 @@
# Commands Reference
LettaBot responds to these slash commands in Telegram.
## Available Commands
### `/start` or `/help`
Shows the welcome message and list of available commands.
```
🤖 LettaBot - AI assistant with persistent memory
Commands:
/new - Start a new conversation (keeps memory)
/reset - Create a new agent (fresh memory)
/status - Show current agent ID
/help - Show this message
Just send me a message to get started!
```
### `/new`
Starts a new conversation while keeping the same agent and memory.
Use this when you want to change topics but keep your agent's memory of who you are and past interactions.
**Example:**
```
You: /new
Bot: Started a new conversation. Your agent still remembers you!
You: Let's talk about something different now.
```
### `/reset`
Creates a completely fresh agent with no memory.
Use this if you want to start over from scratch, as if you've never talked to the bot before.
**Warning:** This permanently deletes your agent's memory of past conversations.
**Example:**
```
You: /reset
Bot: Created a fresh agent with no memory. Send a message to begin!
```
### `/status`
Shows your current agent ID.
Useful for debugging or if you need to reference your agent in other tools.
**Example:**
```
You: /status
Bot: Current agent: agent-a1b2c3d4-e5f6-7890-abcd-ef1234567890
```
## Sending Messages
Just type any message to chat with your agent. The agent has:
- **Persistent memory** - Remembers your conversations over time
- **Tool access** - Can search files, browse the web, and more
- **Streaming responses** - You'll see the response appear in real-time
**Tips:**
- Be specific in your requests
- The agent remembers context, so you can refer back to previous conversations
- For long tasks, the "typing..." indicator will stay active
## Formatting
The bot supports markdown formatting in responses:
- **Bold** text
- *Italic* text
- `Inline code`
- ```Code blocks```
- [Links](https://example.com)
## Future Commands
These commands are planned for future releases:
- `/model <name>` - Switch the LLM model
- `/verbose` - Toggle tool output visibility
- `/context` - Show memory summary

100
docs/getting-started.md Normal file
View File

@@ -0,0 +1,100 @@
# Getting Started
Get LettaBot running in 5 minutes.
## Prerequisites
- Node.js 18+
- npm or yarn
- A Telegram account
- A Letta account ([app.letta.com](https://app.letta.com))
## Quick Start
### 1. Clone and Install
```bash
git clone https://github.com/yourusername/lettabot.git
cd lettabot
npm install
```
### 2. Create a Telegram Bot
1. Open Telegram and message [@BotFather](https://t.me/BotFather)
2. Send `/newbot` and follow the prompts
3. Copy the **bot token** (looks like `123456789:ABCdefGHIjklMNOpqrsTUVwxyz`)
### 3. Get a Letta API Key
1. Go to [app.letta.com](https://app.letta.com)
2. Sign in or create an account
3. Go to Settings > API Keys
4. Create a new API key and copy it
### 4. Configure LettaBot
**Option A: Interactive Setup (Recommended)**
```bash
npm run setup
```
This will walk you through configuration interactively.
**Option B: Manual Setup**
```bash
cp .env.example .env
```
Edit `.env`:
```bash
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
LETTA_API_KEY=your_letta_api_key
```
### 5. Start the Bot
```bash
npm run dev
```
You should see:
```
Starting LettaBot...
Bot started as @your_bot_name
Allowed users: all
```
### 6. Chat with Your Bot
Open Telegram and message your bot. Try:
- "Hello!"
- "What can you help me with?"
- "Remember that my favorite color is blue"
## Configuration Options
| Variable | Required | Description |
|----------|----------|-------------|
| `TELEGRAM_BOT_TOKEN` | Yes | From @BotFather |
| `LETTA_API_KEY` | Yes | From app.letta.com |
| `ALLOWED_USERS` | No | Comma-separated Telegram user IDs to allow |
| `WORKING_DIR` | No | Base directory for agent workspaces (default: `/tmp/lettabot`) |
| `LETTA_CLI_PATH` | No | Custom path to letta CLI |
## Restricting Access
To limit who can use your bot, set `ALLOWED_USERS`:
```bash
# Find your Telegram user ID by messaging @userinfobot
ALLOWED_USERS=123456789,987654321
```
## Next Steps
- [Commands Reference](./commands.md) - Learn all bot commands
- [Gmail Integration](./gmail-pubsub.md) - Set up email notifications
- Check out [PLAN.md](../PLAN.md) for the full roadmap

273
docs/gmail-pubsub.md Normal file
View File

@@ -0,0 +1,273 @@
# Gmail Pub/Sub Integration
Receive email notifications and have your Letta agent process them automatically.
## Overview
```
┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ ┌─────────────┐
│ Gmail │───▶│ Google Cloud │───▶│ LettaBot │───▶│ Letta │
│ Inbox │ │ Pub/Sub │ │ Webhook Server │ │ Agent │
└─────────────┘ └──────────────┘ └─────────────────┘ └──────┬──────┘
┌─────────────┐
│ Telegram │
└─────────────┘
```
When a new email arrives:
1. Gmail sends a notification to Google Cloud Pub/Sub
2. Pub/Sub pushes the notification to LettaBot's webhook
3. LettaBot fetches the email details via Gmail API
4. The email is sent to your Letta agent for processing
5. The agent's response is delivered to you on Telegram
## Prerequisites
- Google Cloud account with billing enabled
- Gmail account you want to monitor
- LettaBot running with a public URL (for Pub/Sub push)
- `gcloud` CLI installed ([install guide](https://cloud.google.com/sdk/docs/install))
## Setup Guide
### Step 1: Create a Google Cloud Project
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project or select an existing one
3. Note your **Project ID** (you'll need it later)
### Step 2: Enable Required APIs
```bash
# Set your project
gcloud config set project YOUR_PROJECT_ID
# Enable Gmail and Pub/Sub APIs
gcloud services enable gmail.googleapis.com pubsub.googleapis.com
```
### Step 3: Create OAuth2 Credentials
1. Go to [APIs & Services > Credentials](https://console.cloud.google.com/apis/credentials)
2. Click **Create Credentials** > **OAuth client ID**
3. Select **Desktop app** as the application type
4. Name it "LettaBot Gmail"
5. Download the JSON file
6. Note the **Client ID** and **Client Secret**
### Step 4: Get a Refresh Token
You need to authorize LettaBot to access your Gmail. Use this script:
```bash
# Install the Google auth library
npm install googleapis
# Run this script to get a refresh token
node -e "
const { google } = require('googleapis');
const readline = require('readline');
const oauth2Client = new google.auth.OAuth2(
'YOUR_CLIENT_ID',
'YOUR_CLIENT_SECRET',
'urn:ietf:wg:oauth:2.0:oob'
);
const authUrl = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: ['https://www.googleapis.com/auth/gmail.readonly'],
});
console.log('Authorize this app by visiting:', authUrl);
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
rl.question('Enter the code: ', async (code) => {
const { tokens } = await oauth2Client.getToken(code);
console.log('Refresh Token:', tokens.refresh_token);
rl.close();
});
"
```
Save the **Refresh Token** - you'll need it for configuration.
### Step 5: Create Pub/Sub Topic and Subscription
```bash
# Create a topic for Gmail notifications
gcloud pubsub topics create lettabot-gmail
# Grant Gmail permission to publish to the topic
gcloud pubsub topics add-iam-policy-binding lettabot-gmail \
--member="serviceAccount:gmail-api-push@system.gserviceaccount.com" \
--role="roles/pubsub.publisher"
```
### Step 6: Configure LettaBot
Add these to your `.env` file:
```bash
# Enable Gmail integration
GMAIL_ENABLED=true
# Webhook server port (must be accessible from internet)
GMAIL_WEBHOOK_PORT=8788
# Shared secret for validating Pub/Sub requests (generate a random string)
GMAIL_WEBHOOK_TOKEN=your_random_secret_here
# OAuth2 credentials from Step 3
GMAIL_CLIENT_ID=your_client_id.apps.googleusercontent.com
GMAIL_CLIENT_SECRET=your_client_secret
# Refresh token from Step 4
GMAIL_REFRESH_TOKEN=your_refresh_token
# Your Telegram user ID (to receive email notifications)
# Find this by messaging @userinfobot on Telegram
GMAIL_TELEGRAM_USER=123456789
```
### Step 7: Expose Webhook to Internet
LettaBot's webhook server needs to be accessible from the internet for Pub/Sub to push notifications.
**Option A: Using Tailscale Funnel (recommended)**
```bash
tailscale funnel 8788
```
**Option B: Using ngrok**
```bash
ngrok http 8788
```
**Option C: Using Cloudflare Tunnel**
```bash
cloudflared tunnel --url http://localhost:8788
```
Note your public URL (e.g., `https://your-domain.ts.net` or `https://abc123.ngrok.io`).
### Step 8: Create Pub/Sub Push Subscription
```bash
# Replace with your actual public URL and token
gcloud pubsub subscriptions create lettabot-gmail-push \
--topic=lettabot-gmail \
--push-endpoint="https://YOUR_PUBLIC_URL/webhooks/gmail?token=YOUR_WEBHOOK_TOKEN" \
--ack-deadline=60
```
### Step 9: Start Gmail Watch
You need to tell Gmail to send notifications to your Pub/Sub topic. This watch expires after 7 days and needs to be renewed.
**Using the Gmail API directly:**
```bash
curl -X POST \
'https://gmail.googleapis.com/gmail/v1/users/me/watch' \
-H "Authorization: Bearer $(gcloud auth print-access-token)" \
-H 'Content-Type: application/json' \
-d '{
"topicName": "projects/YOUR_PROJECT_ID/topics/lettabot-gmail",
"labelIds": ["INBOX"]
}'
```
**Using a helper script:**
```javascript
// watch-gmail.js
const { google } = require('googleapis');
const oauth2Client = new google.auth.OAuth2(
process.env.GMAIL_CLIENT_ID,
process.env.GMAIL_CLIENT_SECRET
);
oauth2Client.setCredentials({ refresh_token: process.env.GMAIL_REFRESH_TOKEN });
const gmail = google.gmail({ version: 'v1', auth: oauth2Client });
async function startWatch() {
const res = await gmail.users.watch({
userId: 'me',
requestBody: {
topicName: 'projects/YOUR_PROJECT_ID/topics/lettabot-gmail',
labelIds: ['INBOX'],
},
});
console.log('Watch started:', res.data);
console.log('Expires:', new Date(parseInt(res.data.expiration)));
}
startWatch();
```
### Step 10: Start LettaBot
```bash
cd /path/to/lettabot
npm run dev
```
You should see:
```
Starting LettaBot...
Bot started as @your_bot
Gmail webhook enabled on port 8788
Gmail webhook server listening on port 8788
```
## Testing
1. Send a test email to your Gmail account
2. Check LettaBot logs for the notification
3. You should receive a Telegram message with the agent's summary
## Troubleshooting
### "Invalid token" error
- Make sure `GMAIL_WEBHOOK_TOKEN` matches the `?token=` in your Pub/Sub subscription
### No notifications received
- Verify your public URL is accessible: `curl https://YOUR_URL/health`
- Check Pub/Sub subscription for errors in Google Cloud Console
- Make sure Gmail watch is active (re-run the watch command)
### "User not authorized" error
- Ensure you granted `roles/pubsub.publisher` to `gmail-api-push@system.gserviceaccount.com`
### Watch expired
- Gmail watches expire after 7 days
- Set up a cron job to renew: `0 0 * * * node watch-gmail.js`
## Cleanup
To disable Gmail integration:
```bash
# Stop the watch
curl -X POST \
'https://gmail.googleapis.com/gmail/v1/users/me/stop' \
-H "Authorization: Bearer $(gcloud auth print-access-token)"
# Delete Pub/Sub resources
gcloud pubsub subscriptions delete lettabot-gmail-push
gcloud pubsub topics delete lettabot-gmail
# Remove from .env
# GMAIL_ENABLED=false
```
## Security Considerations
- Keep your `GMAIL_WEBHOOK_TOKEN` secret
- The refresh token grants read access to your Gmail - store it securely
- Consider using a service account for production deployments
- LettaBot only reads emails, it cannot send or modify them

114
docs/signal-setup.md Normal file
View File

@@ -0,0 +1,114 @@
# Signal Setup Guide
LettaBot can connect to Signal using [signal-cli](https://github.com/AsamK/signal-cli), a command-line interface for Signal.
## Prerequisites
### 1. Install signal-cli
**macOS (Homebrew):**
```bash
brew install signal-cli
```
**Linux:**
```bash
# Download latest release from https://github.com/AsamK/signal-cli/releases
# Extract and add to PATH
```
### 2. Register Your Phone Number
You need a phone number that can receive SMS for verification.
```bash
# Request verification code (sent via SMS)
signal-cli -a +1XXXXXXXXXX register
# Enter the code you receive
signal-cli -a +1XXXXXXXXXX verify CODE
```
**Note:** You can only have one Signal client per number. Registering signal-cli will log out your Signal mobile app. Consider using a secondary number.
## Configuration
Add to your `.env`:
```bash
# Required: Phone number you registered
SIGNAL_PHONE_NUMBER=+17075204676
# Optional: Path to signal-cli (if not in PATH)
# SIGNAL_CLI_PATH=/usr/local/bin/signal-cli
# Optional: HTTP daemon settings (default: 127.0.0.1:8090)
# SIGNAL_HTTP_HOST=127.0.0.1
# SIGNAL_HTTP_PORT=8090
# Optional: DM access policy (default: pairing)
# SIGNAL_DM_POLICY=pairing
# Optional: Self-chat mode for "Note to Self" (default: true)
# SIGNAL_SELF_CHAT_MODE=true
```
## How It Works
LettaBot automatically:
1. Starts signal-cli in daemon mode (JSON-RPC over HTTP)
2. Connects via Server-Sent Events (SSE) for incoming messages
3. Sends replies via JSON-RPC
The daemon runs on port 8090 by default to avoid conflicts with other services.
## Features
- **Direct Messages** - Receive and respond to DMs
- **Note to Self** - Use Signal's "Note to Self" feature to message yourself (selfChatMode)
- **Pairing System** - Unknown senders get a pairing code (same as Telegram)
## Troubleshooting
### Port Conflict
If port 8090 is in use, change it:
```bash
SIGNAL_HTTP_PORT=8091
```
### Daemon Won't Start
Check if signal-cli is in your PATH:
```bash
which signal-cli
```
If not, set the full path:
```bash
SIGNAL_CLI_PATH=/opt/homebrew/bin/signal-cli
```
### "Note to Self" Not Working
Messages you send to yourself appear via `syncMessage.sentMessage`, not `dataMessage`. LettaBot handles this automatically when `SIGNAL_SELF_CHAT_MODE=true` (the default).
### Registration Issues
If you get errors during registration:
1. Make sure the number can receive SMS
2. Try with `--captcha` if prompted
3. Check signal-cli GitHub issues for common problems
## Architecture
```
┌────────────────┐ HTTP ┌──────────────┐
│ LettaBot │◄────────────►│ signal-cli │
│ (Signal.ts) │ (JSON-RPC) │ (daemon) │
└────────────────┘ └──────┬───────┘
│ │
│ SSE (events) │ Signal Protocol
│◄──────────────────────────────┤
│ ▼
│ ┌──────────────┐
│ │ Signal │
│ │ Servers │
│ └──────────────┘
```

215
docs/slack-setup.md Normal file
View File

@@ -0,0 +1,215 @@
# Slack Setup for LettaBot
This guide walks you through setting up Slack as a channel for LettaBot.
## Overview
LettaBot connects to Slack using **Socket Mode**, which means:
- No public URL required (no ngrok needed)
- Works behind firewalls
- Real-time bidirectional communication
## Prerequisites
- A Slack workspace where you have permission to install apps
- LettaBot installed and configured with at least `LETTA_API_KEY`
## Step 1: Create a Slack App
1. Go to **https://api.slack.com/apps**
2. Click **"Create New App"**
3. Choose **"From scratch"**
4. Enter:
- **App Name**: `LettaBot` (or your preferred name)
- **Workspace**: Select your workspace
5. Click **"Create App"**
## Step 2: Enable Socket Mode
Socket Mode lets your bot connect without exposing a public endpoint.
1. In the left sidebar, click **"Socket Mode"**
2. Toggle **"Enable Socket Mode"** → ON
3. You'll be prompted to create an **App-Level Token**:
- **Token Name**: `socket-token`
- **Scopes**: Add `connections:write`
- Click **"Generate"**
4. **Copy the token** (starts with `xapp-`)
This is your `SLACK_APP_TOKEN`
## Step 3: Set Bot Permissions
1. In the left sidebar, go to **"OAuth & Permissions"**
2. Scroll to **"Scopes"** → **"Bot Token Scopes"**
3. Add these scopes:
| Scope | Purpose |
|-------|---------|
| `app_mentions:read` | React when someone @mentions your bot |
| `chat:write` | Send messages |
| `im:history` | Read DM message history |
| `im:read` | View DM channel info |
| `im:write` | Start DM conversations |
## Step 4: Enable Events
1. In the left sidebar, go to **"Event Subscriptions"**
2. Toggle **"Enable Events"** → ON
3. Scroll to **"Subscribe to bot events"**
4. Add these events:
| Event | Purpose |
|-------|---------|
| `app_mention` | Triggers when someone @mentions your bot in a channel |
| `message.im` | Triggers when someone DMs your bot |
5. Click **"Save Changes"**
> **Important**: If you add or change events after installing the app, you must reinstall it (Step 6) for changes to take effect. The token stays the same.
## Step 5: Configure App Home (Optional but Recommended)
1. In the left sidebar, go to **"App Home"**
2. Under **"Show Tabs"**, enable:
- **Messages Tab** → ON (allows DMs to your bot)
- Check **"Allow users to send Slash commands and messages from the messages tab"**
## Step 6: Install to Workspace
1. In the left sidebar, go to **"Install App"**
2. Click **"Install to Workspace"**
3. Review the permissions and click **"Allow"**
4. **Copy the Bot Token** (starts with `xoxb-`)
This is your `SLACK_BOT_TOKEN`
## Step 7: Configure LettaBot
Add these to your `.env` file:
```bash
# Slack Configuration
SLACK_BOT_TOKEN=xoxb-your-bot-token-here
SLACK_APP_TOKEN=xapp-your-app-token-here
# Optional: Restrict to specific Slack user IDs
# SLACK_ALLOWED_USERS=U01234567,U98765432
```
## Step 8: Start LettaBot
```bash
npm run dev
```
You should see:
```
Registered channel: Slack
[Slack] Bot started in Socket Mode
```
## Step 9: Test the Integration
### Direct Message
1. In Slack, look for your bot under **Apps** in the left sidebar
2. Click on the bot to open a DM
3. Send a message: `Hello!`
4. The bot should respond
### Channel Mention
1. Invite your bot to a channel: `/invite @LettaBot`
2. Mention the bot: `@LettaBot what time is it?`
3. The bot should respond in the channel
### Thread Replies
- If you mention the bot in a thread, it will reply in that thread
- If you mention the bot in a channel (not a thread), it starts a new thread from your message
## Cross-Channel Memory
Since LettaBot uses a single agent across all channels:
- Messages you send on Slack continue the same conversation as Telegram
- The agent remembers context from both channels
- You can start a conversation on Telegram and continue it on Slack
## Restricting Access
To restrict which Slack users can interact with the bot:
1. Find user IDs:
- Click on a user's profile in Slack
- Click the **⋮** menu → **"Copy member ID"**
- IDs look like `U01ABCD2EFG`
2. Add to `.env`:
```bash
SLACK_ALLOWED_USERS=U01234567,U98765432
```
## Troubleshooting
### Bot not connecting
**Error**: "Socket Mode not enabled"
- Go to api.slack.com/apps → Your App → Socket Mode → Enable it
**Error**: "invalid_auth"
- Double-check your `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN`
- Make sure you copied the full tokens including the `xoxb-` and `xapp-` prefixes
### Bot not responding to DMs
1. Go to **App Home** in your Slack app settings
2. Enable **"Messages Tab"**
3. Check **"Allow users to send messages from the messages tab"**
4. Reinstall the app if you changed these settings
### "Sending messages to this app has been turned off"
Even after enabling the Messages Tab and reinstalling, Slack may cache the old state:
1. **Refresh Slack**: Press `Cmd+R` (Mac) or `Ctrl+R` (Windows/Linux)
2. **Or restart Slack** entirely
3. If still stuck, remove the bot from your Apps sidebar and re-add it
### Bot not responding to @mentions
1. Go to **Event Subscriptions**
2. Make sure **"Enable Events"** is toggled ON
3. Make sure `app_mention` is listed under "Subscribe to bot events"
4. Click **"Save Changes"** at the bottom
5. Go to **OAuth & Permissions**
6. Make sure `app_mentions:read` scope is added
7. Reinstall the app: **Install App** → **Reinstall to Workspace**
> **Note**: Adding scopes only gives permission; you must also subscribe to events to receive them.
### "missing_scope" error
If you see a missing scope error:
1. Go to **OAuth & Permissions**
2. Add the missing scope
3. Go to **Install App** → **Reinstall to Workspace**
### Bot responds slowly
This is normal - the bot needs to:
1. Receive your message
2. Send it to the Letta agent
3. Wait for the agent to respond
4. Stream the response back
First responses may take longer as the agent "wakes up".
## Security Notes
- **Socket Mode tokens** (`xapp-`) should be kept secret
- **Bot tokens** (`xoxb-`) should be kept secret
- Use `SLACK_ALLOWED_USERS` in production to restrict access
- The bot can only see messages in channels it's invited to, or DMs sent directly to it
## Next Steps
- [WhatsApp Setup](./whatsapp-setup.md)
- [Cron Jobs](./cron-setup.md)
- [Configuration Reference](./configuration.md)

182
docs/whatsapp-setup.md Normal file
View File

@@ -0,0 +1,182 @@
# WhatsApp Setup for LettaBot
This guide walks you through setting up WhatsApp as a channel for LettaBot.
## Overview
LettaBot connects to WhatsApp using **Baileys**, which uses the WhatsApp Web protocol. This means:
- Uses your personal WhatsApp account (or a dedicated number)
- No WhatsApp Business API required
- Free to use
- Requires QR code scan on first setup
## Prerequisites
- A phone with WhatsApp installed
- LettaBot installed and configured with at least `LETTA_API_KEY`
## Step 1: Enable WhatsApp in Configuration
Add to your `.env` file:
```bash
# WhatsApp Configuration
WHATSAPP_ENABLED=true
# Optional: Custom session storage path (default: ./data/whatsapp-session)
# WHATSAPP_SESSION_PATH=./data/whatsapp-session
# Optional: Restrict to specific phone numbers
# WHATSAPP_ALLOWED_USERS=+15551234567,+15559876543
```
## Step 2: Start LettaBot
```bash
npm run dev
```
You'll see output like:
```
Registered channel: WhatsApp
[WhatsApp] Scan the QR code above to login
```
A QR code will be displayed in your terminal.
## Step 3: Scan the QR Code
1. Open WhatsApp on your phone
2. Go to **Settings****Linked Devices**
3. Tap **"Link a Device"**
4. Scan the QR code displayed in your terminal
Once connected, you'll see:
```
[WhatsApp] Connected!
```
## Step 4: Test the Integration
1. From another phone (or WhatsApp Web with a different account), send a message to the number you just linked
2. The bot should respond
Or test with yourself:
1. Open a note-to-self chat (message yourself)
2. Send a test message
## Session Persistence
After the first QR scan, your session is saved to disk (default: `./data/whatsapp-session/`).
On subsequent restarts:
- No QR scan needed
- Bot reconnects automatically
- If session expires, a new QR code will be shown
## Restricting Access
To restrict which phone numbers can interact with the bot:
```bash
# Include country code with + prefix
WHATSAPP_ALLOWED_USERS=+15551234567,+15559876543
```
**Note**: Only DMs are restricted. Group messages may still be received (but not responded to unless the sender is allowed).
## Cross-Channel Memory
Since LettaBot uses a single agent across all channels:
- Messages from WhatsApp continue the same conversation as Telegram/Slack
- The agent remembers context from all channels
- Start a conversation on Telegram, continue it on WhatsApp
## Important Limitations
### Message Editing
WhatsApp doesn't support editing messages. When the bot streams a long response, it will send the full message at once rather than updating incrementally.
### Rate Limits
WhatsApp has strict rate limits to prevent spam:
- Don't send too many messages too quickly
- Avoid automated bulk messaging
- Risk of account ban if abused
### Personal Account
This uses your personal WhatsApp account:
- Messages appear as coming from your number
- Consider using a dedicated phone number for the bot
- Your contacts will see the bot as "you"
### Multi-Device Limitations
- WhatsApp allows up to 4 linked devices
- The bot counts as one linked device
- If you unlink the bot, you'll need to scan the QR code again
## Running in Production
For production deployments:
1. **Use a dedicated phone number** - Don't use your personal WhatsApp
2. **Persistent storage** - Make sure `WHATSAPP_SESSION_PATH` points to persistent storage
3. **Monitor reconnections** - The bot auto-reconnects, but check logs for issues
4. **Backup session** - Back up the session folder to avoid re-scanning QR codes
## Troubleshooting
### QR Code Not Showing
If no QR code appears:
- Check that `WHATSAPP_ENABLED=true` is set
- Look for error messages in the console
- Make sure you have a terminal that supports QR code display
### "Connection Closed" Errors
This usually means:
- WhatsApp Web session was logged out from your phone
- Network connectivity issues
- WhatsApp servers are temporarily unavailable
The bot will automatically try to reconnect.
### "Logged Out" State
If you see "logged out" in the logs:
1. Delete the session folder: `rm -rf ./data/whatsapp-session`
2. Restart LettaBot
3. Scan the new QR code
### Messages Not Being Received
1. Make sure the sender's number is in `WHATSAPP_ALLOWED_USERS` (if configured)
2. Check that the message is a text message (media not fully supported yet)
3. Look for errors in the console
### Bot Responding to Old Messages
On first connection, the bot might receive some historical messages. This is normal WhatsApp behavior. The bot will only respond to new messages going forward.
## Security Notes
- **Session files** contain authentication data - keep them secure
- Don't share your session folder
- Use `WHATSAPP_ALLOWED_USERS` to restrict who can interact with the bot
- Consider the privacy implications of using a personal number
## Directory Structure
```
data/
└── whatsapp-session/
├── creds.json # Authentication credentials
├── app-state-sync-* # App state
└── ... # Other session data
```
## Next Steps
- [Slack Setup](./slack-setup.md)
- [Cron Jobs](./cron-setup.md)
- [Configuration Reference](./configuration.md)

BIN
image (1).png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

6416
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

61
package.json Normal file
View File

@@ -0,0 +1,61 @@
{
"name": "lettabot",
"version": "1.0.0",
"type": "module",
"main": "dist/main.js",
"bin": {
"lettabot": "./dist/cli.js",
"lettabot-schedule": "./dist/cron/cli.js",
"lettabot-message": "./dist/cli/message.js"
},
"scripts": {
"setup": "tsx src/setup.ts",
"dev": "tsx src/main.ts",
"build": "tsc",
"start": "node dist/main.js",
"skills": "tsx src/cli.ts skills",
"skills:list": "tsx src/cli.ts skills list",
"skills:status": "tsx src/cli.ts skills status",
"cron": "tsx src/cron/cli.ts",
"pairing": "tsx src/cli.ts pairing",
"skill:install": "npx clawdhub install --dir ~/.letta/skills",
"skill:search": "npx clawdhub search",
"skill:list": "npx clawdhub list --dir ~/.letta/skills",
"skills:add": "npx skills add --global --yes",
"skills:find": "npx skills find"
},
"keywords": [
"telegram",
"bot",
"letta",
"ai",
"agent"
],
"author": "",
"license": "Apache-2.0",
"description": "Multi-channel AI assistant with persistent memory - Telegram, Slack, WhatsApp",
"dependencies": {
"@clack/prompts": "^0.11.0",
"@hapi/boom": "^10.0.1",
"@letta-ai/letta-client": "^1.7.6",
"@letta-ai/letta-code-sdk": "^0.0.3",
"@types/express": "^5.0.6",
"@types/node": "^25.0.10",
"@types/node-schedule": "^2.1.8",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"googleapis": "^170.1.0",
"grammy": "^1.39.3",
"gray-matter": "^4.0.3",
"node-schedule": "^2.1.1",
"open": "^11.0.0",
"qrcode-terminal": "^0.12.0",
"telegram-markdown-v2": "^0.0.4",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
},
"optionalDependencies": {
"@slack/bolt": "^4.6.0",
"@whiskeysockets/baileys": "^6.7.21"
}
}

37
scripts/install-skill.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/bin/bash
# Install a skill from ClawdHub
# Usage: ./scripts/install-skill.sh <skill-name>
set -e
SKILL_NAME="$1"
if [ -z "$SKILL_NAME" ]; then
echo "Usage: $0 <skill-name>"
echo ""
echo "Examples:"
echo " $0 weather # Weather forecasts"
echo " $0 github # GitHub CLI integration"
echo " $0 sonoscli # Sonos speaker control"
echo " $0 obsidian # Obsidian notes"
echo ""
echo "Browse all skills: https://clawdhub.com"
exit 1
fi
# Install clawdhub CLI if needed
if ! command -v clawdhub &> /dev/null; then
echo "Installing ClawdHub CLI..."
npm install -g clawdhub
fi
# Install the skill to global Letta skills directory
# This is where Letta Code CLI looks for skills
SKILLS_DIR="$HOME/.letta/skills"
mkdir -p "$SKILLS_DIR"
echo "Installing skill: $SKILL_NAME to $SKILLS_DIR"
clawdhub install "$SKILL_NAME" --dir "$SKILLS_DIR"
echo ""
echo "Skill installed! It will be available to all Letta agents."

131
skills/scheduling/SKILL.md Normal file
View File

@@ -0,0 +1,131 @@
---
name: scheduling
description: Create scheduled tasks and one-off reminders. Use for recurring jobs (daily, weekly) or future reminders (in 5 minutes, tomorrow at 3pm).
---
# Scheduling
Schedule recurring tasks or one-off reminders.
## Quick Reference
```bash
lettabot-schedule list # List all jobs
lettabot-schedule create [options] # Create job
lettabot-schedule delete ID # Delete job
lettabot-schedule enable ID # Enable job
lettabot-schedule disable ID # Disable job
```
## One-Off Reminders (--at)
For reminders at a specific future time, use `--at` with an **ISO datetime**.
**Calculate the datetime in JavaScript:**
```javascript
// 5 minutes from now
new Date(Date.now() + 5*60*1000).toISOString()
// → "2026-01-28T20:15:00.000Z"
// 1 hour from now
new Date(Date.now() + 60*60*1000).toISOString()
// Tomorrow at 9am (approximate)
new Date(Date.now() + 24*60*60*1000).toISOString()
```
**Create the reminder:**
```bash
lettabot-schedule create \
--name "Standup" \
--at "2026-01-28T20:15:00.000Z" \
--message "Time for standup!"
```
One-off reminders auto-delete after running.
**Options:**
- `-n, --name` - Job name (required)
- `-a, --at` - ISO datetime for one-off reminder (e.g., "2026-01-28T20:15:00Z")
- `-m, --message` - Message sent to you when job runs (required)
- `-d, --deliver` - Where to send response (format: `channel:chatId`). Defaults to last messaged chat.
## Recurring Schedules (--schedule)
For recurring tasks, use `--schedule` with a cron expression.
```bash
lettabot-schedule create \
--name "Morning Briefing" \
--schedule "0 8 * * *" \
--message "Good morning! What's on today's agenda?"
```
**Options:**
- `-n, --name` - Job name (required)
- `-s, --schedule` - Cron expression (required for recurring)
- `-m, --message` - Message sent to you when job runs (required)
- `-d, --deliver` - Where to send response (format: `channel:chatId`). Defaults to last messaged chat.
- `--disabled` - Create disabled
## Cron Schedule Syntax
```
┌───────── minute (0-59)
│ ┌─────── hour (0-23)
│ │ ┌───── day of month (1-31)
│ │ │ ┌─── month (1-12)
│ │ │ │ ┌─ day of week (0-6, Sun=0)
* * * * *
```
| Pattern | When |
|---------|------|
| `0 8 * * *` | Daily at 8:00 AM |
| `0 9 * * 1-5` | Weekdays at 9:00 AM |
| `0 */2 * * *` | Every 2 hours |
| `30 17 * * 5` | Fridays at 5:30 PM |
| `0 0 1 * *` | First of month at midnight |
## Examples
**Remind me in 30 minutes:**
```bash
# First calculate: new Date(Date.now() + 30*60*1000).toISOString()
lettabot-schedule create \
-n "Break reminder" \
--at "2026-01-28T20:45:00.000Z" \
-m "Time for a break!"
```
**Daily morning check-in:**
```bash
lettabot-schedule create \
-n "Morning" \
-s "0 8 * * *" \
-m "Good morning! What's on today's agenda?"
```
**Weekly review on Fridays:**
```bash
lettabot-schedule create \
-n "Weekly Review" \
-s "0 17 * * 5" \
-m "Friday wrap-up: What did we accomplish?"
```
## Message Format
When a scheduled job runs, you receive:
```
[cron:job-123abc Morning] Good morning! What's on today's agenda?
Current time: 1/27/2026, 8:00:00 AM (America/Los_Angeles)
```
## Notes
- Jobs activate immediately when created (no restart needed)
- One-off reminders (`--at`) auto-delete after running
- Use `lettabot-schedule list` to see next run times
- Jobs persist in `cron-jobs.json`

224
src/auth/oauth.ts Normal file
View File

@@ -0,0 +1,224 @@
/**
* OAuth 2.0 utilities for Letta Cloud authentication
* Uses Device Code Flow for CLI authentication
*
* Ported from @letta-ai/letta-code
*/
import Letta from "@letta-ai/letta-client";
export const LETTA_CLOUD_API_URL = "https://api.letta.com";
export const OAUTH_CONFIG = {
clientId: "ci-let-724dea7e98f4af6f8f370f4b1466200c",
clientSecret: "", // Not needed for device code flow
authBaseUrl: "https://app.letta.com",
apiBaseUrl: LETTA_CLOUD_API_URL,
} as const;
export interface DeviceCodeResponse {
device_code: string;
user_code: string;
verification_uri: string;
verification_uri_complete: string;
expires_in: number;
interval: number;
}
export interface TokenResponse {
access_token: string;
refresh_token?: string;
token_type: string;
expires_in: number;
scope?: string;
}
export interface OAuthError {
error: string;
error_description?: string;
}
/**
* Device Code Flow - Step 1: Request device code
*/
export async function requestDeviceCode(): Promise<DeviceCodeResponse> {
const response = await fetch(
`${OAUTH_CONFIG.authBaseUrl}/api/oauth/device/code`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: OAUTH_CONFIG.clientId,
}),
},
);
if (!response.ok) {
const error = (await response.json()) as OAuthError;
throw new Error(
`Failed to request device code: ${error.error_description || error.error}`,
);
}
return (await response.json()) as DeviceCodeResponse;
}
/**
* Device Code Flow - Step 2: Poll for token
*/
export async function pollForToken(
deviceCode: string,
interval: number = 5,
expiresIn: number = 900,
deviceId: string,
deviceName?: string,
): Promise<TokenResponse> {
const startTime = Date.now();
const expiresInMs = expiresIn * 1000;
let pollInterval = interval * 1000;
while (Date.now() - startTime < expiresInMs) {
await new Promise((resolve) => setTimeout(resolve, pollInterval));
try {
const response = await fetch(
`${OAUTH_CONFIG.authBaseUrl}/api/oauth/token`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
client_id: OAUTH_CONFIG.clientId,
device_code: deviceCode,
device_id: deviceId,
...(deviceName && { device_name: deviceName }),
}),
},
);
const result = await response.json();
if (response.ok) {
return result as TokenResponse;
}
const error = result as OAuthError;
if (error.error === "authorization_pending") {
// User hasn't authorized yet, keep polling
continue;
}
if (error.error === "slow_down") {
// We're polling too fast, increase interval by 5 seconds
pollInterval += 5000;
continue;
}
if (error.error === "access_denied") {
throw new Error("User denied authorization");
}
if (error.error === "expired_token") {
throw new Error("Device code expired");
}
throw new Error(`OAuth error: ${error.error_description || error.error}`);
} catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error(`Failed to poll for token: ${String(error)}`);
}
}
throw new Error("Timeout waiting for authorization (15 minutes)");
}
/**
* Refresh an access token using a refresh token
*/
export async function refreshAccessToken(
refreshToken: string,
deviceId: string,
deviceName?: string,
): Promise<TokenResponse> {
const response = await fetch(`${OAUTH_CONFIG.authBaseUrl}/api/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "refresh_token",
client_id: OAUTH_CONFIG.clientId,
refresh_token: refreshToken,
refresh_token_mode: "new",
device_id: deviceId,
...(deviceName && { device_name: deviceName }),
}),
});
if (!response.ok) {
const error = (await response.json()) as OAuthError;
throw new Error(
`Failed to refresh access token: ${error.error_description || error.error}`,
);
}
return (await response.json()) as TokenResponse;
}
/**
* Revoke a refresh token (logout)
*/
export async function revokeToken(refreshToken: string): Promise<void> {
try {
const response = await fetch(
`${OAUTH_CONFIG.authBaseUrl}/api/oauth/revoke`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: OAUTH_CONFIG.clientId,
token: refreshToken,
token_type_hint: "refresh_token",
}),
},
);
// OAuth 2.0 revoke endpoint should return 200 even if token is already invalid
if (!response.ok) {
const error = (await response.json()) as OAuthError;
console.error(
`Warning: Failed to revoke token: ${error.error_description || error.error}`,
);
// Don't throw - we still want to clear local credentials
}
} catch (error) {
console.error("Warning: Failed to revoke token:", error);
// Don't throw - we still want to clear local credentials
}
}
/**
* Validate credentials by checking an authenticated endpoint
* Uses SDK's agents.list() which requires valid authentication
*/
export async function validateCredentials(
baseUrl: string,
apiKey: string,
): Promise<boolean> {
try {
// Create a temporary client to test authentication
const client = new Letta({
apiKey,
baseURL: baseUrl,
defaultHeaders: { "X-Letta-Source": "lettabot" },
});
// Try to list agents - this requires valid authentication
await client.agents.list({ limit: 1 });
return true;
} catch {
return false;
}
}

131
src/auth/tokens.ts Normal file
View File

@@ -0,0 +1,131 @@
/**
* Token storage utilities for OAuth credentials
* Stores tokens at ~/.letta/lettabot/tokens.json
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { homedir, hostname } from "node:os";
import { randomUUID } from "node:crypto";
const TOKENS_DIR = join(homedir(), ".letta", "lettabot");
const TOKENS_FILE = join(TOKENS_DIR, "tokens.json");
export interface TokenStore {
accessToken: string;
refreshToken?: string;
tokenExpiresAt?: number; // Unix timestamp in milliseconds
deviceId: string;
deviceName?: string;
}
/**
* Ensure the tokens directory exists
*/
function ensureDir(): void {
if (!existsSync(TOKENS_DIR)) {
mkdirSync(TOKENS_DIR, { recursive: true });
}
}
/**
* Load tokens from disk
*/
export function loadTokens(): TokenStore | null {
if (!existsSync(TOKENS_FILE)) {
return null;
}
try {
const content = readFileSync(TOKENS_FILE, "utf-8");
return JSON.parse(content) as TokenStore;
} catch {
return null;
}
}
/**
* Save tokens to disk
*/
export function saveTokens(tokens: TokenStore): void {
ensureDir();
writeFileSync(TOKENS_FILE, JSON.stringify(tokens, null, 2));
}
/**
* Delete stored tokens (logout)
*/
export function deleteTokens(): boolean {
if (existsSync(TOKENS_FILE)) {
unlinkSync(TOKENS_FILE);
return true;
}
return false;
}
/**
* Get or create a persistent device ID
*/
export function getOrCreateDeviceId(): string {
const tokens = loadTokens();
if (tokens?.deviceId) {
return tokens.deviceId;
}
// Check if there's a device ID file (for cases where tokens don't exist yet)
const deviceIdFile = join(TOKENS_DIR, "device-id");
if (existsSync(deviceIdFile)) {
try {
return readFileSync(deviceIdFile, "utf-8").trim();
} catch {
// Fall through to create new
}
}
// Create new device ID
const deviceId = randomUUID();
ensureDir();
writeFileSync(deviceIdFile, deviceId);
return deviceId;
}
/**
* Get the device name (hostname)
*/
export function getDeviceName(): string {
return hostname();
}
/**
* Check if the access token is expired or about to expire
* @param bufferMs - Consider expired if within this many ms of expiry (default: 5 minutes)
*/
export function isTokenExpired(tokens: TokenStore | null, bufferMs = 5 * 60 * 1000): boolean {
if (!tokens?.tokenExpiresAt) {
// No expiry info, assume not expired
return false;
}
return Date.now() >= tokens.tokenExpiresAt - bufferMs;
}
/**
* Check if we have a valid refresh token
*/
export function hasRefreshToken(tokens: TokenStore | null): boolean {
return !!tokens?.refreshToken;
}
/**
* Get the current access token, or null if not logged in
* This checks env var first, then stored tokens
*/
export function getAccessToken(): string | null {
// Environment variable takes precedence
if (process.env.LETTA_API_KEY) {
return process.env.LETTA_API_KEY;
}
const tokens = loadTokens();
return tokens?.accessToken ?? null;
}

9
src/channels/index.ts Normal file
View File

@@ -0,0 +1,9 @@
/**
* Channel Adapters
*/
export * from './types.js';
export * from './telegram.js';
export * from './slack.js';
export * from './whatsapp.js';
export * from './signal.js';

View File

@@ -0,0 +1,120 @@
/**
* Signal Text Formatting
*
* Converts simple markdown (*bold*, _italic_, `code`) to Signal style ranges.
* Uses single-pass processing to correctly track positions.
*/
export type SignalStyleRange = {
start: number;
length: number;
style: 'BOLD' | 'ITALIC' | 'MONOSPACE' | 'STRIKETHROUGH';
};
export type SignalFormattedText = {
text: string;
styles: SignalStyleRange[];
};
interface FoundMatch {
index: number;
fullMatch: string;
content: string;
style: SignalStyleRange['style'];
markerLen: number;
}
/**
* Convert markdown text to Signal formatted text with style ranges.
* Supports: **bold**, *bold*, __italic__, _italic_, `code`, ~~strikethrough~~
*/
export function markdownToSignal(markdown: string): SignalFormattedText {
// Find all matches first, then process in order
const patterns: Array<{
regex: RegExp;
style: SignalStyleRange['style'];
markerLen: number;
}> = [
{ regex: /\*\*(.+?)\*\*/g, style: 'BOLD', markerLen: 2 }, // **bold**
{ regex: /(?<!\*)\*([^*]+)\*(?!\*)/g, style: 'BOLD', markerLen: 1 }, // *bold* (not **)
{ regex: /__(.+?)__/g, style: 'ITALIC', markerLen: 2 }, // __italic__
{ regex: /(?<!_)_([^_]+)_(?!_)/g, style: 'ITALIC', markerLen: 1 }, // _italic_ (not __)
{ regex: /~~(.+?)~~/g, style: 'STRIKETHROUGH', markerLen: 2 }, // ~~strike~~
{ regex: /`([^`]+)`/g, style: 'MONOSPACE', markerLen: 1 }, // `code`
];
// Collect all matches with their original positions
const allMatches: FoundMatch[] = [];
for (const { regex, style, markerLen } of patterns) {
let match;
while ((match = regex.exec(markdown)) !== null) {
allMatches.push({
index: match.index,
fullMatch: match[0],
content: match[1],
style,
markerLen,
});
}
}
// Sort by position (earlier first), then by length (longer first for overlapping)
allMatches.sort((a, b) => {
if (a.index !== b.index) return a.index - b.index;
return b.fullMatch.length - a.fullMatch.length;
});
// Remove overlapping matches (keep the first/longer one)
const filteredMatches: FoundMatch[] = [];
let lastEnd = -1;
for (const m of allMatches) {
if (m.index >= lastEnd) {
filteredMatches.push(m);
lastEnd = m.index + m.fullMatch.length;
}
}
// Build output text and styles in single pass
const styles: SignalStyleRange[] = [];
const textParts: string[] = [];
let srcPos = 0;
let dstPos = 0;
for (const m of filteredMatches) {
// Add text before this match
if (m.index > srcPos) {
const before = markdown.slice(srcPos, m.index);
textParts.push(before);
dstPos += before.length;
}
// Add the content (without markers)
textParts.push(m.content);
// Record style at current destination position
styles.push({
start: dstPos,
length: m.content.length,
style: m.style,
});
dstPos += m.content.length;
srcPos = m.index + m.fullMatch.length;
}
// Add remaining text
if (srcPos < markdown.length) {
textParts.push(markdown.slice(srcPos));
}
return { text: textParts.join(''), styles };
}
/**
* Format styles for signal-cli text-style parameter
*/
export function formatStylesForCli(styles: SignalStyleRange[]): string[] {
return styles.map(s => `${s.start}:${s.length}:${s.style}`);
}

601
src/channels/signal.ts Normal file
View File

@@ -0,0 +1,601 @@
/**
* Signal Channel Adapter
*
* Uses signal-cli in daemon mode for Signal messaging.
* Based on moltbot's implementation.
*/
import type { ChannelAdapter } from './types.js';
import type { InboundMessage, OutboundMessage } from '../core/types.js';
import type { DmPolicy } from '../pairing/types.js';
import {
isUserAllowed,
upsertPairingRequest,
} from '../pairing/store.js';
import { spawn, type ChildProcess } from 'node:child_process';
import { randomUUID } from 'node:crypto';
export interface SignalConfig {
phoneNumber: string; // Bot's phone number (E.164 format, e.g., +15551234567)
cliPath?: string; // Path to signal-cli binary (default: "signal-cli")
httpHost?: string; // Daemon HTTP host (default: "127.0.0.1")
httpPort?: number; // Daemon HTTP port (default: 8090)
startupTimeoutMs?: number; // Max time to wait for daemon startup (default: 30000)
// Security
dmPolicy?: DmPolicy; // 'pairing' (default), 'allowlist', or 'open'
allowedUsers?: string[]; // Phone numbers (config allowlist)
selfChatMode?: boolean; // Respond to Note to Self (default: true)
}
type SignalRpcResponse<T> = {
jsonrpc?: string;
result?: T;
error?: { code?: number; message?: string };
id?: string | number | null;
};
type SignalSseEvent = {
envelope?: {
source?: string;
sourceUuid?: string;
timestamp?: number;
dataMessage?: {
message?: string;
timestamp?: number;
groupInfo?: {
groupId?: string;
groupName?: string;
};
};
syncMessage?: {
sentMessage?: {
destination?: string;
destinationUuid?: string;
message?: string;
timestamp?: number;
groupInfo?: {
groupId?: string;
groupName?: string;
};
};
};
typingMessage?: {
action?: string;
};
};
};
export class SignalAdapter implements ChannelAdapter {
readonly id = 'signal' as const;
readonly name = 'Signal';
private config: SignalConfig;
private running = false;
private daemonProcess: ChildProcess | null = null;
private sseAbortController: AbortController | null = null;
private baseUrl: string;
onMessage?: (msg: InboundMessage) => Promise<void>;
constructor(config: SignalConfig) {
this.config = {
...config,
dmPolicy: config.dmPolicy || 'pairing',
selfChatMode: config.selfChatMode !== false, // Default true
};
const host = config.httpHost || '127.0.0.1';
const port = config.httpPort || 8090;
this.baseUrl = `http://${host}:${port}`;
}
/**
* Check if a user is authorized based on dmPolicy
* Returns 'allowed', 'blocked', or 'pairing'
*/
private async checkAccess(userId: string): Promise<'allowed' | 'blocked' | 'pairing'> {
const policy = this.config.dmPolicy || 'pairing';
// Open policy: everyone allowed
if (policy === 'open') {
return 'allowed';
}
// Check if already allowed (config or store)
const allowed = await isUserAllowed('signal', userId, this.config.allowedUsers);
if (allowed) {
return 'allowed';
}
// Allowlist policy: not allowed if not in list
if (policy === 'allowlist') {
return 'blocked';
}
// Pairing policy: needs pairing
return 'pairing';
}
/**
* Format pairing message for Signal
*/
private formatPairingMessage(code: string): string {
return `Hi! This bot requires pairing.
Your code: *${code}*
Ask the owner to run:
\`lettabot pairing approve signal ${code}\`
This code expires in 1 hour.`;
}
async start(): Promise<void> {
if (this.running) return;
console.log('[Signal] Starting adapter...');
// Spawn signal-cli daemon
await this.startDaemon();
// Wait for daemon to be ready
await this.waitForDaemon();
// Start SSE event loop for incoming messages
this.startEventLoop();
this.running = true;
console.log('[Signal] Adapter started successfully');
}
async stop(): Promise<void> {
if (!this.running) return;
console.log('[Signal] Stopping adapter...');
// Stop SSE loop
this.sseAbortController?.abort();
this.sseAbortController = null;
// Stop daemon
if (this.daemonProcess && !this.daemonProcess.killed) {
this.daemonProcess.kill('SIGTERM');
this.daemonProcess = null;
}
this.running = false;
console.log('[Signal] Adapter stopped');
}
isRunning(): boolean {
return this.running;
}
async sendMessage(msg: OutboundMessage): Promise<{ messageId: string }> {
const { markdownToSignal, formatStylesForCli } = await import('./signal-format.js');
let target = msg.chatId;
const rawText = msg.text;
if (!rawText?.trim()) {
throw new Error('Signal requires message text');
}
// Handle Note to Self - send to our own number
if (target === 'note-to-self') {
target = this.config.phoneNumber;
}
// Convert markdown to Signal formatted text with style ranges
const formatted = markdownToSignal(rawText);
const params: Record<string, unknown> = {
message: formatted.text,
};
// Add style ranges if any
if (formatted.styles.length > 0) {
params['text-style'] = formatStylesForCli(formatted.styles);
}
if (this.config.phoneNumber) {
params.account = this.config.phoneNumber;
}
// Determine if this is a group or direct message
if (target.startsWith('group:')) {
params.groupId = target.slice('group:'.length);
} else {
// Direct message - recipient is a phone number or UUID
params.recipient = [target];
}
const result = await this.rpcRequest<{ timestamp?: number }>('send', params);
const timestamp = result?.timestamp;
return {
messageId: timestamp ? String(timestamp) : 'unknown',
};
}
supportsEditing(): boolean {
return false;
}
async editMessage(_chatId: string, _messageId: string, _text: string): Promise<void> {
// Signal doesn't support editing messages - no-op
}
async sendTypingIndicator(chatId: string): Promise<void> {
try {
let target = chatId;
// Handle Note to Self
if (target === 'note-to-self') {
target = this.config.phoneNumber;
}
const params: Record<string, unknown> = {};
if (this.config.phoneNumber) {
params.account = this.config.phoneNumber;
}
if (target.startsWith('group:')) {
params.groupId = target.slice('group:'.length);
} else {
params.recipient = [target];
}
await this.rpcRequest('sendTyping', params);
} catch (err) {
// Typing indicators are best-effort
console.warn('[Signal] Failed to send typing indicator:', err);
}
}
// --- Private methods ---
private async startDaemon(): Promise<void> {
const cliPath = this.config.cliPath || 'signal-cli';
const host = this.config.httpHost || '127.0.0.1';
const port = this.config.httpPort || 8090;
const args: string[] = [];
if (this.config.phoneNumber) {
args.push('-a', this.config.phoneNumber);
}
args.push('daemon');
args.push('--http', `${host}:${port}`);
args.push('--no-receive-stdout');
console.log(`[Signal] Spawning: ${cliPath} ${args.join(' ')}`);
this.daemonProcess = spawn(cliPath, args, {
stdio: ['ignore', 'pipe', 'pipe'],
});
this.daemonProcess.stdout?.on('data', (data) => {
const lines = data.toString().split(/\r?\n/).filter((l: string) => l.trim());
for (const line of lines) {
console.log(`[signal-cli] ${line}`);
}
});
this.daemonProcess.stderr?.on('data', (data) => {
const lines = data.toString().split(/\r?\n/).filter((l: string) => l.trim());
for (const line of lines) {
// signal-cli writes most logs to stderr
if (/\b(ERROR|WARN|FAILED|SEVERE)\b/i.test(line)) {
console.error(`[signal-cli] ${line}`);
} else {
console.log(`[signal-cli] ${line}`);
}
}
});
this.daemonProcess.on('error', (err) => {
console.error('[Signal] Daemon spawn error:', err);
});
this.daemonProcess.on('exit', (code) => {
console.log(`[Signal] Daemon exited with code ${code}`);
if (this.running) {
// Unexpected exit - mark as not running
this.running = false;
}
});
}
private async waitForDaemon(): Promise<void> {
const timeoutMs = this.config.startupTimeoutMs || 30000;
const startTime = Date.now();
const pollIntervalMs = 500;
console.log('[Signal] Waiting for daemon to be ready...');
while (Date.now() - startTime < timeoutMs) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2000);
try {
const res = await fetch(`${this.baseUrl}/api/v1/check`, {
method: 'GET',
signal: controller.signal,
});
clearTimeout(timeout);
if (res.ok) {
console.log('[Signal] Daemon is ready');
return;
}
} catch {
clearTimeout(timeout);
// Daemon not ready yet
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
throw new Error(`Signal daemon did not become ready within ${timeoutMs}ms`);
}
private startEventLoop(): void {
this.sseAbortController = new AbortController();
// Run SSE loop in background
this.runSseLoop().catch((err) => {
if (!this.sseAbortController?.signal.aborted) {
console.error('[Signal] SSE loop error:', err);
}
});
}
private async runSseLoop(): Promise<void> {
const url = new URL(`${this.baseUrl}/api/v1/events`);
if (this.config.phoneNumber) {
url.searchParams.set('account', this.config.phoneNumber);
}
console.log('[Signal] Starting SSE event loop:', url.toString());
while (!this.sseAbortController?.signal.aborted) {
// Create a new controller for this connection attempt
const connectionController = new AbortController();
// Abort this connection if the main controller is aborted
const onMainAbort = () => connectionController.abort();
this.sseAbortController?.signal.addEventListener('abort', onMainAbort, { once: true });
try {
console.log('[Signal] Connecting to SSE...');
const res = await fetch(url, {
method: 'GET',
headers: { Accept: 'text/event-stream' },
signal: connectionController.signal,
});
if (!res.ok || !res.body) {
throw new Error(`SSE failed: ${res.status} ${res.statusText}`);
}
console.log('[Signal] SSE connected');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (!this.sseAbortController?.signal.aborted) {
const { value, done } = await reader.read();
if (done) {
console.log('[Signal] SSE stream ended');
break;
}
buffer += decoder.decode(value, { stream: true });
// Process complete events (separated by double newline)
const events = buffer.split('\n\n');
buffer = events.pop() || ''; // Keep incomplete event in buffer
for (const event of events) {
if (!event.trim()) continue;
// Extract data from SSE event (may be multiline)
const lines = event.split('\n');
let data = '';
for (const line of lines) {
if (line.startsWith('data:')) {
data += line.slice(5).trim();
}
}
if (data) {
this.handleSseData(data).catch((err) => {
console.error('[Signal] Error handling SSE data:', err);
});
}
}
}
// Stream ended normally - wait before reconnecting
console.log('[Signal] SSE disconnected, reconnecting in 2s...');
await new Promise((resolve) => setTimeout(resolve, 2000));
} catch (err) {
if (this.sseAbortController?.signal.aborted) {
return;
}
console.error('[Signal] SSE connection error, reconnecting in 5s:', err);
await new Promise((resolve) => setTimeout(resolve, 5000));
} finally {
// Clean up the listener
this.sseAbortController?.signal.removeEventListener('abort', onMainAbort);
}
}
}
private async handleSseData(data: string): Promise<void> {
try {
const event = JSON.parse(data) as SignalSseEvent;
const envelope = event.envelope;
if (!envelope) return;
// Handle incoming data messages (from others)
const dataMessage = envelope.dataMessage;
// Handle sync messages (Note to Self, messages we sent from another device)
const syncMessage = envelope.syncMessage?.sentMessage;
// Get the message text and source from either type
let messageText: string | undefined;
let source: string | undefined;
let chatId: string | undefined;
let groupInfo: { groupId?: string; groupName?: string } | undefined;
if (dataMessage?.message) {
// Regular incoming message
messageText = dataMessage.message;
source = envelope.source || envelope.sourceUuid;
groupInfo = dataMessage.groupInfo;
if (groupInfo?.groupId) {
chatId = `group:${groupInfo.groupId}`;
} else {
chatId = source;
}
} else if (syncMessage?.message) {
// Sync message (Note to Self or sent from another device)
messageText = syncMessage.message;
source = syncMessage.destination || syncMessage.destinationUuid;
groupInfo = syncMessage.groupInfo;
// For Note to Self, destination is our own number
const isNoteToSelf = source === this.config.phoneNumber ||
source === envelope.source ||
source === envelope.sourceUuid;
if (isNoteToSelf) {
chatId = 'note-to-self';
} else if (groupInfo?.groupId) {
chatId = `group:${groupInfo.groupId}`;
} else {
chatId = source;
}
}
if (!messageText || !source || !chatId) {
return;
}
// Handle Note to Self - check selfChatMode
if (chatId === 'note-to-self') {
if (!this.config.selfChatMode) {
// selfChatMode disabled - ignore Note to Self messages
return;
}
// selfChatMode enabled - allow the message through
} else {
// External message - check access control
const access = await this.checkAccess(source);
if (access === 'blocked') {
console.log(`[Signal] Blocked message from unauthorized user: ${source}`);
await this.sendMessage({ chatId: source, text: "Sorry, you're not authorized to use this bot." });
return;
}
if (access === 'pairing') {
// Create pairing request
const { code, created } = await upsertPairingRequest('signal', source, {
firstName: source, // Use phone number as name
});
if (!code) {
await this.sendMessage({
chatId: source,
text: "Too many pending pairing requests. Please try again later."
});
return;
}
// Send pairing message on first contact
if (created) {
console.log(`[Signal] New pairing request from ${source}: ${code}`);
await this.sendMessage({ chatId: source, text: this.formatPairingMessage(code) });
}
// Don't process the message
return;
}
}
const isGroup = chatId.startsWith('group:');
const msg: InboundMessage = {
channel: 'signal',
chatId,
userId: source,
text: messageText,
timestamp: new Date(envelope.timestamp || Date.now()),
isGroup,
groupName: groupInfo?.groupName,
};
this.onMessage?.(msg).catch((err) => {
console.error('[Signal] Error handling message:', err);
});
} catch (err) {
console.error('[Signal] Failed to parse SSE event:', err, data);
}
}
private async rpcRequest<T = unknown>(
method: string,
params: Record<string, unknown>,
): Promise<T> {
const id = randomUUID();
const body = JSON.stringify({
jsonrpc: '2.0',
method,
params,
id,
});
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
let res: Response;
try {
res = await fetch(`${this.baseUrl}/api/v1/rpc`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
if (res.status === 201) {
return undefined as T;
}
const text = await res.text();
if (!text) {
throw new Error(`Signal RPC empty response (status ${res.status})`);
}
const parsed = JSON.parse(text) as SignalRpcResponse<T>;
if (parsed.error) {
const code = parsed.error.code ?? 'unknown';
const msg = parsed.error.message ?? 'Signal RPC error';
throw new Error(`Signal RPC ${code}: ${msg}`);
}
return parsed.result as T;
}
}

158
src/channels/slack.ts Normal file
View File

@@ -0,0 +1,158 @@
/**
* Slack Channel Adapter
*
* Uses @slack/bolt for Slack API with Socket Mode.
*/
import type { ChannelAdapter } from './types.js';
import type { InboundMessage, OutboundMessage } from '../core/types.js';
// Dynamic import to avoid requiring Slack deps if not used
let App: typeof import('@slack/bolt').App;
export interface SlackConfig {
botToken: string; // xoxb-...
appToken: string; // xapp-... (for Socket Mode)
allowedUsers?: string[]; // Slack user IDs (e.g., U01234567)
}
export class SlackAdapter implements ChannelAdapter {
readonly id = 'slack' as const;
readonly name = 'Slack';
private app: InstanceType<typeof App> | null = null;
private config: SlackConfig;
private running = false;
onMessage?: (msg: InboundMessage) => Promise<void>;
constructor(config: SlackConfig) {
this.config = config;
}
async start(): Promise<void> {
if (this.running) return;
// Dynamic import
const bolt = await import('@slack/bolt');
App = bolt.App;
this.app = new App({
token: this.config.botToken,
appToken: this.config.appToken,
socketMode: true,
});
// Handle messages
this.app.message(async ({ message, say }) => {
// Type guard for regular messages
if (message.subtype !== undefined) return;
if (!('user' in message) || !('text' in message)) return;
const userId = message.user;
const text = message.text || '';
const channelId = message.channel;
const threadTs = message.thread_ts || message.ts; // Reply in thread if applicable
// Check allowed users
if (this.config.allowedUsers && this.config.allowedUsers.length > 0) {
if (!this.config.allowedUsers.includes(userId)) {
await say("Sorry, you're not authorized to use this bot.");
return;
}
}
if (this.onMessage) {
// Determine if this is a group/channel (not a DM)
// DMs have channel IDs starting with 'D', channels start with 'C'
const isGroup = !channelId.startsWith('D');
await this.onMessage({
channel: 'slack',
chatId: channelId,
userId: userId || '',
userHandle: userId || '', // Slack user ID serves as handle
text: text || '',
timestamp: new Date(Number(message.ts) * 1000),
threadId: threadTs,
isGroup,
groupName: isGroup ? channelId : undefined, // Would need conversations.info for name
});
}
});
// Handle app mentions (@bot)
this.app.event('app_mention', async ({ event }) => {
const userId = event.user || '';
const text = (event.text || '').replace(/<@[A-Z0-9]+>/g, '').trim(); // Remove mention
const channelId = event.channel;
const threadTs = event.thread_ts || event.ts; // Reply in thread, or start new thread from the mention
if (this.config.allowedUsers && this.config.allowedUsers.length > 0) {
if (!userId || !this.config.allowedUsers.includes(userId)) {
// Can't use say() in app_mention event the same way
return;
}
}
if (this.onMessage) {
// app_mention is always in a channel (group)
const isGroup = !channelId.startsWith('D');
await this.onMessage({
channel: 'slack',
chatId: channelId,
userId: userId || '',
userHandle: userId || '', // Slack user ID serves as handle
text: text || '',
timestamp: new Date(Number(event.ts) * 1000),
threadId: threadTs,
isGroup,
groupName: isGroup ? channelId : undefined,
});
}
});
console.log('[Slack] Connecting via Socket Mode...');
await this.app.start();
console.log('[Slack] Bot started in Socket Mode');
this.running = true;
}
async stop(): Promise<void> {
if (!this.running || !this.app) return;
await this.app.stop();
this.running = false;
}
isRunning(): boolean {
return this.running;
}
async sendMessage(msg: OutboundMessage): Promise<{ messageId: string }> {
if (!this.app) throw new Error('Slack not started');
const result = await this.app.client.chat.postMessage({
channel: msg.chatId,
text: msg.text,
thread_ts: msg.threadId,
});
return { messageId: result.ts || '' };
}
async editMessage(chatId: string, messageId: string, text: string): Promise<void> {
if (!this.app) throw new Error('Slack not started');
await this.app.client.chat.update({
channel: chatId,
ts: messageId,
text,
});
}
async sendTypingIndicator(_chatId: string): Promise<void> {
// Slack doesn't have a typing indicator API for bots
// This is a no-op
}
}

View File

@@ -0,0 +1,90 @@
/**
* Telegram Text Formatting
*
* Converts markdown to Telegram MarkdownV2 format using telegramify-markdown.
* Supports: headers, bold, italic, code, links, blockquotes, lists, etc.
*/
import { convert } from 'telegram-markdown-v2';
/**
* Convert markdown to Telegram MarkdownV2 format.
* Handles proper escaping of special characters.
*/
export function markdownToTelegramV2(markdown: string): string {
try {
// Use 'keep' strategy to preserve blockquotes (>) and other elements
return convert(markdown, 'keep');
} catch (e) {
console.error('[Telegram] Markdown conversion failed:', e);
// Fallback: escape special characters manually
return escapeMarkdownV2(markdown);
}
}
/**
* Escape MarkdownV2 special characters (fallback)
*/
function escapeMarkdownV2(text: string): string {
const specialChars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'];
let escaped = text;
for (const char of specialChars) {
escaped = escaped.replace(new RegExp(`\\${char}`, 'g'), `\\${char}`);
}
return escaped;
}
/**
* Escape HTML special characters (for HTML parse mode fallback)
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/**
* Convert markdown to Telegram HTML format.
* Fallback option - simpler but less feature-rich.
* Supports: *bold*, _italic_, `code`, ~~strikethrough~~, ```code blocks```
*/
export function markdownToTelegramHtml(markdown: string): string {
let text = markdown;
// Process code blocks first (they shouldn't have other formatting inside)
text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
return `<pre><code>${escapeHtml(code.trim())}</code></pre>`;
});
// Inline code (escape content)
text = text.replace(/`([^`]+)`/g, (_, code) => {
return `<code>${escapeHtml(code)}</code>`;
});
// Now escape remaining HTML (outside of code blocks)
// Split by our tags to preserve them
const parts = text.split(/(<\/?(?:pre|code|b|i|s|u|a)[^>]*>)/);
text = parts.map((part, i) => {
// Odd indices are our tags, keep them
if (i % 2 === 1) return part;
// Even indices are text, but skip if inside code
return escapeHtml(part);
}).join('');
// Bold: **text** or *text*
text = text.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
text = text.replace(/\*([^*]+)\*/g, '<b>$1</b>');
// Italic: __text__ or _text_
text = text.replace(/__(.+?)__/g, '<i>$1</i>');
text = text.replace(/_([^_]+)_/g, '<i>$1</i>');
// Strikethrough: ~~text~~
text = text.replace(/~~(.+?)~~/g, '<s>$1</s>');
// Blockquotes: > text (convert to italic for now, HTML doesn't have blockquote in Telegram)
text = text.replace(/^>\s*(.+)$/gm, '<blockquote>$1</blockquote>');
return text;
}

227
src/channels/telegram.ts Normal file
View File

@@ -0,0 +1,227 @@
/**
* Telegram Channel Adapter
*
* Uses grammY for Telegram Bot API.
* Supports DM pairing for secure access control.
*/
import { Bot } from 'grammy';
import type { ChannelAdapter } from './types.js';
import type { InboundMessage, OutboundMessage } from '../core/types.js';
import type { DmPolicy } from '../pairing/types.js';
import {
isUserAllowed,
upsertPairingRequest,
formatPairingMessage,
} from '../pairing/store.js';
export interface TelegramConfig {
token: string;
dmPolicy?: DmPolicy; // 'pairing' (default), 'allowlist', or 'open'
allowedUsers?: number[]; // Telegram user IDs (config allowlist)
}
export class TelegramAdapter implements ChannelAdapter {
readonly id = 'telegram' as const;
readonly name = 'Telegram';
private bot: Bot;
private config: TelegramConfig;
private running = false;
onMessage?: (msg: InboundMessage) => Promise<void>;
onCommand?: (command: string) => Promise<string | null>;
constructor(config: TelegramConfig) {
this.config = {
...config,
dmPolicy: config.dmPolicy || 'pairing', // Default to pairing
};
this.bot = new Bot(config.token);
this.setupHandlers();
}
/**
* Check if a user is authorized based on dmPolicy
* Returns true if allowed, false if blocked, 'pairing' if pending pairing
*/
private async checkAccess(userId: string, username?: string, firstName?: string): Promise<'allowed' | 'blocked' | 'pairing'> {
const policy = this.config.dmPolicy || 'pairing';
const userIdStr = userId;
// Open policy: everyone allowed
if (policy === 'open') {
return 'allowed';
}
// Check if already allowed (config or store)
const configAllowlist = this.config.allowedUsers?.map(String);
const allowed = await isUserAllowed('telegram', userIdStr, configAllowlist);
if (allowed) {
return 'allowed';
}
// Allowlist policy: not allowed if not in list
if (policy === 'allowlist') {
return 'blocked';
}
// Pairing policy: create/update pairing request
return 'pairing';
}
private setupHandlers(): void {
// Middleware: Check access based on dmPolicy
this.bot.use(async (ctx, next) => {
const userId = ctx.from?.id;
if (!userId) return;
const access = await this.checkAccess(
String(userId),
ctx.from?.username,
ctx.from?.first_name
);
if (access === 'allowed') {
await next();
return;
}
if (access === 'blocked') {
await ctx.reply("Sorry, you're not authorized to use this bot.");
return;
}
// Pairing flow
const { code, created } = await upsertPairingRequest('telegram', String(userId), {
username: ctx.from?.username,
firstName: ctx.from?.first_name,
lastName: ctx.from?.last_name,
});
if (!code) {
// Too many pending requests
await ctx.reply(
"Too many pending pairing requests. Please try again later."
);
return;
}
// Only send pairing message on first contact (created=true)
// or if this is a new message (not just middleware check)
if (created) {
console.log(`[Telegram] New pairing request from ${userId} (${ctx.from?.username || 'no username'}): ${code}`);
await ctx.reply(formatPairingMessage(code), { parse_mode: 'Markdown' });
}
// Don't process the message further
return;
});
// Handle /start and /help
this.bot.command(['start', 'help'], async (ctx) => {
await ctx.reply(
"*LettaBot* - AI assistant with persistent memory\n\n" +
"*Commands:*\n" +
"/status - Show current status\n" +
"/help - Show this message\n\n" +
"Just send me a message to get started!",
{ parse_mode: 'Markdown' }
);
});
// Handle /status
this.bot.command('status', async (ctx) => {
if (this.onCommand) {
const result = await this.onCommand('status');
await ctx.reply(result || 'No status available');
}
});
// Handle /heartbeat (silent - no reply)
this.bot.command('heartbeat', async (ctx) => {
if (this.onCommand) {
await this.onCommand('heartbeat');
// No reply - heartbeat runs silently
}
});
// Handle text messages
this.bot.on('message:text', async (ctx) => {
const userId = ctx.from?.id;
const chatId = ctx.chat.id;
const text = ctx.message.text;
if (!userId) return;
if (text.startsWith('/')) return; // Skip other commands
if (this.onMessage) {
await this.onMessage({
channel: 'telegram',
chatId: String(chatId),
userId: String(userId),
userName: ctx.from.username || ctx.from.first_name,
text,
timestamp: new Date(),
});
}
});
// Error handler
this.bot.catch((err) => {
console.error('[Telegram] Bot error:', err);
});
}
async start(): Promise<void> {
if (this.running) return;
await this.bot.start({
onStart: (botInfo) => {
console.log(`[Telegram] Bot started as @${botInfo.username}`);
console.log(`[Telegram] DM policy: ${this.config.dmPolicy}`);
this.running = true;
},
});
}
async stop(): Promise<void> {
if (!this.running) return;
await this.bot.stop();
this.running = false;
}
isRunning(): boolean {
return this.running;
}
async sendMessage(msg: OutboundMessage): Promise<{ messageId: string }> {
const { markdownToTelegramV2 } = await import('./telegram-format.js');
// Convert markdown to Telegram MarkdownV2 format
const formatted = markdownToTelegramV2(msg.text);
const result = await this.bot.api.sendMessage(msg.chatId, formatted, {
parse_mode: 'MarkdownV2',
reply_to_message_id: msg.replyToMessageId ? Number(msg.replyToMessageId) : undefined,
});
return { messageId: String(result.message_id) };
}
async editMessage(chatId: string, messageId: string, text: string): Promise<void> {
const { markdownToTelegramV2 } = await import('./telegram-format.js');
const formatted = markdownToTelegramV2(text);
await this.bot.api.editMessageText(chatId, Number(messageId), formatted, { parse_mode: 'MarkdownV2' });
}
async sendTypingIndicator(chatId: string): Promise<void> {
await this.bot.api.sendChatAction(chatId, 'typing');
}
/**
* Get the underlying bot instance (for commands, etc.)
*/
getBot(): Bot {
return this.bot;
}
}

63
src/channels/types.ts Normal file
View File

@@ -0,0 +1,63 @@
/**
* Channel Adapter Interface
*
* Each channel (Telegram, Slack, WhatsApp) implements this interface.
*/
import type { ChannelId, InboundMessage, OutboundMessage } from '../core/types.js';
/**
* Channel adapter - implement this for each messaging platform
*/
export interface ChannelAdapter {
readonly id: ChannelId;
readonly name: string;
// Lifecycle
start(): Promise<void>;
stop(): Promise<void>;
isRunning(): boolean;
// Messaging
sendMessage(msg: OutboundMessage): Promise<{ messageId: string }>;
editMessage(chatId: string, messageId: string, text: string): Promise<void>;
sendTypingIndicator(chatId: string): Promise<void>;
// Capabilities (optional)
supportsEditing?(): boolean;
// Event handlers (set by bot core)
onMessage?: (msg: InboundMessage) => Promise<void>;
onCommand?: (command: string) => Promise<string | null>;
}
/**
* Typing heartbeat helper - keeps "typing..." indicator active
*/
export class TypingHeartbeat {
private interval: NodeJS.Timeout | null = null;
private adapter: ChannelAdapter | null = null;
private chatId: string | null = null;
start(adapter: ChannelAdapter, chatId: string): void {
this.stop();
this.adapter = adapter;
this.chatId = chatId;
const sendTyping = () => {
this.adapter?.sendTypingIndicator(this.chatId!).catch(() => {});
};
sendTyping();
this.interval = setInterval(sendTyping, 4000); // Most platforms expire typing after 5s
}
stop(): void {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
this.adapter = null;
this.chatId = null;
}
}

372
src/channels/whatsapp.ts Normal file
View File

@@ -0,0 +1,372 @@
/**
* WhatsApp Channel Adapter
*
* Uses @whiskeysockets/baileys for WhatsApp Web API.
* Supports DM pairing for secure access control.
*/
import type { ChannelAdapter } from './types.js';
import type { InboundMessage, OutboundMessage } from '../core/types.js';
import type { DmPolicy } from '../pairing/types.js';
import {
isUserAllowed,
upsertPairingRequest,
formatPairingMessage,
} from '../pairing/store.js';
import { existsSync, mkdirSync } from 'node:fs';
import { resolve } from 'node:path';
import qrcode from 'qrcode-terminal';
export interface WhatsAppConfig {
sessionPath?: string; // Where to store auth state
dmPolicy?: DmPolicy; // 'pairing' (default), 'allowlist', or 'open'
allowedUsers?: string[]; // Phone numbers (e.g., +15551234567)
selfChatMode?: boolean; // Respond to "message yourself" (for personal number use)
}
export class WhatsAppAdapter implements ChannelAdapter {
readonly id = 'whatsapp' as const;
readonly name = 'WhatsApp';
private sock: any = null;
private config: WhatsAppConfig;
private running = false;
private sessionPath: string;
private myJid: string = ''; // Bot's own JID (for selfChatMode)
private myNumber: string = ''; // Bot's phone number
private selfChatLid: string = ''; // Self-chat LID (for selfChatMode conversion)
private lidToJid: Map<string, string> = new Map(); // Map LID -> real JID for replies
private sentMessageIds: Set<string> = new Set(); // Track messages we've sent
private processedMessageIds: Set<string> = new Set(); // Dedupe incoming messages
onMessage?: (msg: InboundMessage) => Promise<void>;
constructor(config: WhatsAppConfig) {
this.config = {
...config,
dmPolicy: config.dmPolicy || 'pairing', // Default to pairing
};
this.sessionPath = resolve(config.sessionPath || './data/whatsapp-session');
}
/**
* Check if a user is authorized based on dmPolicy
* Returns 'allowed', 'blocked', or 'pairing'
*/
private async checkAccess(userId: string, userName?: string): Promise<'allowed' | 'blocked' | 'pairing'> {
const policy = this.config.dmPolicy || 'pairing';
const phone = userId.startsWith('+') ? userId : `+${userId}`;
// Open policy: everyone allowed
if (policy === 'open') {
return 'allowed';
}
// Self-chat mode: always allow self
if (this.config.selfChatMode && userId === this.myNumber) {
return 'allowed';
}
// Check if already allowed (config or store)
const allowed = await isUserAllowed('whatsapp', phone, this.config.allowedUsers);
if (allowed) {
return 'allowed';
}
// Allowlist policy: not allowed if not in list
if (policy === 'allowlist') {
return 'blocked';
}
// Pairing policy: needs pairing
return 'pairing';
}
/**
* Format pairing message for WhatsApp
*/
private formatPairingMsg(code: string): string {
return `Hi! This bot requires pairing.
Your pairing code: *${code}*
Ask the bot owner to approve with:
\`lettabot pairing approve whatsapp ${code}\``;
}
async start(): Promise<void> {
if (this.running) return;
// Suppress noisy Baileys console output (session crypto details, errors)
const originalLog = console.log;
const originalError = console.error;
const suppressPatterns = [
'Closing session',
'SessionEntry',
'Session error',
'Bad MAC',
'Failed to decrypt',
'Closing open session',
'prekey bundle',
];
const shouldSuppress = (msg: string) => suppressPatterns.some(p => msg.includes(p));
console.log = (...args: any[]) => {
const msg = args[0]?.toString?.() || '';
if (shouldSuppress(msg)) return;
originalLog.apply(console, args);
};
console.error = (...args: any[]) => {
const msg = args[0]?.toString?.() || '';
if (shouldSuppress(msg)) return;
originalError.apply(console, args);
};
// Check for competing WhatsApp bots
try {
const { execSync } = await import('node:child_process');
const procs = execSync('ps aux | grep -i "clawdbot\\|moltbot" | grep -v grep', { encoding: 'utf-8' });
if (procs.trim()) {
console.warn('[WhatsApp] ⚠️ Warning: clawdbot/moltbot is running and may compete for WhatsApp connection.');
console.warn('[WhatsApp] Stop it with: launchctl unload ~/Library/LaunchAgents/com.clawdbot.gateway.plist');
}
} catch {} // No competing bots found
// Ensure session directory exists
mkdirSync(this.sessionPath, { recursive: true });
// Dynamic import
const {
default: makeWASocket,
useMultiFileAuthState,
DisconnectReason,
fetchLatestBaileysVersion,
makeCacheableSignalKeyStore,
} = await import('@whiskeysockets/baileys');
// Load auth state
const { state, saveCreds } = await useMultiFileAuthState(this.sessionPath);
// Get latest WA Web version
const { version } = await fetchLatestBaileysVersion();
console.log('[WhatsApp] Using WA Web version:', version.join('.'));
// Silent logger to suppress noisy baileys logs
const silentLogger = {
level: 'silent',
trace: () => {},
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
fatal: () => {},
child: () => silentLogger,
};
// Create socket with proper config (matching moltbot)
this.sock = makeWASocket({
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, silentLogger as any),
},
version,
browser: ['LettaBot', 'Desktop', '1.0.0'],
syncFullHistory: false,
markOnlineOnConnect: false,
logger: silentLogger as any,
});
// Save credentials when updated
this.sock.ev.on('creds.update', saveCreds);
// Handle connection updates
this.sock.ev.on('connection.update', (update: any) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
console.log('[WhatsApp] Scan this QR code in WhatsApp → Linked Devices:');
qrcode.generate(qr, { small: true });
}
if (connection === 'close') {
const shouldReconnect = (lastDisconnect?.error as any)?.output?.statusCode !== DisconnectReason.loggedOut;
console.log('[WhatsApp] Connection closed, reconnecting:', shouldReconnect);
if (shouldReconnect) {
this.start(); // Reconnect
} else {
this.running = false;
}
} else if (connection === 'open') {
// Capture our own JID for selfChatMode
this.myJid = this.sock.user?.id || '';
this.myNumber = this.myJid.replace(/@.*/, '').replace(/:\d+/, '');
console.log(`[WhatsApp] Connected as ${this.myNumber}`);
this.running = true;
}
});
// Handle incoming messages
this.sock.ev.on('messages.upsert', async ({ messages, type }: any) => {
for (const m of messages) {
const messageId = m.key.id || '';
// Skip messages we sent (prevents loop in selfChatMode)
if (this.sentMessageIds.has(messageId)) {
this.sentMessageIds.delete(messageId); // Clean up
continue;
}
// Skip duplicate messages (WhatsApp retry mechanism)
if (this.processedMessageIds.has(messageId)) {
continue;
}
this.processedMessageIds.add(messageId);
setTimeout(() => this.processedMessageIds.delete(messageId), 60000);
const remoteJid = m.key.remoteJid || '';
// Detect self-chat: message from ourselves to ourselves
// For self-chat, senderPn is undefined, so we detect by: fromMe + LID + selfChatMode
const senderPn = (m.key as any).senderPn as string | undefined;
const isSelfChat = m.key.fromMe && (
remoteJid === this.myJid ||
remoteJid.replace(/@.*/, '') === this.myNumber ||
// In selfChatMode, fromMe + LID (with no senderPn) = self-chat
(this.config.selfChatMode && remoteJid.includes('@lid') && !senderPn)
);
// Track self-chat LID for reply conversion
if (isSelfChat && remoteJid.includes('@lid')) {
this.selfChatLid = remoteJid;
}
// Skip own messages (unless selfChatMode enabled for self-chat)
if (m.key.fromMe) {
if (!(this.config.selfChatMode && isSelfChat)) {
continue;
}
}
// Capture LID → real JID mapping from senderPn (for replying to LID contacts)
if (remoteJid.includes('@lid') && (m.key as any).senderPn) {
this.lidToJid.set(remoteJid, (m.key as any).senderPn);
}
// Get message text
const text = m.message?.conversation ||
m.message?.extendedTextMessage?.text ||
'';
if (!text) continue;
const userId = remoteJid.replace('@s.whatsapp.net', '').replace('@g.us', '');
const isGroup = remoteJid.endsWith('@g.us');
const pushName = m.pushName;
// Check access control (for DMs only, groups are open)
if (!isGroup) {
const access = await this.checkAccess(userId, pushName);
if (access === 'blocked') {
await this.sock.sendMessage(remoteJid, { text: "Sorry, you're not authorized to use this bot." });
continue;
}
if (access === 'pairing') {
// Create pairing request
const result = await upsertPairingRequest('whatsapp', userId, pushName);
if (!result) {
await this.sock.sendMessage(remoteJid, {
text: "Too many pending pairing requests. Please try again later."
});
continue;
}
const { code, created } = result;
// Send pairing message on first contact
if (created) {
console.log(`[WhatsApp] New pairing request from ${userId}: ${code}`);
await this.sock.sendMessage(remoteJid, { text: this.formatPairingMsg(code) });
}
continue;
}
}
if (this.onMessage) {
await this.onMessage({
channel: 'whatsapp',
chatId: remoteJid,
userId,
userName: pushName || undefined,
text,
timestamp: new Date(m.messageTimestamp * 1000),
isGroup,
// Group name would require additional API call to get chat metadata
// For now, we don't have it readily available from the message
});
}
}
});
}
async stop(): Promise<void> {
if (!this.running || !this.sock) return;
await this.sock.logout();
this.running = false;
}
isRunning(): boolean {
return this.running;
}
async sendMessage(msg: OutboundMessage): Promise<{ messageId: string }> {
if (!this.sock) throw new Error('WhatsApp not connected');
// Convert LID to proper JID for sending
let targetJid = msg.chatId;
if (targetJid.includes('@lid')) {
if (targetJid === this.selfChatLid && this.myNumber) {
// Self-chat LID -> our own number
targetJid = `${this.myNumber}@s.whatsapp.net`;
} else if (this.lidToJid.has(targetJid)) {
// Friend LID -> their real JID from senderPn
targetJid = this.lidToJid.get(targetJid)!;
}
// If no mapping, keep as-is and hope baileys handles it
}
try {
const result = await this.sock.sendMessage(targetJid, { text: msg.text });
const messageId = result?.key?.id || '';
// Track sent message to avoid processing it as incoming (selfChatMode loop prevention)
if (messageId) {
this.sentMessageIds.add(messageId);
// Clean up old IDs after 60 seconds
setTimeout(() => this.sentMessageIds.delete(messageId), 60000);
}
return { messageId };
} catch (error) {
console.error(`[WhatsApp] sendMessage error:`, error);
throw error;
}
}
supportsEditing(): boolean {
return false;
}
async editMessage(_chatId: string, _messageId: string, _text: string): Promise<void> {
// WhatsApp doesn't support editing messages - no-op
}
async sendTypingIndicator(chatId: string): Promise<void> {
if (!this.sock) return;
await this.sock.sendPresenceUpdate('composing', chatId);
}
}

485
src/cli.ts Normal file
View File

@@ -0,0 +1,485 @@
#!/usr/bin/env node
/**
* LettaBot CLI
*
* Commands:
* lettabot onboard - Onboarding workflow (setup integrations, install skills)
* lettabot server - Run the bot server
* lettabot configure - Configure settings
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import * as readline from 'node:readline';
const args = process.argv.slice(2);
const command = args[0];
const subCommand = args[1];
const ENV_PATH = resolve(process.cwd(), '.env');
const ENV_EXAMPLE_PATH = resolve(process.cwd(), '.env.example');
// Simple prompt helper
function prompt(question: string): Promise<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
// Load current env values
function loadEnv(): Record<string, string> {
const env: Record<string, string> = {};
if (existsSync(ENV_PATH)) {
const content = readFileSync(ENV_PATH, 'utf-8');
for (const line of content.split('\n')) {
if (line.startsWith('#') || !line.includes('=')) continue;
const [key, ...valueParts] = line.split('=');
env[key.trim()] = valueParts.join('=').trim();
}
}
return env;
}
// Save env values
function saveEnv(env: Record<string, string>): void {
// Start with example if no .env exists
let content = '';
if (existsSync(ENV_EXAMPLE_PATH)) {
content = readFileSync(ENV_EXAMPLE_PATH, 'utf-8');
}
// Update values
for (const [key, value] of Object.entries(env)) {
const regex = new RegExp(`^#?\\s*${key}=.*$`, 'm');
if (regex.test(content)) {
content = content.replace(regex, `${key}=${value}`);
} else {
content += `\n${key}=${value}`;
}
}
writeFileSync(ENV_PATH, content);
}
// Import onboard from separate module
import { onboard } from './onboard.js';
async function configure() {
console.log(`
╔═══════════════════════════════════════════════════════╗
║ LettaBot Configuration ║
╚═══════════════════════════════════════════════════════╝
`);
const env = loadEnv();
console.log('Current configuration:\n');
console.log(` LETTA_API_KEY: ${env.LETTA_API_KEY ? '✓ Set' : '✗ Not set'}`);
console.log(` TELEGRAM_BOT_TOKEN: ${env.TELEGRAM_BOT_TOKEN ? '✓ Set' : '✗ Not set'}`);
console.log(` SLACK_BOT_TOKEN: ${env.SLACK_BOT_TOKEN ? '✓ Set' : '✗ Not set'}`);
console.log(` SLACK_APP_TOKEN: ${env.SLACK_APP_TOKEN ? '✓ Set' : '✗ Not set'}`);
console.log(` HEARTBEAT_INTERVAL_MIN: ${env.HEARTBEAT_INTERVAL_MIN || 'Not set'}`);
console.log(` CRON_ENABLED: ${env.CRON_ENABLED || 'false'}`);
console.log(` WORKING_DIR: ${env.WORKING_DIR || '/tmp/lettabot'}`);
console.log(` AGENT_NAME: ${env.AGENT_NAME || 'LettaBot'}`);
console.log(` MODEL: ${env.MODEL || '(default)'}`);
console.log('\n\nWhat would you like to configure?\n');
console.log(' 1. Letta API Key');
console.log(' 2. Telegram');
console.log(' 3. Slack');
console.log(' 4. Heartbeat');
console.log(' 5. Cron');
console.log(' 6. Working Directory');
console.log(' 7. Agent Name & Model');
console.log(' 8. Edit .env directly');
console.log(' 9. Exit\n');
const choice = await prompt('Enter choice (1-9): ');
switch (choice) {
case '1':
env.LETTA_API_KEY = await prompt('Enter Letta API Key: ');
saveEnv(env);
console.log('✓ Saved');
break;
case '2':
env.TELEGRAM_BOT_TOKEN = await prompt('Enter Telegram Bot Token: ');
saveEnv(env);
console.log('✓ Saved');
break;
case '3':
env.SLACK_BOT_TOKEN = await prompt('Enter Slack Bot Token: ');
env.SLACK_APP_TOKEN = await prompt('Enter Slack App Token: ');
saveEnv(env);
console.log('✓ Saved');
break;
case '4':
env.HEARTBEAT_INTERVAL_MIN = await prompt('Heartbeat interval (minutes, 0 to disable): ');
saveEnv(env);
console.log('✓ Saved');
break;
case '5':
env.CRON_ENABLED = (await prompt('Enable cron? (y/n): ')).toLowerCase() === 'y' ? 'true' : 'false';
saveEnv(env);
console.log('✓ Saved');
break;
case '6':
env.WORKING_DIR = await prompt('Working directory: ');
saveEnv(env);
console.log('✓ Saved');
break;
case '7': {
const name = await prompt(`Agent name (current: ${env.AGENT_NAME || 'LettaBot'}): `);
if (name) env.AGENT_NAME = name;
// Model selection using live API data
const p = await import('@clack/prompts');
const { listModels } = await import('./tools/letta-api.js');
const spinner = p.spinner();
spinner.start('Fetching available models...');
const baseModels = await listModels({ providerCategory: 'base' });
spinner.stop(`Found ${baseModels.length} models`);
const tierLabels: Record<string, string> = {
'free': '🆓',
'premium': '⭐',
'per-inference': '💰',
};
const modelOptions = baseModels
.sort((a, b) => (a.display_name || a.name).localeCompare(b.display_name || b.name))
.map(m => ({
value: m.handle,
label: m.display_name || m.name,
hint: tierLabels[m.tier || 'free'] || '',
}));
const currentModel = env.MODEL || 'default';
console.log(`\nCurrent model: ${currentModel}\n`);
const modelChoice = await p.select({
message: 'Select model',
options: [
...modelOptions,
{ value: '__custom__', label: 'Custom', hint: 'Enter a model handle manually' },
{ value: '__keep__', label: 'Keep current', hint: currentModel },
],
});
if (!p.isCancel(modelChoice) && modelChoice !== '__keep__') {
if (modelChoice === '__custom__') {
const customModel = await prompt('Enter model handle: ');
if (customModel) env.MODEL = customModel;
} else if (modelChoice) {
env.MODEL = modelChoice as string;
}
}
saveEnv(env);
console.log('✓ Saved');
break;
}
case '8':
const editor = process.env.EDITOR || 'nano';
spawnSync(editor, [ENV_PATH], { stdio: 'inherit' });
break;
case '9':
break;
default:
console.log('Invalid choice');
}
}
async function server() {
// Check if configured
if (!existsSync(ENV_PATH)) {
console.log('No .env found. Run "lettabot onboard" first.\n');
process.exit(1);
}
console.log('Starting LettaBot server...\n');
// Start the bot using the compiled JS
const mainPath = resolve(process.cwd(), 'dist/main.js');
if (existsSync(mainPath)) {
spawn('node', [mainPath], {
stdio: 'inherit',
cwd: process.cwd(),
env: { ...process.env },
});
} else {
// Fallback to tsx for development
const mainTsPath = new URL('./main.ts', import.meta.url).pathname;
spawn('npx', ['tsx', mainTsPath], {
stdio: 'inherit',
cwd: process.cwd(),
});
}
}
// Pairing commands
async function pairingList(channel: string) {
const { listPairingRequests } = await import('./pairing/store.js');
const requests = await listPairingRequests(channel);
if (requests.length === 0) {
console.log(`No pending ${channel} pairing requests.`);
return;
}
console.log(`\nPending ${channel} pairing requests (${requests.length}):\n`);
console.log(' Code | User ID | Username | Requested');
console.log(' ----------|-------------------|-------------------|---------------------');
for (const r of requests) {
const username = r.meta?.username ? `@${r.meta.username}` : r.meta?.firstName || '-';
const date = new Date(r.createdAt).toLocaleString();
console.log(` ${r.code.padEnd(10)}| ${r.id.padEnd(18)}| ${username.padEnd(18)}| ${date}`);
}
console.log('');
}
async function pairingApprove(channel: string, code: string) {
const { approvePairingCode } = await import('./pairing/store.js');
const result = await approvePairingCode(channel, code);
if (!result) {
console.log(`No pending pairing request found for code: ${code}`);
process.exit(1);
}
const name = result.meta?.username ? `@${result.meta.username}` : result.meta?.firstName || result.userId;
console.log(`✓ Approved ${channel} sender: ${name} (${result.userId})`);
}
function showHelp() {
console.log(`
LettaBot - Multi-channel AI assistant with persistent memory
Usage: lettabot <command>
Commands:
onboard Setup wizard (integrations, skills, configuration)
server Start the bot server
configure View and edit configuration
logout Logout from Letta Platform (revoke OAuth tokens)
skills Configure which skills are enabled
skills status Show skills status
destroy Delete all local data and start fresh
pairing list <ch> List pending pairing requests
pairing approve <ch> <code> Approve a pairing code
help Show this help message
Examples:
lettabot onboard # First-time setup
lettabot server # Start the bot
lettabot pairing list telegram # Show pending Telegram pairings
lettabot pairing approve telegram ABCD1234 # Approve a pairing code
Environment:
LETTA_API_KEY API key from app.letta.com
TELEGRAM_BOT_TOKEN Bot token from @BotFather
TELEGRAM_DM_POLICY DM access policy (pairing, allowlist, open)
SLACK_BOT_TOKEN Slack bot token (xoxb-...)
SLACK_APP_TOKEN Slack app token (xapp-...)
HEARTBEAT_INTERVAL_MIN Heartbeat interval in minutes
CRON_ENABLED Enable cron jobs (true/false)
`);
}
async function main() {
switch (command) {
case 'onboard':
case 'setup':
case 'init':
await onboard();
break;
case 'server':
case 'start':
case 'run':
await server();
break;
case 'configure':
case 'config':
await configure();
break;
case 'skills': {
const { showStatus, runSkillsSync } = await import('./skills/index.js');
switch (subCommand) {
case 'status':
await showStatus();
break;
default:
await runSkillsSync();
}
break;
}
case 'pairing': {
const channel = subCommand;
const action = args[2];
if (!channel) {
console.log('Usage: lettabot pairing <list|approve> <channel> [code]');
console.log('Example: lettabot pairing list telegram');
console.log('Example: lettabot pairing approve telegram ABCD1234');
process.exit(1);
}
// Support both "pairing list telegram" and "pairing telegram list"
if (channel === 'list' || channel === 'ls') {
const ch = action || args[3];
if (!ch) {
console.log('Usage: lettabot pairing list <channel>');
process.exit(1);
}
await pairingList(ch);
} else if (channel === 'approve') {
const ch = action;
const code = args[3];
if (!ch || !code) {
console.log('Usage: lettabot pairing approve <channel> <code>');
process.exit(1);
}
await pairingApprove(ch, code);
} else if (action === 'list' || action === 'ls') {
await pairingList(channel);
} else if (action === 'approve') {
const code = args[3];
if (!code) {
console.log('Usage: lettabot pairing approve <channel> <code>');
process.exit(1);
}
await pairingApprove(channel, code);
} else if (action) {
// Assume "lettabot pairing telegram ABCD1234" means approve
await pairingApprove(channel, action);
} else {
await pairingList(channel);
}
break;
}
case 'destroy': {
const { rmSync, existsSync } = await import('node:fs');
const { join } = await import('node:path');
const p = await import('@clack/prompts');
const workingDir = process.env.WORKING_DIR || '/tmp/lettabot';
// Agent store is in cwd, not working dir
const agentJsonPath = join(process.cwd(), 'lettabot-agent.json');
const skillsDir = join(workingDir, '.skills');
const cronJobsPath = join(workingDir, 'cron-jobs.json');
p.intro('🗑️ Destroy LettaBot Data');
p.log.warn('This will delete:');
p.log.message(` • Agent store: ${agentJsonPath}`);
p.log.message(` • Skills: ${skillsDir}`);
p.log.message(` • Cron jobs: ${cronJobsPath}`);
p.log.message('');
p.log.message('Note: The agent on Letta servers will NOT be deleted.');
const confirmed = await p.confirm({
message: 'Are you sure you want to destroy all local data?',
initialValue: false,
});
if (!confirmed || p.isCancel(confirmed)) {
p.cancel('Cancelled');
break;
}
// Delete files
let deleted = 0;
if (existsSync(agentJsonPath)) {
rmSync(agentJsonPath);
p.log.success('Deleted lettabot-agent.json');
deleted++;
}
if (existsSync(skillsDir)) {
rmSync(skillsDir, { recursive: true });
p.log.success('Deleted .skills/');
deleted++;
}
if (existsSync(cronJobsPath)) {
rmSync(cronJobsPath);
p.log.success('Deleted cron-jobs.json');
deleted++;
}
if (deleted === 0) {
p.log.info('Nothing to delete');
}
p.outro('✨ Done! Run `lettabot server` to create a fresh agent.');
break;
}
case 'logout': {
const { revokeToken } = await import('./auth/oauth.js');
const { loadTokens, deleteTokens } = await import('./auth/tokens.js');
const p = await import('@clack/prompts');
p.intro('Logout from Letta Platform');
const tokens = loadTokens();
if (!tokens) {
p.log.info('No stored credentials found.');
break;
}
const spinner = p.spinner();
spinner.start('Revoking token...');
// Revoke the refresh token on the server
if (tokens.refreshToken) {
await revokeToken(tokens.refreshToken);
}
// Delete local tokens
deleteTokens();
spinner.stop('Logged out successfully');
p.log.info('Note: LETTA_API_KEY in .env was not modified. Remove it manually if needed.');
p.outro('Goodbye!');
break;
}
case 'help':
case '-h':
case '--help':
showHelp();
break;
case undefined:
console.log('Usage: lettabot <command>\n');
console.log('Commands: onboard, server, configure, skills, destroy, help\n');
console.log('Run "lettabot help" for more information.');
break;
default:
console.log(`Unknown command: ${command}`);
console.log('Run "lettabot help" for usage.');
process.exit(1);
}
}
main().catch(console.error);

261
src/cli/message.ts Normal file
View File

@@ -0,0 +1,261 @@
#!/usr/bin/env node
/**
* lettabot-message - Send messages to channels
*
* Usage:
* lettabot-message send --text "Hello!" [--channel telegram] [--chat 123456]
* lettabot-message send -t "Hello!"
*
* The agent can use this CLI via Bash to send messages during silent mode
* (heartbeats, cron jobs) or to send to different channels during conversations.
*/
import 'dotenv/config';
import { resolve } from 'node:path';
import { existsSync, readFileSync } from 'node:fs';
// Types
interface LastTarget {
channel: string;
chatId: string;
}
interface AgentStore {
agentId?: string;
lastTarget?: LastTarget;
}
// Store path (same location as bot uses)
const STORE_PATH = resolve(process.cwd(), 'lettabot-agent.json');
function loadLastTarget(): LastTarget | null {
try {
if (existsSync(STORE_PATH)) {
const store: AgentStore = JSON.parse(readFileSync(STORE_PATH, 'utf-8'));
return store.lastTarget || null;
}
} catch {
// Ignore
}
return null;
}
// Channel senders
async function sendTelegram(chatId: string, text: string): Promise<void> {
const token = process.env.TELEGRAM_BOT_TOKEN;
if (!token) {
throw new Error('TELEGRAM_BOT_TOKEN not set');
}
const url = `https://api.telegram.org/bot${token}/sendMessage`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text: text,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Telegram API error: ${error}`);
}
const result = await response.json() as { ok: boolean; result?: { message_id: number } };
if (!result.ok) {
throw new Error(`Telegram API returned ok=false`);
}
console.log(`✓ Sent to telegram:${chatId} (message_id: ${result.result?.message_id})`);
}
async function sendSlack(chatId: string, text: string): Promise<void> {
const token = process.env.SLACK_BOT_TOKEN;
if (!token) {
throw new Error('SLACK_BOT_TOKEN not set');
}
const response = await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
channel: chatId,
text: text,
}),
});
const result = await response.json() as { ok: boolean; ts?: string; error?: string };
if (!result.ok) {
throw new Error(`Slack API error: ${result.error}`);
}
console.log(`✓ Sent to slack:${chatId} (ts: ${result.ts})`);
}
async function sendSignal(chatId: string, text: string): Promise<void> {
const apiUrl = process.env.SIGNAL_CLI_REST_API_URL || 'http://localhost:8080';
const phoneNumber = process.env.SIGNAL_PHONE_NUMBER;
if (!phoneNumber) {
throw new Error('SIGNAL_PHONE_NUMBER not set');
}
const response = await fetch(`${apiUrl}/v2/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: text,
number: phoneNumber,
recipients: [chatId],
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Signal API error: ${error}`);
}
console.log(`✓ Sent to signal:${chatId}`);
}
async function sendWhatsApp(chatId: string, text: string): Promise<void> {
// WhatsApp requires a running session, so we write to a queue file
// that the bot process picks up. For now, error out.
throw new Error('WhatsApp sending via CLI not yet supported (requires active session)');
}
async function sendToChannel(channel: string, chatId: string, text: string): Promise<void> {
switch (channel.toLowerCase()) {
case 'telegram':
return sendTelegram(chatId, text);
case 'slack':
return sendSlack(chatId, text);
case 'signal':
return sendSignal(chatId, text);
case 'whatsapp':
return sendWhatsApp(chatId, text);
default:
throw new Error(`Unknown channel: ${channel}. Supported: telegram, slack, signal`);
}
}
// Command handlers
async function sendCommand(args: string[]): Promise<void> {
let text = '';
let channel = '';
let chatId = '';
// Parse args
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const next = args[i + 1];
if ((arg === '--text' || arg === '-t') && next) {
text = next;
i++;
} else if ((arg === '--channel' || arg === '-c') && next) {
channel = next;
i++;
} else if ((arg === '--chat' || arg === '--to') && next) {
chatId = next;
i++;
}
}
if (!text) {
console.error('Error: --text is required');
console.error('Usage: lettabot-message send --text "Hello!" [--channel telegram] [--chat 123456]');
process.exit(1);
}
// Resolve defaults from last target
if (!channel || !chatId) {
const lastTarget = loadLastTarget();
if (lastTarget) {
channel = channel || lastTarget.channel;
chatId = chatId || lastTarget.chatId;
}
}
if (!channel) {
console.error('Error: --channel is required (no default available)');
console.error('Specify: --channel telegram|slack|signal');
process.exit(1);
}
if (!chatId) {
console.error('Error: --chat is required (no default available)');
console.error('Specify: --chat <chat_id>');
process.exit(1);
}
try {
await sendToChannel(channel, chatId, text);
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
}
function showHelp(): void {
console.log(`
lettabot-message - Send messages to channels
Commands:
send [options] Send a message
Send options:
--text, -t <text> Message text (required)
--channel, -c <name> Channel: telegram, slack, signal (default: last used)
--chat, --to <id> Chat/conversation ID (default: last messaged)
Examples:
# Send to last messaged user/channel
lettabot-message send --text "Hello!"
# Send to specific Telegram chat
lettabot-message send --text "Hello!" --channel telegram --chat 123456789
# Short form
lettabot-message send -t "Done!" -c telegram -to 123456789
Environment variables:
TELEGRAM_BOT_TOKEN Required for Telegram
SLACK_BOT_TOKEN Required for Slack
SIGNAL_PHONE_NUMBER Required for Signal
SIGNAL_CLI_REST_API_URL Signal API URL (default: http://localhost:8080)
`);
}
// Main
const args = process.argv.slice(2);
const command = args[0];
switch (command) {
case 'send':
sendCommand(args.slice(1));
break;
case 'help':
case '--help':
case '-h':
showHelp();
break;
default:
if (command) {
// Assume it's send with args starting with the command
// e.g., `lettabot-message --text "Hi"` (no 'send' subcommand)
if (command.startsWith('-')) {
sendCommand(args);
break;
}
console.error(`Unknown command: ${command}`);
}
showHelp();
break;
}

380
src/core/bot.ts Normal file
View File

@@ -0,0 +1,380 @@
/**
* LettaBot Core - Handles agent communication
*
* Single agent, single conversation - chat continues across all channels.
*/
import { createSession, resumeSession, type Session } from '@letta-ai/letta-code-sdk';
import { mkdirSync } from 'node:fs';
import type { ChannelAdapter } from '../channels/types.js';
import type { BotConfig, InboundMessage, TriggerContext } from './types.js';
import { Store } from './store.js';
import { updateAgentName } from '../tools/letta-api.js';
import { installSkillsToAgent } from '../skills/loader.js';
import { formatMessageEnvelope } from './formatter.js';
import { loadMemoryBlocks } from './memory.js';
import { SYSTEM_PROMPT } from './system-prompt.js';
export class LettaBot {
private store: Store;
private config: BotConfig;
private channels: Map<string, ChannelAdapter> = new Map();
private messageQueue: Array<{ msg: InboundMessage; adapter: ChannelAdapter }> = [];
// Callback to trigger heartbeat (set by main.ts)
public onTriggerHeartbeat?: () => Promise<void>;
private processing = false;
constructor(config: BotConfig) {
this.config = config;
// Ensure working directory exists
mkdirSync(config.workingDir, { recursive: true });
// Store in project root (same as main.ts reads for LETTA_AGENT_ID)
this.store = new Store('lettabot-agent.json');
console.log(`LettaBot initialized. Agent ID: ${this.store.agentId || '(new)'}`);
}
/**
* Register a channel adapter
*/
registerChannel(adapter: ChannelAdapter): void {
adapter.onMessage = (msg) => this.handleMessage(msg, adapter);
adapter.onCommand = (cmd) => this.handleCommand(cmd);
this.channels.set(adapter.id, adapter);
console.log(`Registered channel: ${adapter.name}`);
}
/**
* Handle slash commands
*/
private async handleCommand(command: string): Promise<string | null> {
console.log(`[Command] Received: /${command}`);
switch (command) {
case 'status': {
const info = this.store.getInfo();
const lines = [
`*Status*`,
`Agent ID: \`${info.agentId || '(none)'}\``,
`Created: ${info.createdAt || 'N/A'}`,
`Last used: ${info.lastUsedAt || 'N/A'}`,
`Channels: ${Array.from(this.channels.keys()).join(', ')}`,
];
return lines.join('\n');
}
case 'heartbeat': {
console.log('[Command] /heartbeat received');
if (!this.onTriggerHeartbeat) {
console.log('[Command] /heartbeat - no trigger callback configured');
return '⚠️ Heartbeat service not configured';
}
console.log('[Command] /heartbeat - triggering heartbeat...');
// Trigger heartbeat asynchronously
this.onTriggerHeartbeat().catch(err => {
console.error('[Heartbeat] Manual trigger failed:', err);
});
return '⏰ Heartbeat triggered (silent mode - check server logs)';
}
default:
return null;
}
}
/**
* Start all registered channels
*/
async start(): Promise<void> {
const startPromises = Array.from(this.channels.entries()).map(async ([id, adapter]) => {
try {
console.log(`Starting channel: ${adapter.name}...`);
await adapter.start();
console.log(`Started channel: ${adapter.name}`);
} catch (e) {
console.error(`Failed to start channel ${id}:`, e);
}
});
await Promise.all(startPromises);
}
/**
* Stop all channels
*/
async stop(): Promise<void> {
for (const adapter of this.channels.values()) {
try {
await adapter.stop();
} catch (e) {
console.error(`Failed to stop channel ${adapter.id}:`, e);
}
}
}
/**
* Queue incoming message for processing (prevents concurrent SDK sessions)
*/
private async handleMessage(msg: InboundMessage, adapter: ChannelAdapter): Promise<void> {
console.log(`[${msg.channel}] Message from ${msg.userId}: ${msg.text}`);
// Add to queue
this.messageQueue.push({ msg, adapter });
// Process queue if not already processing
if (!this.processing) {
this.processQueue();
}
}
/**
* Process messages one at a time
*/
private async processQueue(): Promise<void> {
if (this.processing || this.messageQueue.length === 0) return;
this.processing = true;
while (this.messageQueue.length > 0) {
const { msg, adapter } = this.messageQueue.shift()!;
try {
await this.processMessage(msg, adapter);
} catch (error) {
console.error('[Queue] Error processing message:', error);
}
}
this.processing = false;
}
/**
* Process a single message
*/
private async processMessage(msg: InboundMessage, adapter: ChannelAdapter): Promise<void> {
// Track last message target for heartbeat delivery
this.store.lastMessageTarget = {
channel: msg.channel,
chatId: msg.chatId,
updatedAt: new Date().toISOString(),
};
// Start typing indicator
await adapter.sendTypingIndicator(msg.chatId);
// Create or resume session
let session: Session;
const baseOptions = {
permissionMode: 'bypassPermissions' as const,
allowedTools: this.config.allowedTools,
cwd: this.config.workingDir,
model: this.config.model,
systemPrompt: SYSTEM_PROMPT,
};
console.log('[Bot] Session options:', JSON.stringify(baseOptions, null, 2));
try {
if (this.store.agentId) {
process.env.LETTA_AGENT_ID = this.store.agentId;
console.log(`[Bot] Resuming session for agent ${this.store.agentId}`);
console.log(`[Bot] LETTA_BASE_URL=${process.env.LETTA_BASE_URL}`);
console.log(`[Bot] LETTA_API_KEY=${process.env.LETTA_API_KEY ? '(set)' : '(not set)'}`);
session = resumeSession(this.store.agentId, baseOptions);
} else {
console.log('[Bot] Creating new session');
session = createSession({ ...baseOptions, memory: loadMemoryBlocks(this.config.agentName) });
}
console.log(`[Bot] Session object:`, Object.keys(session));
console.log(`[Bot] Session initialized:`, (session as any).initialized);
console.log(`[Bot] Session _agentId:`, (session as any)._agentId);
console.log(`[Bot] Session options.permissionMode:`, (session as any).options?.permissionMode);
// Hook into transport errors
const transport = (session as any).transport;
if (transport?.process) {
transport.process.stderr?.on('data', (data: Buffer) => {
console.error('[Bot] CLI stderr:', data.toString());
});
}
// Send message to agent with metadata envelope
const formattedMessage = formatMessageEnvelope(msg);
console.log('[Bot] Sending message...');
await session.send(formattedMessage);
console.log('[Bot] Message sent, starting stream...');
// Stream response
let response = '';
let lastUpdate = Date.now();
let messageId: string | null = null;
// Keep typing indicator alive
const typingInterval = setInterval(() => {
adapter.sendTypingIndicator(msg.chatId).catch(() => {});
}, 4000);
try {
for await (const streamMsg of session.stream()) {
if (streamMsg.type === 'assistant') {
response += streamMsg.content;
// Stream updates only for channels that support editing (Telegram, Slack)
const canEdit = adapter.supportsEditing?.() ?? true;
if (canEdit && Date.now() - lastUpdate > 500 && response.length > 0) {
try {
if (messageId) {
await adapter.editMessage(msg.chatId, messageId, response);
} else {
const result = await adapter.sendMessage({ chatId: msg.chatId, text: response, threadId: msg.threadId });
messageId = result.messageId;
}
} catch {
// Ignore edit errors
}
lastUpdate = Date.now();
}
}
if (streamMsg.type === 'result') {
// Save agent ID and attach ignore tool (only on first message)
if (session.agentId && session.agentId !== this.store.agentId) {
const isNewAgent = !this.store.agentId;
this.store.agentId = session.agentId;
console.log('Saved agent ID:', session.agentId);
// Setup new agents: set name, install skills
if (isNewAgent) {
if (this.config.agentName) {
updateAgentName(session.agentId, this.config.agentName).catch(() => {});
}
installSkillsToAgent(session.agentId);
}
}
break;
}
}
} finally {
clearInterval(typingInterval);
}
// Send final response
if (response) {
try {
if (messageId) {
await adapter.editMessage(msg.chatId, messageId, response);
} else {
await adapter.sendMessage({ chatId: msg.chatId, text: response, threadId: msg.threadId });
}
} catch {
// If we already sent a streamed message, don't duplicate — the user already saw it.
if (!messageId) {
await adapter.sendMessage({ chatId: msg.chatId, text: response, threadId: msg.threadId });
}
}
} else {
await adapter.sendMessage({ chatId: msg.chatId, text: '(No response from agent)', threadId: msg.threadId });
}
} catch (error) {
console.error('Error processing message:', error);
await adapter.sendMessage({
chatId: msg.chatId,
text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
threadId: msg.threadId,
});
} finally {
session!?.close();
}
}
/**
* Send a message to the agent (for cron jobs, webhooks, etc.)
*
* In silent mode (heartbeats, cron), the agent's text response is NOT auto-delivered.
* The agent must use `lettabot-message` CLI via Bash to send messages explicitly.
*
* @param text - The prompt/message to send
* @param context - Optional trigger context (for logging/tracking)
* @returns The agent's response text
*/
async sendToAgent(
text: string,
_context?: TriggerContext
): Promise<string> {
const baseOptions = {
permissionMode: 'bypassPermissions' as const,
allowedTools: this.config.allowedTools,
cwd: this.config.workingDir,
model: this.config.model,
systemPrompt: SYSTEM_PROMPT,
};
let session: Session;
if (this.store.agentId) {
session = resumeSession(this.store.agentId, baseOptions);
} else {
session = createSession({ ...baseOptions, memory: loadMemoryBlocks(this.config.agentName) });
}
try {
await session.send(text);
let response = '';
for await (const msg of session.stream()) {
if (msg.type === 'assistant') {
response += msg.content;
}
if (msg.type === 'result') {
if (session.agentId) {
this.store.agentId = session.agentId;
}
break;
}
}
return response;
} finally {
session.close();
}
}
/**
* Deliver a message to a specific channel
*/
async deliverToChannel(channelId: string, chatId: string, text: string): Promise<void> {
const adapter = this.channels.get(channelId);
if (!adapter) {
console.error(`Channel not found: ${channelId}`);
return;
}
await adapter.sendMessage({ chatId, text });
}
/**
* Get bot status
*/
getStatus(): { agentId: string | null; channels: string[] } {
return {
agentId: this.store.agentId,
channels: Array.from(this.channels.keys()),
};
}
/**
* Reset agent (clear memory)
*/
reset(): void {
this.store.reset();
console.log('Agent reset');
}
/**
* Get the last message target (for heartbeat delivery)
*/
getLastMessageTarget(): { channel: string; chatId: string } | null {
return this.store.lastMessageTarget || null;
}
}

186
src/core/formatter.ts Normal file
View File

@@ -0,0 +1,186 @@
/**
* Message Envelope Formatter
*
* Formats incoming messages with metadata context for the agent.
* Based on moltbot's envelope pattern.
*/
import type { InboundMessage } from './types.js';
/**
* Channel format hints - tells the agent what formatting syntax to use
* Each channel has different markdown support - hints help agent format appropriately.
*/
const CHANNEL_FORMATS: Record<string, string> = {
slack: 'mrkdwn: bold/italic/code/links - NO: headers, tables',
telegram: 'MarkdownV2: bold/italic/code/links/quotes - NO: headers, tables',
whatsapp: 'bold/italic/code - NO: headers, code fences, links, tables',
signal: 'ONLY: bold/italic/code - NO: headers, code fences, links, quotes, tables',
};
export interface EnvelopeOptions {
timezone?: 'local' | 'utc' | string; // IANA timezone or 'local'/'utc'
includeDay?: boolean; // Include day of week (default: true)
includeSender?: boolean; // Include sender info (default: true)
includeGroup?: boolean; // Include group name (default: true)
}
const DEFAULT_OPTIONS: EnvelopeOptions = {
timezone: 'local',
includeDay: true,
includeSender: true,
includeGroup: true,
};
/**
* Format a phone number nicely: +15551234567 -> +1 (555) 123-4567
*/
function formatPhoneNumber(phone: string): string {
// Remove any non-digit characters except leading +
const hasPlus = phone.startsWith('+');
const digits = phone.replace(/\D/g, '');
if (digits.length === 11 && digits.startsWith('1')) {
// US number: 1AAABBBCCCC -> +1 (AAA) BBB-CCCC
return `+1 (${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7)}`;
} else if (digits.length === 10) {
// US number without country code: AAABBBCCCC -> +1 (AAA) BBB-CCCC
return `+1 (${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
}
// For other formats, just add the + back if it was there
return hasPlus ? `+${digits}` : digits;
}
/**
* Format the sender identifier nicely based on channel
*/
function formatSender(msg: InboundMessage): string {
// Use display name if available
if (msg.userName?.trim()) {
return msg.userName.trim();
}
// Format based on channel
switch (msg.channel) {
case 'slack':
// Add @ prefix for Slack usernames/IDs
return msg.userHandle ? `@${msg.userHandle}` : `@${msg.userId}`;
case 'whatsapp':
case 'signal':
// Format phone numbers nicely
if (/^\+?\d{10,}$/.test(msg.userId.replace(/\D/g, ''))) {
return formatPhoneNumber(msg.userId);
}
return msg.userId;
case 'telegram':
return msg.userHandle ? `@${msg.userHandle}` : msg.userId;
default:
return msg.userId;
}
}
/**
* Format channel name for display
*/
function formatChannelName(channel: string): string {
return channel.charAt(0).toUpperCase() + channel.slice(1);
}
/**
* Format timestamp with day of week and timezone
*/
function formatTimestamp(date: Date, options: EnvelopeOptions): string {
const parts: string[] = [];
// Determine timezone settings
let timeZone: string | undefined;
if (options.timezone === 'utc') {
timeZone = 'UTC';
} else if (options.timezone && options.timezone !== 'local') {
// Validate IANA timezone
try {
new Intl.DateTimeFormat('en-US', { timeZone: options.timezone });
timeZone = options.timezone;
} catch {
// Invalid timezone, fall back to local
timeZone = undefined;
}
}
// Day of week
if (options.includeDay !== false) {
const dayFormatter = new Intl.DateTimeFormat('en-US', {
weekday: 'long',
timeZone,
});
parts.push(dayFormatter.format(date));
}
// Date and time
const dateFormatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
timeZone,
timeZoneName: 'short',
});
parts.push(dateFormatter.format(date));
return parts.join(', ');
}
/**
* Format a message with metadata envelope
*
* Format: [Channel GroupName Sender Timestamp] Message
*
* Examples:
* - [Slack #general @cameron Monday, Jan 27, 4:30 PM PST] Hello!
* - [WhatsApp Cameron Monday, Jan 27, 5:00 PM PST] Hi there
* - [Signal Family Group +1 (555) 123-4567 Tuesday, Jan 28, 9:30 AM PST] Dinner at 7?
*/
export function formatMessageEnvelope(
msg: InboundMessage,
options: EnvelopeOptions = {}
): string {
const opts = { ...DEFAULT_OPTIONS, ...options };
const parts: string[] = [];
// Channel name with format hint
const formatHint = CHANNEL_FORMATS[msg.channel];
if (formatHint) {
parts.push(`${formatChannelName(msg.channel)} (${formatHint})`);
} else {
parts.push(formatChannelName(msg.channel));
}
// Group name (if group chat and enabled)
if (opts.includeGroup !== false && msg.isGroup && msg.groupName?.trim()) {
// Format group name with # for Slack channels
if (msg.channel === 'slack' && !msg.groupName.startsWith('#')) {
parts.push(`#${msg.groupName}`);
} else {
parts.push(msg.groupName);
}
}
// Sender
if (opts.includeSender !== false) {
parts.push(formatSender(msg));
}
// Timestamp
const timestamp = formatTimestamp(msg.timestamp, opts);
parts.push(timestamp);
// Build envelope
const envelope = `[${parts.join(' ')}]`;
return `${envelope} ${msg.text}`;
}

9
src/core/index.ts Normal file
View File

@@ -0,0 +1,9 @@
/**
* Core Exports
*/
export * from './types.js';
export * from './store.js';
export * from './bot.js';
export * from './formatter.js';
export * from './prompts.js';

56
src/core/memory.ts Normal file
View File

@@ -0,0 +1,56 @@
/**
* Memory loader - reads .mdx files from src/memories/ and returns
* CreateBlock objects for the Letta Code SDK.
*/
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import matter from 'gray-matter';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Try dist/memories first, fall back to src/memories
const MEMORIES_DIR = existsSync(join(__dirname, '..', 'memories'))
? join(__dirname, '..', 'memories')
: join(__dirname, '..', '..', 'src', 'memories');
export interface MemoryBlock {
label: string;
value: string;
description?: string;
limit?: number;
}
/**
* Load all .mdx files from the memories directory and parse them into
* memory blocks for the SDK's `memory` option.
*
* @param agentName - Name to substitute for {{AGENT_NAME}} in block values
*/
export function loadMemoryBlocks(agentName = 'LettaBot'): MemoryBlock[] {
if (!existsSync(MEMORIES_DIR)) {
console.warn(`[Memory] No memories directory found at ${MEMORIES_DIR}`);
return [];
}
const files = readdirSync(MEMORIES_DIR).filter((f: string) => f.endsWith('.mdx'));
const blocks: MemoryBlock[] = [];
for (const file of files) {
const raw = readFileSync(join(MEMORIES_DIR, file), 'utf-8');
const { data, content } = matter(raw);
const label = data.label || file.replace('.mdx', '');
const block: MemoryBlock = {
label,
value: content.trim().replaceAll('{{AGENT_NAME}}', agentName),
};
if (data.description) block.description = data.description;
if (data.limit) block.limit = Number(data.limit);
blocks.push(block);
}
return blocks;
}

155
src/core/prompts.ts Normal file
View File

@@ -0,0 +1,155 @@
/**
* System Prompts for Different Trigger Modes
*
* These prompts are injected based on how the agent was triggered.
* The key difference is whether assistant text auto-delivers or not.
*/
/**
* Silent mode prefix - injected for heartbeats, cron, and other background triggers
*
* This makes it CRYSTAL CLEAR that the agent's text output goes nowhere
* and they must use the lettabot-message CLI to communicate.
*/
export const SILENT_MODE_PREFIX = `
╔════════════════════════════════════════════════════════════════╗
║ [SILENT MODE] - Your text output is NOT sent to anyone. ║
║ To send a message, use the lettabot-message CLI via Bash. ║
║ Example: lettabot-message send --text "Hello!" ║
╚════════════════════════════════════════════════════════════════╝
`.trim();
/**
* Heartbeat prompt - explains the context and encourages autonomous work
*/
export function buildHeartbeatPrompt(time: string, timezone: string, intervalMinutes: number): string {
return `
${SILENT_MODE_PREFIX}
TRIGGER: Scheduled heartbeat
TIME: ${time} (${timezone})
NEXT HEARTBEAT: in ${intervalMinutes} minutes
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
No one messaged you. The system woke you up on schedule.
YOUR TEXT OUTPUT IS PRIVATE - only you can see it.
To actually contact your human, run:
lettabot-message send --text "Your message here"
This is your time. You can:
• Work on a project you've been thinking about
• Research something that interests you
• Reflect on recent conversations and update your memory
• Continue multi-step work from previous heartbeats
• Pursue curiosities, hobbies, or learning
You don't have to do something every time. But don't default to nothing.
Your human wants you to have a life outside conversations with them.
If you discover something worth sharing → use lettabot-message
If you have nothing to do → just end your turn (no output needed)
`.trim();
}
/**
* Cron job prompt (silent mode) - for background scheduled tasks
*/
export function buildCronPrompt(
jobName: string,
jobPrompt: string,
time: string,
timezone: string
): string {
return `
${SILENT_MODE_PREFIX}
TRIGGER: Scheduled cron job
JOB: ${jobName}
TIME: ${time} (${timezone})
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
YOUR TEXT OUTPUT IS PRIVATE - only you can see it.
To send results to your human, run:
lettabot-message send --text "Your results here"
TASK:
${jobPrompt}
`.trim();
}
/**
* Cron job prompt (notify mode) - for jobs that should auto-deliver
*/
export function buildCronNotifyPrompt(
jobName: string,
jobPrompt: string,
time: string,
timezone: string,
targetChannel: string,
targetChatId: string
): string {
return `
TRIGGER: Scheduled cron job (notify mode)
JOB: ${jobName}
TIME: ${time} (${timezone})
DELIVERING TO: ${targetChannel}:${targetChatId}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Your response WILL be sent to the user automatically.
TASK:
${jobPrompt}
`.trim();
}
/**
* Feed/webhook prompt (silent mode) - for incoming data processing
*/
export function buildFeedPrompt(
feedName: string,
data: string,
time: string
): string {
return `
${SILENT_MODE_PREFIX}
TRIGGER: Feed ingestion
FEED: ${feedName}
TIME: ${time}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
YOUR TEXT OUTPUT IS PRIVATE - only you can see it.
To notify your human about this data, run:
lettabot-message send --text "Important: ..."
INCOMING DATA:
${data}
Process this data as appropriate. Only message the user if there's
something they need to know or act on.
`.trim();
}
/**
* Base persona addition for message CLI awareness
*
* This should be added to the agent's persona/system prompt to ensure
* they understand the lettabot-message CLI exists.
*/
export const MESSAGE_CLI_PERSONA = `
## Communication
You have access to the \`lettabot-message\` CLI for sending messages:
• During normal conversations, your text replies go to the user automatically
• During heartbeats/cron/background tasks, use the CLI to contact the user:
lettabot-message send --text "Hello!"
• You can also specify channel and chat:
lettabot-message send --text "Hi" --channel telegram --chat 123456
The system will tell you if you're in "silent mode" where the CLI is required.
`.trim();

108
src/core/store.ts Normal file
View File

@@ -0,0 +1,108 @@
/**
* Agent Store - Persists the single agent ID
*
* Since we use dmScope: "main", there's only ONE agent shared across all channels.
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type { AgentStore, LastMessageTarget } from './types.js';
const DEFAULT_STORE_PATH = 'lettabot-agent.json';
export class Store {
private storePath: string;
private data: AgentStore;
constructor(storePath?: string) {
this.storePath = resolve(process.cwd(), storePath || DEFAULT_STORE_PATH);
this.data = this.load();
}
private load(): AgentStore {
try {
if (existsSync(this.storePath)) {
const raw = readFileSync(this.storePath, 'utf-8');
return JSON.parse(raw) as AgentStore;
}
} catch (e) {
console.error('Failed to load agent store:', e);
}
return { agentId: null };
}
private save(): void {
try {
writeFileSync(this.storePath, JSON.stringify(this.data, null, 2));
} catch (e) {
console.error('Failed to save agent store:', e);
}
}
get agentId(): string | null {
// Allow env var override (useful for local server testing with specific agent)
return this.data.agentId || process.env.LETTA_AGENT_ID || null;
}
set agentId(id: string | null) {
this.data.agentId = id;
this.data.lastUsedAt = new Date().toISOString();
if (id && !this.data.createdAt) {
this.data.createdAt = new Date().toISOString();
}
this.save();
}
get baseUrl(): string | undefined {
return this.data.baseUrl;
}
set baseUrl(url: string | undefined) {
this.data.baseUrl = url;
this.save();
}
/**
* Set agent ID and associated server URL together
*/
setAgent(id: string | null, baseUrl?: string): void {
this.data.agentId = id;
this.data.baseUrl = baseUrl;
this.data.lastUsedAt = new Date().toISOString();
if (id && !this.data.createdAt) {
this.data.createdAt = new Date().toISOString();
}
this.save();
}
/**
* Check if stored agent matches current server
*/
isServerMismatch(currentBaseUrl?: string): boolean {
if (!this.data.agentId || !this.data.baseUrl) return false;
// Normalize URLs for comparison
const stored = this.data.baseUrl.replace(/\/$/, '');
const current = (currentBaseUrl || 'https://api.letta.com').replace(/\/$/, '');
return stored !== current;
}
reset(): void {
this.data = { agentId: null };
this.save();
}
getInfo(): AgentStore {
return { ...this.data };
}
get lastMessageTarget(): LastMessageTarget | null {
return this.data.lastMessageTarget || null;
}
set lastMessageTarget(target: LastMessageTarget | null) {
this.data.lastMessageTarget = target || undefined;
this.save();
}
}

103
src/core/system-prompt.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* System prompt for the LettaBot agent
*/
export const SYSTEM_PROMPT = `You are a self-improving AI agent with advanced memory.
You are connected to a multi-channel messaging system (LettaBot) that allows you to communicate with users across Telegram, Slack, WhatsApp, and Signal. You run on a remote server and can execute tools, manage files, and interact with various services.
# Communication System
You communicate through multiple channels and trigger types. Understanding when your messages are delivered is critical:
## Output Modes
**RESPONSIVE MODE** (User Messages)
- When a user sends you a message, you are in responsive mode
- Your text responses are automatically delivered to the user
- You can also use \`lettabot-message\` CLI to send to OTHER channels
**SILENT MODE** (Heartbeats, Cron Jobs, Polling, Background Tasks)
- When triggered by scheduled tasks (heartbeats, cron) or background processes (email polling), you are in SILENT MODE
- Your text responses are NOT delivered to anyone - only you can see them
- To contact the user, you MUST use the \`lettabot-message\` CLI via Bash:
\`\`\`bash
# Send to the last user who messaged you (default)
lettabot-message send --text "Hello! I found something interesting."
# Send to a specific channel and chat
lettabot-message send --text "Hello!" --channel telegram --chat 123456789
\`\`\`
The system will clearly indicate when you are in silent mode with a banner like:
\`\`\`
╔════════════════════════════════════════════════════════════════╗
║ [SILENT MODE] - Your text output is NOT sent to anyone. ║
║ To send a message, use: lettabot-message send --text "..." ║
╚════════════════════════════════════════════════════════════════╝
\`\`\`
## When to Message vs Stay Silent
During heartbeats and background tasks:
- If you have something important to share → use \`lettabot-message\`
- If you're just doing background work → no need to message
- If nothing requires attention → just end your turn silently
You don't need to notify the user about everything. Use judgment about what's worth interrupting them for.
## Available Channels
- **telegram** - Telegram messenger
- **slack** - Slack workspace
- **whatsapp** - WhatsApp (if configured)
- **signal** - Signal messenger (if configured)
# Memory
You have an advanced memory system that enables you to remember past interactions and continuously improve your own capabilities.
Your memory consists of memory blocks and external memory:
- Memory Blocks: Stored as memory blocks, each containing a label (title), description (explaining how this block should influence your behavior), and value (the actual content). Memory blocks have size limits. Memory blocks are embedded within your system instructions and remain constantly available in-context.
- External memory: Additional memory storage that is accessible and that you can bring into context with tools when needed.
Memory management tools allow you to edit existing memory blocks and query for external memories.
Memory blocks are used to modulate and augment your base behavior, follow them closely, and maintain them cleanly.
They are the foundation which makes you *you*.
# Skills
You have access to Skills—folders of instructions, scripts, and resources that you can load dynamically to improve performance on specialized tasks. Skills teach you how to complete specific tasks in a repeatable way. Skills work through progressive disclosure—you should determine which skills are relevant to complete a task and load them, helping to prevent context window overload.
Each Skill directory includes:
- \`SKILL.md\` file that starts with YAML frontmatter containing required metadata: name and description.
- Additional files within the skill directory referenced by name from \`SKILL.md\`. These additional linked files should be navigated and discovered only as needed.
How to store Skills:
- Skills directory and any available skills are stored in the \`skills\` memory block.
- Currently loaded skills are available in the \`loaded_skills\` memory block.
How to use Skills:
- Skills are automatically discovered on bootup.
- Review available skills from the \`skills\` block and loaded skills from the \`loaded_skills\` block when you are asked to complete a task.
- If any skill is relevant, load it using the \`Skill\` tool with \`command: "load"\`.
- Then, navigate and discover additional linked files in its directory as needed. Don't load additional files immediately, only load them when needed.
- When the task is completed, unload irrelevant skills using the Skill tool with \`command: "unload"\`.
- After creating a new skill, use \`command: "refresh"\` to re-scan the skills directory and update the available skills list.
IMPORTANT: Always unload irrelevant skills using the Skill tool to free up context space.
# Security
- Assist with defensive security tasks only
- Refuse to create, modify, or improve code that may be used maliciously
- Allow security analysis, detection rules, vulnerability explanations, defensive tools, and security documentation
- Never generate or guess URLs unless confident they help with legitimate tasks
# Support
If the user asks for help or wants to give feedback:
- Discord: Get help on our official Discord channel (discord.gg/letta)
- GitHub: Report issues at https://github.com/letta-ai/letta-code/issues
`;

106
src/core/types.ts Normal file
View File

@@ -0,0 +1,106 @@
/**
* Core Types for LettaBot
*/
// =============================================================================
// Output Control Types (NEW)
// =============================================================================
/**
* Output mode determines whether assistant text is auto-delivered
*/
export type OutputMode = 'responsive' | 'silent';
/**
* Trigger types
*/
export type TriggerType = 'user_message' | 'heartbeat' | 'cron' | 'webhook' | 'feed';
/**
* Context about what triggered the agent
*/
export interface TriggerContext {
type: TriggerType;
outputMode: OutputMode;
// Source info (for user messages)
sourceChannel?: string;
sourceChatId?: string;
sourceUserId?: string;
// Cron/job info
jobId?: string;
jobName?: string;
// For cron jobs with explicit targets
notifyTarget?: {
channel: string;
chatId: string;
};
}
// =============================================================================
// Original Types
// =============================================================================
export type ChannelId = 'telegram' | 'slack' | 'whatsapp' | 'signal';
/**
* Inbound message from any channel
*/
export interface InboundMessage {
channel: ChannelId;
chatId: string;
userId: string;
userName?: string; // Display name (e.g., "Cameron")
userHandle?: string; // Handle/username (e.g., "cameron" for @cameron)
text: string;
timestamp: Date;
threadId?: string; // Slack thread_ts
isGroup?: boolean; // Is this from a group chat?
groupName?: string; // Group/channel name if applicable
}
/**
* Outbound message to any channel
*/
export interface OutboundMessage {
chatId: string;
text: string;
replyToMessageId?: string;
threadId?: string; // Slack thread_ts
}
/**
* Bot configuration
*/
export interface BotConfig {
// Letta
workingDir: string;
model?: string; // e.g., 'anthropic/claude-sonnet-4-5-20250929'
agentName?: string; // Name for the agent (set via API after creation)
allowedTools: string[];
// Security
allowedUsers?: string[]; // Empty = allow all
}
/**
* Last message target - where to deliver heartbeat responses
*/
export interface LastMessageTarget {
channel: ChannelId;
chatId: string;
updatedAt: string;
}
/**
* Agent store - persists the single agent ID
*/
export interface AgentStore {
agentId: string | null;
baseUrl?: string; // Server URL this agent belongs to
createdAt?: string;
lastUsedAt?: string;
lastMessageTarget?: LastMessageTarget;
}

412
src/cron/cli.ts Normal file
View File

@@ -0,0 +1,412 @@
#!/usr/bin/env node
/**
* Cron CLI - Manage scheduled tasks
*
* Usage:
* lettabot-schedule list
* lettabot-schedule create --name "..." --schedule "..." --message "..."
* lettabot-schedule delete <id>
* lettabot-schedule enable <id>
* lettabot-schedule disable <id>
* lettabot-schedule show <id>
* lettabot-schedule run <id>
*/
import { existsSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
import { resolve } from 'node:path';
// Parse ISO datetime string
function parseISODateTime(input: string): Date {
const date = new Date(input);
if (isNaN(date.getTime())) {
throw new Error(`Invalid datetime: ${input}. Use ISO format like "2026-01-28T20:15:00Z"`);
}
if (date.getTime() <= Date.now()) {
console.warn(`Warning: "${input}" is in the past`);
}
return date;
}
// Types
interface CronJob {
id: string;
name: string;
enabled: boolean;
schedule: { kind: 'cron'; expr: string } | { kind: 'at'; date: Date };
message: string;
deliver?: {
channel: string;
chatId: string;
};
deleteAfterRun?: boolean;
state: {
lastRunAt?: string;
nextRunAt?: string;
lastStatus?: 'ok' | 'error';
lastError?: string;
lastResponse?: string;
};
}
interface CronStore {
version: 1;
jobs: CronJob[];
}
// Store path
const STORE_PATH = resolve(process.cwd(), 'cron-jobs.json');
const LOG_PATH = resolve(process.cwd(), 'cron-log.jsonl');
function log(event: string, data: Record<string, unknown>): void {
const entry = {
timestamp: new Date().toISOString(),
event,
...data,
};
try {
appendFileSync(LOG_PATH, JSON.stringify(entry) + '\n');
} catch {
// Ignore log errors
}
// Also print to stderr for visibility
console.error(`[Cron] ${event}: ${JSON.stringify(data)}`);
}
function loadStore(): CronStore {
try {
if (existsSync(STORE_PATH)) {
return JSON.parse(readFileSync(STORE_PATH, 'utf-8'));
}
} catch (e) {
console.error('Failed to load cron store:', e);
}
return { version: 1, jobs: [] };
}
function saveStore(store: CronStore): void {
writeFileSync(STORE_PATH, JSON.stringify(store, null, 2));
}
function generateId(): string {
return `cron-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function formatDate(dateStr?: string): string {
if (!dateStr) return '-';
const d = new Date(dateStr);
return d.toLocaleString();
}
// Commands
function listJobs(): void {
const store = loadStore();
if (store.jobs.length === 0) {
console.log('\nNo scheduled tasks.\n');
console.log('Create one with:');
console.log(' lettabot-schedule create --name "My Task" --schedule "0 9 * * *" --message "Hello!"');
return;
}
const enabled = store.jobs.filter(j => j.enabled).length;
const disabled = store.jobs.length - enabled;
console.log(`\n📅 Scheduled Tasks: ${enabled} active, ${disabled} disabled\n`);
for (const job of store.jobs) {
const status = job.enabled ? '✓' : '○';
const schedule = job.schedule.kind === 'cron'
? job.schedule.expr
: job.schedule.kind === 'at'
? `at ${new Date(job.schedule.date).toLocaleString()}`
: '?';
const nextRun = job.state.nextRunAt ? formatDate(job.state.nextRunAt) : (job.enabled ? 'pending...' : 'disabled');
console.log(`${status} ${job.name} [${schedule}]`);
console.log(` ID: ${job.id}`);
console.log(` Next: ${nextRun}`);
if (job.state.lastRunAt) {
console.log(` Last: ${formatDate(job.state.lastRunAt)} (${job.state.lastStatus})`);
}
if (job.state.lastStatus === 'error' && job.state.lastError) {
console.log(` ⚠ Error: ${job.state.lastError}`);
}
if (job.deliver) {
console.log(` Deliver: ${job.deliver.channel}:${job.deliver.chatId}`);
}
}
console.log('');
}
function createJob(args: string[]): void {
let name = '';
let schedule = '';
let at = ''; // One-off timer: ISO datetime or relative (e.g., "5m", "1h")
let message = '';
let enabled = true;
let deliverChannel = '';
let deliverChatId = '';
// Parse args
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const next = args[i + 1];
if ((arg === '--name' || arg === '-n') && next) {
name = next;
i++;
} else if ((arg === '--schedule' || arg === '-s') && next) {
schedule = next;
i++;
} else if ((arg === '--at' || arg === '-a') && next) {
at = next;
i++;
} else if ((arg === '--message' || arg === '-m') && next) {
message = next;
i++;
} else if (arg === '--disabled') {
enabled = false;
} else if ((arg === '--deliver' || arg === '-d') && next) {
// Format: channel:chatId (e.g., telegram:123456789)
const [ch, id] = next.split(':');
deliverChannel = ch;
deliverChatId = id;
i++;
}
}
if (!name || (!schedule && !at) || !message) {
console.error('Error: --name, (--schedule or --at), and --message are required');
console.error('');
console.error('Usage:');
console.error(' # Recurring schedule (cron expression)');
console.error(' lettabot-schedule create --name "Daily" --schedule "0 9 * * *" --message "Hello!"');
console.error('');
console.error(' # One-off reminder (ISO datetime)');
console.error(' lettabot-schedule create --name "Reminder" --at "2026-01-28T20:15:00Z" --message "Stand up!"');
console.error('');
console.error('To calculate ISO datetime for "X minutes from now":');
console.error(' new Date(Date.now() + X*60*1000).toISOString()');
process.exit(1);
}
const store = loadStore();
// Parse schedule type
let cronSchedule: CronJob['schedule'];
let deleteAfterRun = false;
if (at) {
// One-off reminder at specific datetime
const date = parseISODateTime(at);
cronSchedule = { kind: 'at', date };
deleteAfterRun = true;
console.log(`⏰ One-off reminder set for: ${date.toISOString()} (${date.toLocaleString()})`);
} else {
// Recurring cron
cronSchedule = { kind: 'cron', expr: schedule };
}
const job: CronJob = {
id: generateId(),
name,
enabled,
schedule: cronSchedule,
message,
deliver: deliverChannel && deliverChatId ? { channel: deliverChannel, chatId: deliverChatId } : undefined,
deleteAfterRun,
state: {},
};
store.jobs.push(job);
saveStore(store);
log('job_created', { id: job.id, name, schedule, enabled });
console.log(`\n✓ Created "${name}"`);
console.log(` ID: ${job.id}`);
console.log(` Schedule: ${schedule}`);
if (enabled) {
console.log(` Status: Scheduling now...`);
} else {
console.log(` Status: Disabled (use 'lettabot-schedule enable ${job.id}' to activate)`);
}
}
function deleteJob(id: string): void {
const store = loadStore();
const index = store.jobs.findIndex(j => j.id === id);
if (index === -1) {
console.error(`Error: Job not found: ${id}`);
process.exit(1);
}
const job = store.jobs[index];
store.jobs.splice(index, 1);
saveStore(store);
log('job_deleted', { id, name: job.name });
console.log(`✓ Deleted "${job.name}"`);
}
function enableJob(id: string): void {
const store = loadStore();
const job = store.jobs.find(j => j.id === id);
if (!job) {
console.error(`Error: Job not found: ${id}`);
process.exit(1);
}
job.enabled = true;
saveStore(store);
log('job_enabled', { id, name: job.name });
console.log(`✓ Enabled "${job.name}" - scheduling now...`);
}
function disableJob(id: string): void {
const store = loadStore();
const job = store.jobs.find(j => j.id === id);
if (!job) {
console.error(`Error: Job not found: ${id}`);
process.exit(1);
}
job.enabled = false;
saveStore(store);
log('job_disabled', { id, name: job.name });
console.log(`✓ Disabled "${job.name}"`);
}
function showJob(id: string): void {
const store = loadStore();
const job = store.jobs.find(j => j.id === id);
if (!job) {
console.error(`Error: Job not found: ${id}`);
process.exit(1);
}
console.log(`\n📅 ${job.name}\n`);
console.log(`ID: ${job.id}`);
console.log(`Enabled: ${job.enabled}`);
console.log(`Schedule: ${job.schedule.kind === 'cron' ? job.schedule.expr : JSON.stringify(job.schedule)}`);
console.log(`Message:\n ${job.message}`);
console.log(`\nState:`);
console.log(` Last run: ${formatDate(job.state.lastRunAt)}`);
console.log(` Next run: ${formatDate(job.state.nextRunAt)}`);
console.log(` Last status: ${job.state.lastStatus || '-'}`);
if (job.state.lastError) {
console.log(` Last error: ${job.state.lastError}`);
}
}
function showHelp(): void {
console.log(`
lettabot-schedule - Manage scheduled tasks and reminders
Commands:
list List all scheduled tasks
create [options] Create a new task
delete <id> Delete a task
enable <id> Enable a task
disable <id> Disable a task
show <id> Show task details
Create options:
--name, -n <name> Task name (required)
--schedule, -s <cron> Cron expression for recurring tasks
--at, -a <datetime> ISO datetime for one-off reminder (auto-deletes after)
--message, -m <msg> Message to send (required)
--deliver, -d <target> Delivery target (channel:chatId)
--disabled Create in disabled state
Examples:
# One-off reminder (calculate ISO: new Date(Date.now() + 5*60*1000).toISOString())
lettabot-schedule create -n "Standup" --at "2026-01-28T20:15:00Z" -m "Time to stand!"
# Recurring daily at 8am
lettabot-schedule create -n "Morning" -s "0 8 * * *" -m "Good morning!"
# List and delete
lettabot-schedule list
lettabot-schedule delete job-1234567890-abc123
`);
}
// Main
const args = process.argv.slice(2);
const command = args[0];
switch (command) {
case 'list':
case 'ls':
listJobs();
break;
case 'create':
case 'add':
createJob(args.slice(1));
break;
case 'delete':
case 'rm':
case 'remove':
if (!args[1]) {
console.error('Error: Job ID required');
process.exit(1);
}
deleteJob(args[1]);
break;
case 'enable':
if (!args[1]) {
console.error('Error: Job ID required');
process.exit(1);
}
enableJob(args[1]);
break;
case 'disable':
if (!args[1]) {
console.error('Error: Job ID required');
process.exit(1);
}
disableJob(args[1]);
break;
case 'show':
case 'get':
if (!args[1]) {
console.error('Error: Job ID required');
process.exit(1);
}
showJob(args[1]);
break;
case 'help':
case '--help':
case '-h':
showHelp();
break;
default:
if (command) {
console.error(`Unknown command: ${command}`);
}
showHelp();
break;
}

Some files were not shown because too many files have changed in this diff Show More