
Lettabot
- 2 installs
- 327 repo stars
- Updated May 25, 2026
- letta-ai/lettabot
Sets up and runs LettaBot, a multi-channel AI assistant with persistent memory for Telegram, Slack, Discord, WhatsApp, and Signal, via wizard or non-interactive config.
About
Guides installing, configuring, and running LettaBot across five messaging channels with safe defaults. A developer uses it to stand up a persistent-memory AI assistant either through an interactive wizard or agent-friendly non-interactive setup.
- Agent-friendly non-interactive onboard via environment variables
- Safe defaults including pairing DM policy and self-chat modes
Lettabot by the numbers
- 2 all-time installs (skills.sh)
- Ranked #13,957 of 16,556 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/letta-ai/lettabot --skill lettabotAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 327 |
| Last updated | May 25, 2026 |
| Repository | letta-ai/lettabot ↗ |
What it does
Sets up and runs LettaBot, a multi-channel AI assistant with persistent memory for Telegram, Slack, Discord, WhatsApp, and Signal, via wizard or non-interactive config.
Files
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(realopexamples)
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):
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 injectover 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 signininside tmux and authorize in the app. - Do not run
opoutside tmux; stop and ask if tmux is unavailable.
node_modules
dist
.git
.env
.env.*
lettabot.yaml
lettabot.yml
lettabot-agent.json
data/
*.log
.skills/
# ============================================
# 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
# Persistent data directory override (agent store, cron jobs, logs)
# DATA_DIR=/absolute/path/to/lettabot-data
# 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
# Disallowed tools (comma-separated)
# Default blocks plan-mode interactive tools that can stall headless agents
# DISALLOWED_TOOLS=EnterPlanMode,ExitPlanMode
# ============================================
# 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
# ============================================
# Memory Filesystem (optional)
# ============================================
# Enable memory filesystem (git-backed context repository)
# Syncs agent memory blocks to local files
# LETTABOT_MEMFS=true
# ============================================
# Heartbeat (optional)
# ============================================
# Explicit heartbeat toggle
# HEARTBEAT_ENABLED=true
#
# 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
# ============================================
# Voice Memos / TTS (optional)
# ============================================
# TTS provider: "elevenlabs" (default) or "openai"
# TTS_PROVIDER=elevenlabs
# ElevenLabs (default provider)
# ELEVENLABS_API_KEY=sk_your_elevenlabs_key
# ELEVENLABS_VOICE_ID=21m00Tcm4TlvDq8ikWAM
# ELEVENLABS_MODEL_ID=eleven_multilingual_v2
# OpenAI TTS (uses OPENAI_API_KEY from above)
# OPENAI_TTS_VOICE=alloy
# OPENAI_TTS_MODEL=tts-1
# ============================================
# API Server (for Docker/CLI integration)
# ============================================
# API key for CLI authentication (auto-generated if not set)
# Check bot server logs on first run to see the generated key
# LETTABOT_API_KEY=your-secret-key-here
# API server URL (for CLI when bot runs in Docker)
# LETTABOT_API_URL=http://localhost:8080
# API server port (default: 8080)
# PORT=8080
# API server bind address (default: 127.0.0.1 for security)
# Use 0.0.0.0 in Docker to expose on all interfaces
# API_HOST=127.0.0.1
# CORS allowed origin (default: same-origin only)
# Use '*' to allow all origins (not recommended for production)
# API_CORS_ORIGIN=*
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):"
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"
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
issues: write
pull-requests: read
jobs:
release:
name: Create Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for changelog generation
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint runtime console usage
run: npm run lint:console
- name: Build
run: npm run build
- name: Run tests
run: npm run test:run
- name: Generate release notes
id: notes
run: |
# Get the previous tag (if any)
PREV_TAG=$(git tag --sort=-creatordate | sed -n '2p')
CURRENT_TAG=${GITHUB_REF#refs/tags/}
if [ -z "$PREV_TAG" ]; then
echo "First release - including all commits"
RANGE="HEAD"
else
RANGE="${PREV_TAG}..${CURRENT_TAG}"
fi
# Collect merged PRs from commit messages
echo "## What's Changed" > notes.md
echo "" >> notes.md
# Extract PR numbers and titles from merge commits
git log $RANGE --oneline --grep="(#" | while read -r line; do
# Extract PR number
PR_NUM=$(echo "$line" | grep -oP '\(#\K[0-9]+' | head -1)
# Clean up the message (remove hash prefix)
MSG=$(echo "$line" | sed 's/^[a-f0-9]* //')
if [ -n "$PR_NUM" ]; then
echo "- ${MSG} by @$(gh pr view $PR_NUM --json author --jq '.author.login' 2>/dev/null || echo 'contributor')" >> notes.md
else
echo "- ${MSG}" >> notes.md
fi
done
# Add install instructions
echo "" >> notes.md
echo "## Install" >> notes.md
echo "" >> notes.md
echo '```bash' >> notes.md
echo "npx lettabot onboard" >> notes.md
echo "# or" >> notes.md
echo "npm install -g lettabot" >> notes.md
echo '```' >> notes.md
# Add full changelog link
if [ -n "$PREV_TAG" ]; then
echo "" >> notes.md
echo "**Full Changelog**: https://github.com/letta-ai/lettabot/compare/${PREV_TAG}...${CURRENT_TAG}" >> notes.md
fi
cat notes.md
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create GitHub Release
run: |
CURRENT_TAG=${GITHUB_REF#refs/tags/}
# Determine if pre-release
if echo "$CURRENT_TAG" | grep -qE '(alpha|beta|rc)'; then
PRERELEASE="--prerelease"
else
PRERELEASE=""
fi
gh release create "$CURRENT_TAG" \
--title "$CURRENT_TAG" \
--notes-file notes.md \
$PRERELEASE
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Publish to npm
if: env.NPM_TOKEN != ''
run: |
CURRENT_TAG=${GITHUB_REF#refs/tags/}
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
# Set version from git tag (strip 'v' prefix)
VERSION=${CURRENT_TAG#v}
npm version "$VERSION" --no-git-tag-version --allow-same-version
# Determine npm tag (pre-releases get 'next', stable gets 'latest')
if echo "$CURRENT_TAG" | grep -qE '(alpha|beta|rc)'; then
npm publish --tag next --access public
else
npm publish --access public
fi
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
unit:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint runtime console usage
run: npm run lint:console
- name: Build
run: npm run build
- name: Run unit tests
run: npm run test:run
e2e:
name: E2E Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint runtime console usage
run: npm run lint:console
- name: Build
run: npm run build
- name: Run e2e tests
# Tests requiring secrets (bot.e2e, models.e2e) skip gracefully via describe.skipIf.
# OpenAI SDK compat tests always run (no secrets needed, uses mock gateway).
run: npm run test:e2e
env:
LETTA_API_KEY: ${{ secrets.LETTA_API_KEY }}
LETTA_E2E_AGENT_ID: ${{ secrets.LETTA_E2E_AGENT_ID }}
docker:
name: Docker Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker image
# Ensures Dockerfile build path stays healthy on main and PRs.
run: docker build --tag lettabot:test .
# 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/
.claude/
# Runtime files
cron-log.jsonl
cron-jobs.json
lettabot-agent.json
lettabot-agent.json.bak
lettabot-api.json
PERSONA.md
CLAUDE.md
# Related repos
moltbot/
letta-code-sdk/
# WhatsApp session (contains credentials)
data/whatsapp-session/
# Telegram MTProto session (contains authenticated session data)
data/telegram-mtproto/
# Config with secrets
lettabot.yaml
lettabot.yml
# Platform-specific deploy configs (generated by fly launch, etc.)
fly.toml
bun.lock
.tool-versions
# Telegram MTProto session data (contains auth secrets)
data/telegram-mtproto/
logs/
22
op CLI examples (from op help)
Sign in
op signinop signin --account <shorthand|signin-address|account-id|user-id>
Read
op read op://app-prod/db/passwordop 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_PASSWORDop run --env-file="./.env" -- printenv DB_PASSWORD
Inject
echo "db_password: {{ op://app-prod/db/password }}" | op injectop inject -i config.yml.tpl -o config.yml
Whoami / accounts
op whoamiop account list
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 signinto pick one, or--account/OP_ACCOUNT. - For non-integration auth, use
op account add.
Himalaya Configuration Reference
Configuration file location: ~/.config/himalaya/config.toml
Minimal IMAP + SMTP Setup
[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)
backend.auth.raw = "your-password"Password from command (recommended)
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)
backend.auth.keyring = "imap-example"Then run himalaya account configure <account> to store the password.
Gmail Configuration
[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
[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:
[accounts.default.folder.alias]
inbox = "INBOX"
sent = "Sent"
drafts = "Drafts"
trash = "Trash"Multiple Accounts
[accounts.personal]
email = "personal@example.com"
default = true
# ... backend config ...
[accounts.work]
email = "work@company.com"
# ... backend config ...Switch accounts with --account:
himalaya --account work envelope listNotmuch Backend (local mail)
[accounts.local]
email = "user@example.com"
backend.type = "notmuch"
backend.db-path = "~/.mail/.notmuch"OAuth2 Authentication (for providers that support it)
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
[accounts.default]
signature = "Best regards,\nYour Name"
signature-delim = "-- \n"Downloads directory
[accounts.default]
downloads-dir = "~/Downloads/himalaya"Editor for composing
Set via environment variable:
export EDITOR="vim"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 addressTo: Primary recipient(s)Cc: Carbon copy recipientsBcc: Blind carbon copy recipientsSubject: Message subjectReply-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,
AliceMML 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 contenttype=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 attachname=<name>: Display name for attachmentdisposition=inline: Display inline instead of as attachmentid=<cid>: Content ID for referencing in HTML
Composing from CLI
Interactive compose
Opens your $EDITOR:
himalaya message writeReply (opens editor with quoted message)
himalaya message reply 42
himalaya message reply 42 --all # reply-allForward
himalaya message forward 42Send from stdin
cat message.txt | himalaya template sendPrefill headers from CLI
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 --fullto inspect the raw MIME structure of received emails.
#!/usr/bin/env npx tsx
/**
* Linear CLI for triage workflows
* Usage: npx tsx linear.ts <command> [options]
*/
const LINEAR_API = "https://api.linear.app/graphql";
const API_KEY = process.env.LINEAR_API_KEY;
// Type definitions
interface GraphQLError {
message: string;
}
interface WorkflowState {
id: string;
name: string;
}
interface IssueUpdateInput {
priority?: number;
stateId?: string;
}
if (!API_KEY) {
console.error("Error: LINEAR_API_KEY environment variable not set");
process.exit(1);
}
async function graphql(query: string, variables: Record<string, unknown> = {}) {
const res = await fetch(LINEAR_API, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": API_KEY!,
},
body: JSON.stringify({ query, variables }),
});
const data = await res.json();
if (data.errors) {
throw new Error(data.errors.map((e: GraphQLError) => e.message).join(", "));
}
return data.data;
}
async function listIssues(filters: { state?: string; assignee?: string; limit?: number }) {
const limit = filters.limit || 50;
let filterStr = "";
const filterParts: string[] = [];
if (filters.state) {
filterParts.push(`state: { name: { eq: "${filters.state}" } }`);
}
if (filters.assignee) {
filterParts.push(`assignee: { name: { containsIgnoreCase: "${filters.assignee}" } }`);
}
if (filterParts.length > 0) {
filterStr = `filter: { ${filterParts.join(", ")} }`;
}
const query = `
query {
issues(first: ${limit}, ${filterStr}) {
nodes {
id
identifier
title
priority
state { name }
assignee { name }
createdAt
url
}
}
}
`;
const data = await graphql(query);
return data.issues.nodes;
}
async function getIssue(idOrIdentifier: string) {
// Try by identifier first (e.g., "ENG-123")
const query = `
query($id: String!) {
issue(id: $id) {
id
identifier
title
description
priority
state { name }
assignee { name }
labels { nodes { name } }
createdAt
updatedAt
url
comments {
nodes {
body
user { name }
createdAt
}
}
}
}
`;
try {
const data = await graphql(query, { id: idOrIdentifier });
return data.issue;
} catch {
// Try searching by identifier
const searchQuery = `
query($filter: IssueFilter) {
issues(filter: $filter, first: 1) {
nodes {
id
identifier
title
description
priority
state { name }
assignee { name }
labels { nodes { name } }
createdAt
updatedAt
url
}
}
}
`;
const data = await graphql(searchQuery, {
filter: { number: { eq: parseInt(idOrIdentifier.replace(/\D/g, "")) } }
});
return data.issues.nodes[0];
}
}
async function updateIssue(id: string, updates: { priority?: number; state?: string }) {
// First get the issue to find its ID
const issue = await getIssue(id);
if (!issue) {
throw new Error(`Issue not found: ${id}`);
}
const input: IssueUpdateInput = {};
if (updates.priority !== undefined) {
input.priority = updates.priority;
}
if (updates.state) {
// Need to find the state ID
const statesQuery = `
query {
workflowStates {
nodes {
id
name
}
}
}
`;
const statesData = await graphql(statesQuery);
const state = statesData.workflowStates.nodes.find(
(s: WorkflowState) => s.name.toLowerCase() === updates.state!.toLowerCase()
);
if (!state) {
throw new Error(`State not found: ${updates.state}`);
}
input.stateId = state.id;
}
const mutation = `
mutation($id: String!, $input: IssueUpdateInput!) {
issueUpdate(id: $id, input: $input) {
issue {
id
identifier
title
priority
state { name }
}
}
}
`;
const data = await graphql(mutation, { id: issue.id, input });
return data.issueUpdate.issue;
}
async function addComment(id: string, body: string) {
const issue = await getIssue(id);
if (!issue) {
throw new Error(`Issue not found: ${id}`);
}
const mutation = `
mutation($issueId: String!, $body: String!) {
commentCreate(input: { issueId: $issueId, body: $body }) {
comment {
id
body
createdAt
}
}
}
`;
const data = await graphql(mutation, { issueId: issue.id, body });
return data.commentCreate.comment;
}
async function searchIssues(query: string) {
const searchQuery = `
query($query: String!) {
searchIssues(query: $query, first: 20) {
nodes {
id
identifier
title
priority
state { name }
assignee { name }
url
}
}
}
`;
const data = await graphql(searchQuery, { query });
return data.searchIssues.nodes;
}
// CLI parsing
const args = process.argv.slice(2);
const command = args[0];
async function main() {
try {
switch (command) {
case "list": {
const filters: { state?: string; assignee?: string; limit?: number } = {};
for (let i = 1; i < args.length; i++) {
if (args[i] === "--state" && args[i + 1]) {
filters.state = args[++i];
} else if (args[i] === "--assignee" && args[i + 1]) {
filters.assignee = args[++i];
} else if (args[i] === "--limit" && args[i + 1]) {
filters.limit = parseInt(args[++i]);
}
}
const issues = await listIssues(filters);
console.log(JSON.stringify(issues, null, 2));
break;
}
case "get": {
const id = args[1];
if (!id) {
console.error("Usage: get <issue-id>");
process.exit(1);
}
const issue = await getIssue(id);
console.log(JSON.stringify(issue, null, 2));
break;
}
case "update": {
const id = args[1];
if (!id) {
console.error("Usage: update <issue-id> [--priority N] [--state STATE]");
process.exit(1);
}
const updates: { priority?: number; state?: string } = {};
for (let i = 2; i < args.length; i++) {
if (args[i] === "--priority" && args[i + 1]) {
updates.priority = parseInt(args[++i]);
} else if (args[i] === "--state" && args[i + 1]) {
updates.state = args[++i];
}
}
const issue = await updateIssue(id, updates);
console.log(JSON.stringify(issue, null, 2));
break;
}
case "comment": {
const id = args[1];
const body = args[2];
if (!id || !body) {
console.error("Usage: comment <issue-id> <body>");
process.exit(1);
}
const comment = await addComment(id, body);
console.log(JSON.stringify(comment, null, 2));
break;
}
case "search": {
const query = args.slice(1).join(" ");
if (!query) {
console.error("Usage: search <query>");
process.exit(1);
}
const issues = await searchIssues(query);
console.log(JSON.stringify(issues, null, 2));
break;
}
default:
console.log(`
Linear Triage CLI
Commands:
list [--state STATE] [--assignee NAME] [--limit N]
get <issue-id>
update <issue-id> [--priority N] [--state STATE]
comment <issue-id> <body>
search <query>
Priority levels: 0=none, 1=urgent, 2=high, 3=medium, 4=low
`);
}
} catch (error) {
console.error("Error:", error instanceof Error ? error.message : error);
process.exit(1);
}
}
main();
[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"]
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
# 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 --reloadOpen the API docs at http://127.0.0.1:8000/docs.
Places API
Set the Google Places API key before running:
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:
{
"query": "italian restaurant",
"filters": {
"types": ["restaurant"],
"open_now": true,
"min_rating": 4.0,
"price_levels": [1, 2]
},
"limit": 10
}Notes:
filters.typessupports a single type (mapped to GoogleincludedType).
Example search request (curl):
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):
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
uv run pytestOpenAPI
Generate the OpenAPI schema:
uv run python scripts/generate_openapi.py__all__ = ["__version__"]
__version__ = "0.1.0"
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)
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)
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
#!/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()
#!/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())
#!/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"
#!/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);
#!/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}"
#!/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
#!/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"
{
"version": 1,
"updatedAt": "2026-03-10T19:26:51.496Z",
"agents": {
"LettaBot": {
"cursor": 1773168967870655,
"wantedDids": [
"did:plc:gfrmhdmjvxn2sjedzboeudef"
],
"wantedCollections": [
"app.bsky.feed.post"
],
"auth": {
"did": "did:plc:gfrmhdmjvxn2sjedzboeudef",
"handle": "cameron.stream"
}
}
}
}FROM node:22-slim AS build
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim
RUN apt-get update && apt-get install -y git jq curl python3 make g++ && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
COPY --from=build /app/skills ./skills
ENV NODE_ENV=production
EXPOSE 8080
CMD ["node", "dist/main.js"]
Bluesky Jetstream Setup
LettaBot can ingest Bluesky events using the Jetstream WebSocket feed. This channel is read-only by default, with optional reply posting if you provide a Bluesky app password.
Overview
- Jetstream provides a firehose of ATProto commit events.
wantedDids/listscontrol which authors are ingested.groupsmode controls per-author behavior (open,listen,mention-only,disabled).- Posting credentials (
handle+appPassword) control whether replies can actually be posted. - Events are listening-only by default (
listen).
Configuration (lettabot.yaml)
channels:
bluesky:
enabled: true
# groups controls auto-reply policy; default fallback is listen (read-only)
wantedDids: ["did:plc:..."]
# lists:
# "at://did:plc:.../app.bsky.graph.list/xyz": { mode: listen }
# wantedCollections: ["app.bsky.feed.post"]
# notifications:
# enabled: true
# intervalSec: 60
# reasons: ["mention", "reply", "quote"]
# handle: you.bsky.social
# appPassword: xxxx-xxxx-xxxx-xxxx
# serviceUrl: https://bsky.social
# appViewUrl: https://public.api.bsky.appConversation routing
If you want Bluesky to keep its own conversation history while other channels stay shared, add a per-channel override:
conversations:
mode: shared
perChannel: ["bluesky"]Filters (how Jetstream is narrowed)
wantedDids: list of DID(s) to include. Multiple entries are ORed.wantedCollections: list of collections to include. Multiple entries are ORed.- Both filters are ANDed together.
- Example: wantedDids=[A] + wantedCollections=[app.bsky.feed.post] => only posts by DID A.
If you omit wantedCollections, you'll see all collections for the included DIDs (posts, likes, reposts, follows, blocks, etc.).
If there are no wantedDids (after list expansion), Jetstream does not connect. Notifications polling can still run if auth is configured.
Manual posting (skill/CLI)
Bluesky is read-only by default. To post, reply, like, or repost, use the CLI:
lettabot-bluesky post --text "Hello" --agent <name>
lettabot-bluesky post --reply-to at://did:plc:.../app.bsky.feed.post/... --text "Reply" --agent <name>
lettabot-bluesky like at://did:plc:.../app.bsky.feed.post/... --agent <name>
lettabot-bluesky repost at://did:plc:.../app.bsky.feed.post/... --agent <name>Posts over 300 characters require --threaded to explicitly split into a reply thread.
If there are no wantedDids (after list expansion), Jetstream does not connect. Notifications polling can still run if auth is configured.
Mentions
Jetstream does not provide mention notifications. Mentions are surfaced via the Notifications API (see below). mention-only mode only triggers replies for mention notifications.
Notifications (mentions, replies, likes, etc.)
Jetstream does not include notifications. To get mentions/replies like the Bluesky app, enable polling via the Notifications API:
channels:
bluesky:
notifications:
enabled: true
intervalSec: 60
reasons: ["mention", "reply", "quote"]If you supply posting credentials (handle + appPassword) and do not explicitly disable notifications, polling is enabled with defaults (60s, reasons: mention/reply/quote). Notifications polling works even if wantedDids is empty.
If you omit notifications.reasons, it defaults to mention, reply, and quote (not all reason types).
Notification reasons include (non-exhaustive): like, repost, follow, mention, reply, quote, starterpack-joined, verified, unverified, like-via-repost, repost-via-repost, subscribed-post.
Only mention, reply, and quote are considered "actionable" for reply behavior (based on your groups mode). Other reasons are always listening-only.
Author filtering note:
- Notifications are fetched from your account's notifications feed, then filtered by DID mode.
groupsstill applies here; any DID with modedisabledis dropped before delivery to the agent.- There is no separate
notifications.allowedUserssetting.
Filter notifications to specific users (DIDs):
channels:
bluesky:
notifications:
enabled: true
reasons: ["mention", "reply", "quote"]
groups:
"*": { mode: disabled }
"did:plc:alice": { mode: open }
"did:plc:bob": { mode: listen }Runtime Kill Switch (per agent)
Disable or re-enable Bluesky without restarting the server:
lettabot bluesky disable --agent MyAgent
lettabot bluesky enable --agent MyAgentRefresh list expansions on the running server:
lettabot bluesky refresh-lists --agent MyAgentKill switch state is stored in bluesky-runtime.json (per agent) under the data directory and polled by the running server.
When you use bluesky add-did, bluesky add-list, or bluesky set-default, the CLI also triggers a runtime config reload so the running server updates Jetstream subscriptions without restart.
Per-DID Modes (using groups syntax)
Bluesky uses the same groups pattern as other channels, where "*" is the default:
channels:
bluesky:
enabled: true
wantedDids: ["did:plc:author1"]
groups:
"*": { mode: listen }
"did:plc:author1": { mode: open }
"did:plc:author2": { mode: listen }
"did:plc:spammy": { mode: disabled }Mode mapping:
open-> reply to posts for that DIDlisten-> listening-onlymention-only-> reply only for mention notificationsdisabled-> ignore events from that DID
Default behavior:
- If
"*"is set, it is used as the default for any DID without an explicit override. - If
"*"is not set, default islisten.
Scope:
- DID mode is applied to both Jetstream events and notifications events.
"*"is optional; it only defines the fallback mode for unmatched DIDs.
Lists
You can target a Bluesky list by URI and assign a mode. On startup, the list is expanded to member DIDs and added to the stream filter.
channels:
bluesky:
lists:
"at://did:plc:.../app.bsky.graph.list/xyz": { mode: listen }If a DID appears in both groups and a list, the explicit groups mode wins.
List expansion uses the AppView API (default: https://public.api.bsky.app). Set appViewUrl if you need a different AppView (e.g., for private lists).
Reply Posting (optional)
To allow replies, set posting credentials and choose a default mode that allows replies (open or mention-only):
channels:
bluesky:
groups:
"*": { mode: open }
handle: you.bsky.social
appPassword: xxxx-xxxx-xxxx-xxxxNotes:
- You must use a Bluesky app password (Settings -> App Passwords).
- Replies are posted only for
app.bsky.feed.postevents. - Replies go to the latest post from the DID currently being processed.
- Posts are capped to 300 characters.
Embeds (summary output)
Post embeds are summarized in a compact form, for example:
Embed: 2 image(s) (alt: ...)Embed: link "Title" https://...Embed: record at://...
Troubleshooting
No messages appearing
- Ensure
wantedDidscontains DID values (e.g.did:plc:...), not handles. - Confirm
wantedCollectionsisn't filtering out posts (omit it to see all collections). - Check logs for the warning about missing
wantedDids(firehose may be too noisy). - Verify the Jetstream URL is reachable.
CLI Tools
LettaBot ships with a few small CLIs that the agent can invoke via Bash, or you can run manually. They use the same config/credentials as the bot server.
lettabot config
Manage your lettabot.yaml configuration.
lettabot config # Show current config summary + menu
lettabot config tui # Interactive core config editor
lettabot config encode # Encode config as base64 (for cloud deploy)
lettabot config decode <base64> # Decode base64 config back to YAMLInteractive TUI editor
lettabot config tui opens an interactive editor for the most common settings:
- Server auth -- switch between API/Docker mode, set API key or base URL
- Agent identity -- change agent name and ID
- Channels -- enable/disable channels and run their setup wizards
- Features -- toggle cron, heartbeat (with interval), and memfs
The TUI loads your existing config, lets you edit fields interactively, shows a summary of changes, and saves back to the same file. Non-core fields (providers, attachments, secondary agents, etc.) are preserved through the round-trip.
From the lettabot config menu, you can also choose "Open TUI editor" or "Edit config file" to open the raw YAML in your $EDITOR.
lettabot-message
Send a message to the most recent chat, or target a specific channel/chat.
lettabot-message send --text "Hello from a background task"
lettabot-message send --text "Hello" --channel slack --chat C123456
lettabot-message send --file /tmp/report.pdf --text "Report attached" --channel discord --chat 123456789
lettabot-message send --file /tmp/voice.ogg --voice # Send as native voice note (see voice.md)lettabot-react
Add a reaction to a message (emoji can be unicode or :alias:).
lettabot-react add --emoji :eyes: --channel discord --chat 123 --message 456
lettabot-react add --emoji "👍"lettabot-history
Fetch recent messages from supported channels (Discord, Slack).
lettabot-history fetch --limit 25 --channel discord --chat 123456789
lettabot-history fetch --limit 10 --channel slack --chat C123456 --before 1712345678.000100Notes:
- History fetch is not supported by the Telegram Bot API, Signal, or WhatsApp.
- If you omit
--channelor--chat, the CLI falls back to the last message target stored inlettabot-agent.json. - You need the channel-specific bot token set (
DISCORD_BOT_TOKENorSLACK_BOT_TOKEN). - File sending uses the API server and requires
LETTABOT_API_KEY(supported: telegram, slack, discord, whatsapp).
Cloud Deployment
Deploy LettaBot to any cloud platform that supports Docker or Node.js.
Prerequisites
- A Letta API key (or a self-hosted Letta server -- see Docker Server Setup)
- At least one channel token (Telegram, Discord, or Slack)
- A working
lettabot.yamlconfig (runlettabot onboardto create one)
Configuration
Cloud platforms typically don't support config files directly. LettaBot solves this with LETTABOT_CONFIG_YAML -- a single environment variable containing your entire config.
Encoding Your Config
# Using the CLI helper (recommended)
lettabot config encode
# Or manually
base64 < lettabot.yaml | tr -d '\n'Set the output as LETTABOT_CONFIG_YAML on your platform. This is the only env var you need -- everything (API key, channels, features) is in the YAML.
Both base64-encoded and raw YAML values are accepted. Base64 is recommended since some platforms don't handle multi-line env vars well.
Verifying
To decode and inspect what a LETTABOT_CONFIG_YAML value contains:
LETTABOT_CONFIG_YAML=... lettabot config decodeDocker
LettaBot includes a Dockerfile for containerized deployment.
Build and Run
docker build -t lettabot .
docker run -d \
-e LETTABOT_CONFIG_YAML="$(base64 < lettabot.yaml | tr -d '\n')" \
-p 8080:8080 \
lettabotDocker Compose
services:
lettabot:
build: .
ports:
- "8080:8080"
environment:
- LETTABOT_CONFIG_YAML=${LETTABOT_CONFIG_YAML}
restart: unless-stoppedIf running alongside a self-hosted Letta server, see Docker Server Setup for the Letta container config.
Fly.io
# Install CLI
brew install flyctl
fly auth login
# Launch (detects Dockerfile automatically)
fly launch
# Set your config
fly secrets set LETTABOT_CONFIG_YAML="$(base64 < lettabot.yaml | tr -d '\n')"
# Set a stable API key (optional, prevents regeneration across deploys)
fly secrets set LETTABOT_API_KEY=$(openssl rand -hex 32)
# Deploy
fly deployfly launch generates a fly.toml with your app name. Edit it to keep the bot running (Fly defaults to stopping idle machines):
[http_service]
auto_stop_machines = false
min_machines_running = 1Scale to 1 machine (multiple instances would conflict on channel tokens):
fly scale count 1Railway
See Railway Deployment for the full guide including one-click deploy, persistent volumes, and Railway-specific configuration.
The short version:
1. Fork the repo and connect to Railway 2. Set LETTABOT_CONFIG_YAML (or individual env vars for simple setups) 3. Deploy
Other Platforms
Any platform that runs Docker images or Node.js works. Set LETTABOT_CONFIG_YAML as an env var and you're done.
Render: Deploy from GitHub, set env var in dashboard.
DigitalOcean App Platform: Use the Dockerfile, set env var in app settings.
Any VPS (EC2, Linode, Hetzner): Build the Docker image and run it, or install Node.js and run npm start directly.
Web Portal
LettaBot includes an admin portal at /portal for managing pairing approvals from a browser. Navigate to https://your-host/portal and enter your API key to:
- View pending pairing requests across all channels
- Approve users with one click
- Auto-refreshes every 10 seconds
API Key
An API key is auto-generated on first boot and printed in logs. It's required for the web portal and HTTP API endpoints.
To make it stable across deploys, set LETTABOT_API_KEY as an environment variable:
# Fly.io
fly secrets set LETTABOT_API_KEY=$(openssl rand -hex 32)
# Railway / Render / etc.
# Set LETTABOT_API_KEY in the platform's env var UIHealth Check
LettaBot exposes GET /health which returns ok. Configure your platform's health check to use this endpoint.
Channel Limitations
| Channel | Cloud Support | Notes |
|---|---|---|
| Telegram | Yes | Full support |
| Discord | Yes | Full support |
| Slack | Yes | Full support |
| No | Requires local QR code pairing | |
| Signal | No | Requires local device registration |
Commands Reference
LettaBot responds to these slash commands in chat channels.
Available Commands
/start or /help
Shows the welcome message and list of available commands.
LettaBot - AI assistant with persistent memory
Commands:
/status - Show current status
/help - Show this message
Just send me a message to get started!/status
Shows your current agent ID and connection status.
Useful for debugging or if you need to reference your agent in other tools.
Example:
You: /status
Bot: Agent: agent-a1b2c3d4-...
Model: claude-sonnet-4
Channels: telegram, slack/heartbeat
Manually triggers a heartbeat check-in.
Heartbeats are background tasks where the agent can:
- Review pending tasks
- Check reminders
- Perform proactive actions
Note: This command runs silently - the agent won't automatically reply. If the agent wants to message you during a heartbeat, it will use the lettabot-message CLI.
/approve
Approves all currently pending tool approvals for your current conversation scope.
- In shared mode, this applies to the shared conversation.
- In per-channel/per-chat modes, this applies only to that channel/chat conversation.
Useful when a run is blocked waiting on tool approval and you want to continue directly from chat.
/disapprove [reason]
Denies all currently pending tool approvals for your current conversation scope.
- You can provide an optional reason, e.g.
/disapprove not safe to run. - Without a reason, LettaBot sends a default denial reason.
Use this to quickly reject pending tool calls without leaving your chat client.
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
Note: Available formatting varies by channel. WhatsApp and Signal have limited markdown support.
Cross-Channel Commands
Commands work the same across all channels (Telegram, Slack, Discord, WhatsApp, Signal). The agent maintains a single conversation across all channels.
Scheduling Tasks (Cron & Heartbeat)
LettaBot supports two types of background tasks:
- Cron jobs: Send scheduled messages at specific times
- Heartbeats: Periodic agent check-ins
Enabling Background Tasks
Add to your lettabot.yaml:
features:
cron: true
heartbeat:
enabled: true
intervalMin: 60 # Every 60 minutesOr via environment variables:
CRON_ENABLED=true
HEARTBEAT_ENABLED=true
HEARTBEAT_INTERVAL_MIN=60Cron Jobs
Schedule tasks that send you messages at specific times.
Creating a Job
lettabot-cron create \
--name "Morning Briefing" \
--schedule "0 8 * * *" \
--message "Good morning! Review tasks for today." \
--deliver telegram:123456789Options:
--name- Job name (required)--schedule- Cron expression (required)--message- Message sent when job runs (required)--deliver- Where to send:channel:chatId(defaults to last messaged chat at creation time; falls back to last messaged chat at runtime)--silent- Do not deliver response automatically (agent must uselettabot-message send)
Managing Jobs
lettabot-cron list # Show all jobs
lettabot-cron update <id> ... # Update job properties (--deliver, --name, --message, etc.)
lettabot-cron delete <id> # Delete a job
lettabot-cron enable <id> # Enable a job
lettabot-cron disable <id> # Disable a jobCron Expression Syntax
┌───────── minute (0-59)
│ ┌─────── hour (0-23)
│ │ ┌───── day of month (1-31)
│ │ │ ┌─── month (1-12)
│ │ │ │ ┌─ day of week (0-6, Sun=0)
* * * * *Examples:
| Expression | 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 |
Example Jobs
Daily morning check-in:
lettabot-cron create \
-n "Morning" \
-s "0 8 * * *" \
-m "Good morning! What's on today's agenda?"Weekly review:
lettabot-cron create \
-n "Weekly Review" \
-s "0 17 * * 5" \
-m "Friday wrap-up: What did we accomplish this week?"Hourly reminder:
lettabot-cron create \
-n "Hydration" \
-s "0 * * * *" \
-m "Time to drink water!"Heartbeats
Heartbeats are periodic check-ins where the agent can:
- Review pending tasks
- Check reminders
- Perform proactive actions
Configuration
features:
heartbeat:
enabled: true
intervalMin: 60 # Default: 60 minutes
skipRecentPolicy: fraction # fixed | fraction | off
skipRecentFraction: 0.5 # Used when policy=fraction (0-1)
# skipRecentUserMin: 5 # Used when policy=fixed (0 disables)
interruptOnUserMessage: true # Cancel in-flight heartbeat when user messages arriveBy default, automatic heartbeats are skipped for half the heartbeat interval (skipRecentPolicy: fraction, skipRecentFraction: 0.5).
- Use
skipRecentPolicy: fixed+skipRecentUserMinfor a fixed window. - Use
skipRecentPolicy: offto disable recent-user skipping. interruptOnUserMessage: trueprioritizes live user messages by cancelling in-flight heartbeat runs on the same key.- Manual
/heartbeatalways bypasses the skip check.
Manual Trigger
You can trigger a heartbeat manually via the /heartbeat command in any channel.
How It Works
1. At each interval (or when /heartbeat is called), the agent receives a heartbeat message 2. The agent runs in Silent Mode - responses are not automatically delivered 3. If the agent wants to message you, it must use lettabot-message send
This prevents unwanted messages while allowing proactive behavior when needed.
Heartbeat To-Dos
Heartbeats include a PENDING TO-DOS section when actionable tasks exist. Tasks can come from:
lettabot todo ...CLI commands- The
manage_todotool - Built-in Letta Code todo tools (
TodoWrite,WriteTodos,write_todos), which are synced into LettaBot's persistent todo store
Only actionable tasks are shown in the heartbeat prompt:
completed: falsesnoozed_untilnot set, or already in the past
Delivery Behavior
Cron Jobs
Cron jobs deliver responses automatically:
- If
--deliverwas specified at creation, responses go to that channel/chat - If
--deliverwas omitted, the CLI auto-fills from the last messaged chat - At runtime, if a job has no configured delivery target, it falls back to the most recent message target
- Use
--silentat creation to explicitly opt out of automatic delivery
Heartbeats (Silent Mode)
Heartbeats run in Silent Mode -- responses are NOT automatically delivered:
- The agent sees a
[SILENT MODE]banner with instructions - To send messages, the agent must explicitly run:
lettabot-message send --text "Your message here"Requirements for background messaging:
- Bash tool must be enabled for the agent
- A user must have messaged the bot at least once (establishes delivery target)
Monitoring & Logs
Check Job Status
lettabot-cron listShows:
- Job ID, name, schedule
- Next run time
- Last run status
Log Files
cron-jobs.json- Job configurationscron-log.jsonl- Execution logs
Cron Storage Path
Cron state is resolved with deterministic precedence:
1. RAILWAY_VOLUME_MOUNT_PATH 2. DATA_DIR 3. WORKING_DIR 4. /tmp/lettabot
Migration note:
- Older versions used
process.cwd()/cron-jobs.jsonwhenDATA_DIRwas not set. - On first run after upgrade, LettaBot auto-copies that legacy file into the new canonical cron path.
Troubleshooting
Cron jobs not running
1. Check features.cron: true in config 2. Verify schedule expression is valid 3. Check lettabot-cron list for next run time
Agent not sending messages during heartbeat
1. Check if Bash tool is enabled (agent needs to run CLI) 2. Verify a user has messaged the bot at least once 3. Check the ADE to see agent activity
Jobs running but no messages received
1. Check lettabot-cron list -- does the job show a delivery target? 2. If delivery shows (none), the job was created without --deliver and no user had messaged the bot yet 3. Fix: lettabot-cron update <id> --deliver telegram:123456789 (or your channel:chatId) 4. Alternatively, send the bot any message to establish a last-message target -- new runs will auto-deliver 5. Check logs for "mode":"silent" entries -- this confirms the job ran but had nowhere to send the response
Response Directives
LettaBot supports XML response directives -- lightweight actions that the agent embeds directly in its text responses. The bot parses and executes these directives before delivering the message, stripping them from the output so the user never sees raw XML.
This is cheaper than tool calls (no round trip to the server) and extends the existing <no-reply/> pattern.
How It Works
The agent includes an <actions> block at the start of its response:
<actions>
<react emoji="thumbsup" />
</actions>
Great idea!The bot: 1. Detects the <actions> block during streaming (held back from display) 2. Parses the directives inside it 3. Executes each directive (e.g. adds a reaction) 4. Delivers only the clean text (Great idea!) to the user
If the <actions> block is the entire response (no text after it), the directive executes silently with no message sent.
Supported Directives
<react>
Adds an emoji reaction to a message.
<react emoji="thumbsup" />
<react emoji="eyes" message="456" />Attributes:
emoji(required) -- The emoji to react with. Accepts:- Text aliases:
thumbsup,eyes,fire,heart,tada,clap,smile,laughing,ok_hand,thumbs_up,+1 - Colon-wrapped aliases:
:thumbsup: - Unicode emoji: direct characters like
👍 message(optional) -- Target message ID. Defaults to the message that triggered the response.
<send-message>
Sends a text message to a specific channel and chat. Unlike normal response text (which goes to the triggering chat), this directive lets the agent proactively send messages to any connected chat -- useful for async job notifications, multi-tenant workflows, or cross-channel delivery.
<send-message channel="whatsapp" chat="5511999999999">Your transcription is ready!</send-message>
<send-message channel="telegram" chat="123456">Job #42 completed successfully.</send-message>Attributes:
channel(required) -- Target channel ID (telegram,slack,discord,whatsapp,signal)chat(required) -- Target chat/conversation ID on that channel
Text content between the opening and closing tags is the message body. Empty messages are ignored.
Works from any context including heartbeats and cron jobs (silent mode). The agent must know the target channel and chat ID -- these are visible in the formatter envelope of inbound messages (e.g. [WhatsApp:5511999999999 ...]).
<send-file>
Sends a file or image. By default, sends to the same channel/chat as the triggering message. With optional channel and chat attributes, can target a different chat (cross-channel file delivery).
<send-file path="/tmp/report.pdf" caption="Report attached" />
<send-file path="/tmp/photo.png" kind="image" caption="Look!" />
<send-file path="/tmp/voice.ogg" kind="audio" cleanup="true" />
<send-file path="/tmp/temp-export.csv" cleanup="true" />
<send-file path="/tmp/result.txt" channel="whatsapp" chat="5511999999999" caption="Here's your file" />Attributes:
path/file(required) -- Local file path on the LettaBot servercaption/text(optional) -- Caption text for the filekind(optional) --image,file, oraudio(defaults to auto-detect based on extension). Audio files (.ogg, .opus, .mp3, .m4a, .wav, .aac, .flac) are auto-detected asaudio.cleanup(optional) --trueto delete the file after sending (default: false)channel(optional) -- Target channel ID for cross-channel deliverychat(optional) -- Target chat ID for cross-channel delivery (bothchannelandchatmust be set)
Security:
- File paths are restricted to the configured
sendFileDirdirectory (defaults todata/outbound/under the agent's working directory). Paths outside this directory are blocked and logged. - Symlinks that resolve outside the allowed directory are also blocked.
- File size is limited to
sendFileMaxSize(default: 50MB). - The
cleanupattribute only works whensendFileCleanup: trueis set in the agent's features config (disabled by default).
<voice>
Generates speech from text via TTS and sends it as a native voice note. No tool calls needed.
<voice>Hey, here's a quick voice reply!</voice>The text content is sent to the configured TTS provider, converted to audio, and delivered as a voice note. Audio is automatically cleaned up after sending. See voice.md for full setup and provider options.
- Requires
ttsto be configured inlettabot.yaml - Renders as native voice bubbles on Telegram and WhatsApp
- Discord and Slack receive a playable audio attachment
- On Telegram, falls back to audio file if voice messages are restricted by Premium privacy settings
- Can be combined with text: any text after the
</actions>block is sent as a normal message alongside the voice note
<no-reply/>
Suppresses response delivery entirely. The agent's text is discarded.
<no-reply/>This is a standalone marker (not inside <actions>) and must be the entire response text. Useful when the agent decides observation is more appropriate than replying (e.g. in group chats).
Attribute Quoting
The parser accepts multiple quoting styles to handle variation in LLM output:
<!-- All of these work -->
<react emoji="thumbsup" />
<react emoji='thumbsup' />
<react emoji=\"thumbsup\" />Backslash-escaped quotes (common when LLMs generate XML inside a JSON context) are normalized before parsing.
Channel Support
| Channel | addReaction | send-file | kind="audio" | Notes |
|---|---|---|---|---|
| Telegram | Yes | Yes | Voice note (sendVoice) | Falls back to sendAudio if voice messages are restricted by Telegram Premium privacy settings. |
| Slack | Yes | Yes | Audio attachment | Reactions use Slack emoji names (:thumbsup: style). |
| Discord | Yes | Yes | Audio attachment | Custom server emoji not yet supported. |
| No | Yes | Voice note (PTT) | Sent with ptt: true for native voice bubble. | |
| Signal | No | Yes | Audio attachment | Sent as a file attachment. |
When a channel doesn't implement addReaction, the directive is silently skipped and a warning is logged. This never blocks message delivery.
Emoji Alias Resolution
Each channel adapter resolves emoji aliases independently since platforms have different requirements:
- Telegram/Discord: Map text aliases (
thumbsup,fire, etc.) to Unicode characters - Slack: Maps Unicode back to Slack shortcode names, or passes
:alias:format through directly
The common aliases supported across all reaction-capable channels:
| Alias | Emoji |
|---|---|
eyes | 👀 |
thumbsup / thumbs_up / +1 | 👍 |
heart | ❤️ |
fire | 🔥 |
smile | 😄 |
laughing | 😆 |
tada | 🎉 |
clap | 👏 |
ok_hand | 👌 |
Unicode emoji can always be used directly and are passed through as-is.
Streaming Behavior
During streaming, the bot holds back display while the response could still be an <actions> block or <no-reply/> marker. Once the block is complete (or clearly not present), the cleaned text begins streaming to the user. This prevents raw XML from flashing in the chat.
Extending with New Directives
The parser (src/core/directives.ts) is designed to be extensible. Adding a new directive type involves:
1. Add the tag name to DIRECTIVE_TOKEN_REGEX (self-closing) or its content-bearing alternation 2. Add a new interface to the Directive union type 3. Add a parsing case in parseChildDirectives() 4. Add an execution case in executeDirectives() in bot.ts
See issue #240 for planned directives.
Source
- Parser:
src/core/directives.ts - Execution:
src/core/bot.ts(executeDirectives()) - Tests:
src/core/directives.test.ts - Original PR: #239
Discord Setup for LettaBot
This guide walks you through setting up Discord as a channel for LettaBot.
Overview
LettaBot connects to Discord using a Bot Application with the Gateway API:
- No public URL required (uses WebSocket connection)
- Works behind firewalls
- Real-time bidirectional communication
Prerequisites
- A Discord server where you have permission to add bots
- LettaBot installed and configured with at least
LETTA_API_KEY
Step 1: Create a Discord Application
1. Go to https://discord.com/developers/applications 2. Click "New Application" 3. Enter a name (e.g., LettaBot) 4. Click "Create"
Step 2: Create the Bot
1. In the left sidebar, click "Bot" 2. Click "Reset Token" (or "Add Bot" if this is new) 3. Copy the token - this is your DISCORD_BOT_TOKEN
Important: You can only see this token once. If you lose it, you'll need to reset it.
Step 3: Enable Message Content Intent
This is required for the bot to read message content.
1. Still in the "Bot" section 2. Scroll down to "Privileged Gateway Intents" 3. Enable "MESSAGE CONTENT INTENT" 4. Click "Save Changes"
Step 4: Generate Invite URL
1. In the left sidebar, go to "OAuth2" → "URL Generator" 2. Under "Scopes", select:
bot
3. Under "Bot Permissions", select:
Send MessagesRead Message HistoryView Channels
4. Copy the generated URL at the bottom
Or use this URL template (replace YOUR_CLIENT_ID):
https://discord.com/oauth2/authorize?client_id=YOUR_CLIENT_ID&permissions=68608&scope=botTip: Your Client ID is in "General Information" or in the URL when viewing your app.
Step 5: Add Bot to Your Server
1. Open the invite URL from Step 4 in your browser 2. Select the server you want to add the bot to 3. Click "Authorize" 4. Complete the CAPTCHA if prompted
You should see [Bot Name] has joined the server in Discord.
Step 6: Configure LettaBot
Run the onboarding wizard and select Discord:
lettabot onboardOr add directly to your lettabot.yaml:
channels:
discord:
enabled: true
token: "your-bot-token-here"
dmPolicy: pairing # or 'allowlist' or 'open'Step 7: Start LettaBot
lettabot serverYou should see:
Registered channel: Discord
[Discord] Connecting...
[Discord] Bot logged in as YourBot#1234
[Discord] DM policy: pairingStep 8: Test the Integration
In a Server Channel
1. Go to a text channel in your Discord server 2. Type @YourBot hello! 3. The bot should respond
Direct Message
1. Right-click on the bot in the server member list 2. Click "Message" 3. Send a message: Hello! 4. The bot should respond (may require pairing approval first)
Access Control
LettaBot supports three DM policies for Discord:
Pairing (Recommended)
dmPolicy: pairing- New users receive a pairing code
- Approve with:
lettabot pairing approve discord <CODE> - Most secure for personal use
Allowlist
dmPolicy: allowlist
allowedUsers:
- "123456789012345678" # Discord user IDs- Only specified users can interact
- Find user IDs: Enable Developer Mode in Discord settings, then right-click a user → "Copy User ID"
Open
dmPolicy: open- Anyone can message the bot
- Not recommended for personal bots
Group Behavior
By default, the bot processes and responds to all messages in server channels (open mode). You can control this with the groups config.
Group Modes
Three modes are available:
- `open` -- Bot responds to all messages in the channel (default)
- `listen` -- Bot processes all messages for context/memory, but only responds when @mentioned
- `mention-only` -- Bot completely ignores messages unless @mentioned (cheapest option -- messages are dropped at the adapter level before reaching the agent)
- `disabled` -- Bot drops all messages in the channel unconditionally, even if @mentioned
Configuring group modes
Add a groups section to your Discord channel config. Keys can be channel IDs, guild (server) IDs, or * as a wildcard default:
channels:
discord:
enabled: true
token: "your-bot-token"
groups:
"*": { mode: mention-only } # default: require @mention everywhere
"123456789012345678": { mode: open } # this channel: respond to everything
"987654321098765432": { mode: listen } # this channel: read all, respond on mentionMode resolution priority: channel ID > guild ID > * wildcard > open (built-in default).
To find channel and server IDs: enable Developer Mode in Discord settings (User Settings > Advanced > Developer Mode), then right-click any channel or server and select "Copy Channel ID" or "Copy Server ID".
Channel allowlisting
If you define groups with specific IDs and do not include a * wildcard, the bot will only be active in those listed channels. Messages in unlisted channels are silently dropped -- they never reach the agent and consume no tokens.
channels:
discord:
token: "your-bot-token"
groups:
"111111111111111111": { mode: open }
"222222222222222222": { mode: mention-only }
# No "*" -- all other channels are completely ignoredThis is the recommended approach when you want to restrict the bot to specific channels.
Thread-only mode (Discord)
If you want the bot to reply only inside threads, set threadMode: thread-only on a channel (for example #ezra).
You can also set autoCreateThreadOnMention: true so a top-level @mention creates a thread and the bot replies there.
channels:
discord:
token: "your-bot-token"
groups:
"EZRA_CHANNEL_ID":
mode: open
threadMode: thread-only
autoCreateThreadOnMention: trueBehavior summary:
- Messages already inside threads are processed normally.
- Top-level messages are ignored in
thread-onlymode. - Top-level @mentions create a thread and are answered in that thread when
autoCreateThreadOnMentionis enabled. - Thread messages inherit parent channel config. If
EZRA_CHANNEL_IDis configured, replies in its child threads use that same config. - Each thread gets its own isolated conversation (message history), overriding
sharedandper-channelconversation modes. This prevents crosstalk between threads. Agent memory (blocks) is still shared.
Required Discord permissions for auto-create:
Send MessagesCreate Public Threads(or relevant thread creation permission for your channel type)Send Messages in Threads
Per-group user filtering
Use allowedUsers within a group entry to restrict which Discord users can trigger the bot. Messages from other users are silently dropped before reaching the agent.
channels:
discord:
token: "your-bot-token"
groups:
"*":
mode: mention-only
allowedUsers:
- "YOUR_DISCORD_USER_ID" # Only you can trigger the bot
"TESTING_CHANNEL":
mode: open
# No allowedUsers -- anyone can interact hereFind your Discord user ID: enable Developer Mode in Discord settings, then right-click your name and select "Copy User ID".
Multiple Bots on Discord
If you run multiple agents in a multi-agent configuration, each with their own Discord adapter, there are two scenarios to consider.
Separate Discord app tokens (recommended)
Each bot connects independently. Give each its own groups config:
agents:
- name: helper-bot
channels:
discord:
token: "TOKEN_A"
groups:
"*": { mode: mention-only }
- name: creative-bot
channels:
discord:
token: "TOKEN_B"
groups:
"*": { mode: mention-only }These bots are fully isolated -- Discord delivers messages to each token independently.
Shared Discord app token
If two agents share the same Discord app token, Discord delivers every message to both adapter instances. Use channel allowlisting (no * wildcard) to partition which channels each bot handles:
agents:
- name: bot1
channels:
discord:
token: "SHARED_TOKEN"
groups:
"CHANNEL_FOR_BOT1": { mode: mention-only }
# No "*" -- bot1 ignores all other channels
- name: bot2
channels:
discord:
token: "SHARED_TOKEN"
groups:
"CHANNEL_FOR_BOT2": { mode: mention-only }
# No "*" -- bot2 ignores all other channelsBoth adapters technically receive every Discord event, but the non-matching adapter drops messages immediately in the event handler -- no agent interaction, no token cost. For true isolation at the Discord level, use separate app tokens.
Adding Reactions
LettaBot can react to messages using the lettabot-react CLI:
# React to the most recent message
lettabot-react add --emoji ":eyes:"
# React to a specific message
lettabot-react add --emoji ":thumbsup:" --channel discord --chat 123456789 --message 987654321Troubleshooting
Bot shows as offline
1. Make sure LettaBot is running (lettabot server) 2. Check for errors in the console 3. Verify your bot token is correct
Bot doesn't respond to messages
1. Check MESSAGE CONTENT INTENT is enabled:
- Discord Developer Portal → Your App → Bot → Privileged Gateway Intents
- Toggle ON "MESSAGE CONTENT INTENT"
2. Check bot has permissions in the channel:
- Server Settings → Roles → Your Bot's Role
- Or check channel-specific permissions
3. Check pairing status if using pairing mode:
- New users need to be approved via
lettabot pairing list
All bots respond to every message
If you run multiple agents sharing the same Discord token and all of them respond to every message, you need to configure channel allowlisting. Add a groups section to each agent's Discord config with specific channel IDs and *no `` wildcard**. See Multiple Bots on Discord above.
"0 Servers" in Developer Portal
The bot hasn't been invited to any servers yet. Use the invite URL from Step 4.
Bot can't DM users
Discord bots can only DM users who:
- Share a server with the bot, OR
- Have previously DM'd the bot
This is a Discord limitation, not a LettaBot issue.
Rate limiting
If the bot stops responding temporarily, it may be rate-limited by Discord. Wait a few minutes and try again. Avoid sending many messages in quick succession.
Security Notes
- Bot tokens should be kept secret - never commit them to git
- Use
dmPolicy: pairingorallowlistin production - The bot can only see messages in channels it has access to
- DMs are only visible between the bot and that specific user
Cross-Channel Memory
Since LettaBot uses a single agent across all channels:
- Messages you send on Discord continue the same conversation as Telegram/Slack
- The agent remembers context from all channels
- You can start a conversation on Telegram and continue it on Discord
Next Steps
- Slack Setup
- WhatsApp Setup
- Signal Setup
Getting Started
Get LettaBot running in 5 minutes.
Prerequisites
- Node.js 20+
- npm or yarn
- A Telegram account
- A Letta account (app.letta.com)
Quick Start
1. Clone and Install
git clone https://github.com/letta-ai/lettabot.git
cd lettabot
npm ciNote: Always usenpm ci(notnpm install) to avoid modifying the lockfile, which would block futuregit pullupdates.
2. Create a Telegram Bot
1. Open Telegram and message @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 2. Sign in or create an account 3. Go to Settings > API Keys 4. Create a new API key and copy it
3b. Connect your ChatGPT subscription (optional)
If you want connected provider models from your ChatGPT/ChatGPT Plus subscription, run:
lettabot connect chatgptThe command opens a browser-based flow for OAuth and then makes those handles available in lettabot model and onboarding.
4. Configure LettaBot
Option A: Interactive Setup (Recommended)
npm run build
npm link
lettabot onboardThis will walk you through configuration interactively.
Option B: Manual Setup
cp .env.example .envEdit .env:
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
LETTA_API_KEY=your_letta_api_key5. Start the Bot
npm run devYou should see:
Starting LettaBot...
Bot started as @your_bot_name
Allowed users: all6. 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:
# Find your Telegram user ID by messaging @userinfobot
ALLOWED_USERS=123456789,987654321Updating
Pull the latest changes and rebuild:
npm run updateThis performs a fast-forward-only pull, installs dependencies, and rebuilds without resetting tracked files.
Next Steps
- Commands Reference - Learn all bot commands
- Gmail Integration - Set up email notifications
- Slack Setup - Add Slack channel
- Discord Setup - Add Discord channel
OpenAI-Compatible API
LettaBot exposes an OpenAI-compatible API so you can point any OpenAI SDK or tool at your LettaBot server and interact with your agents directly.
Endpoints
| Method | Path | Description |
|---|---|---|
GET | /v1/models | List available agents (as "models") |
POST | /v1/chat/completions | Send a message and get a response (sync or streaming) |
Both endpoints run on the same API server as the rest of LettaBot (default port 8080, configurable via server.api.port).
Authentication
All requests require an API key, passed as either:
Authorization: Bearer <key>X-Api-Key: <key>
The API key is auto-generated on first run and saved to lettabot-api.json, or set via the LETTABOT_API_KEY environment variable. This is the same key used by /api/v1/chat and /api/v1/chat/async.
Quick Start
Python
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8080/v1",
api_key="YOUR_API_KEY",
)
# Sync
response = client.chat.completions.create(
model="lettabot", # your agent name
messages=[{"role": "user", "content": "What's on my todo list?"}],
)
print(response.choices[0].message.content)
# Streaming
stream = client.chat.completions.create(
model="lettabot",
messages=[{"role": "user", "content": "What's on my todo list?"}],
stream=True,
)
for chunk in stream:
delta = chunk.choices[0].delta
if delta.content:
print(delta.content, end="", flush=True)Node / TypeScript
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "http://localhost:8080/v1",
apiKey: "YOUR_API_KEY",
});
// Sync
const response = await client.chat.completions.create({
model: "lettabot",
messages: [{ role: "user", content: "What's on my todo list?" }],
});
console.log(response.choices[0].message.content);
// Streaming
const stream = await client.chat.completions.create({
model: "lettabot",
messages: [{ role: "user", content: "What's on my todo list?" }],
stream: true,
});
for await (const chunk of stream) {
const delta = chunk.choices[0].delta;
if (delta.content) process.stdout.write(delta.content);
}curl
Sync:
curl -X POST http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"model": "lettabot",
"messages": [{"role": "user", "content": "What is on my todo list?"}]
}'Streaming:
curl -N -X POST http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"model": "lettabot",
"messages": [{"role": "user", "content": "What is on my todo list?"}],
"stream": true
}'Model Mapping
The model field maps to your agent names. Use GET /v1/models to list them:
curl http://localhost:8080/v1/models \
-H "Authorization: Bearer YOUR_API_KEY"{
"object": "list",
"data": [
{ "id": "lettabot", "object": "model", "created": 1740000000, "owned_by": "lettabot" },
{ "id": "helper-bot", "object": "model", "created": 1740000000, "owned_by": "lettabot" }
]
}If you omit model in a chat request, the first configured agent is used.
Streaming
When stream: true, responses arrive as Server-Sent Events (SSE). The stream includes:
- Content deltas -- incremental text from the assistant
- Tool call deltas -- tool invocations with name and arguments
- Finish chunk --
finish_reason: "stop" - `[DONE]` sentinel -- end of stream
Internal events (reasoning, tool results) are filtered and not included in the stream.
Supported Parameters
| Parameter | Supported | Notes |
|---|---|---|
model | Yes | Maps to agent name |
messages | Yes | Only the last user message is extracted (see below) |
stream | Yes | true for SSE streaming, false/omitted for sync |
temperature | Ignored | Accepted but has no effect |
max_tokens | Ignored | Accepted but has no effect |
tools | Ignored | Agent tools are configured server-side |
top_p | Ignored | Accepted but has no effect |
| All others | Ignored | Silently accepted for compatibility |
How Messages Are Handled
The OpenAI API lets you send a full conversation in the messages array. LettaBot handles this differently:
- Only the last user message is extracted and sent to the agent
- Multi-turn context is managed by Letta's built-in memory and conversation history, not by the messages array
- System messages, assistant messages, and tool messages in the array are ignored
This means you don't need to manage conversation history client-side -- the agent remembers everything on its own.
Limitations
- `usage` is always `null` -- token counts are not tracked
- No multi-turn passthrough -- only the last user message is used (see above)
- Tool definitions ignored -- tools are configured on the agent, not per-request
- Reasoning events filtered -- the agent's internal reasoning is not exposed in the stream
Use with Open WebUI
Since the endpoint is OpenAI-compatible, you can connect it to Open WebUI or any other OpenAI-compatible frontend. Point the frontend at http://localhost:8080/v1 with your API key.