git clone <repo-url>
cd rayclaw
cp rayclaw.config.example.yaml rayclaw.config.yaml
# Edit rayclaw.config.yaml with your credentials
cargo run -- start- Rust 1.70+ (2021 edition)
- At least one enabled channel adapter (Telegram bot token from @BotFather, Discord bot token from Discord Developer Portal, Slack app/bot tokens, Feishu/Lark app credentials, or Web UI)
- An Anthropic API key
No other external dependencies. SQLite is bundled via rusqlite.
src/
main.rs # Entry point. Parses CLI args, initializes subsystems, starts platform runtimes.
config.rs # Config struct. All settings loaded from rayclaw.config.yaml.
error.rs # RayClawError enum (thiserror). Centralized error types.
telegram.rs # Core orchestration:
# - Telegram message handler
# - Agentic tool-use loop (process_with_claude)
# - Session resume (load/save full message state)
# - Context compaction (summarize old messages)
# - Continuous typing indicator
# - Group chat catch-up logic
# - Response splitting
discord.rs # Discord message handler (serenity gateway), reuses process_with_claude
channels/slack.rs # Slack adapter (Socket Mode WebSocket)
channels/feishu.rs # Feishu/Lark adapter (WebSocket long connection or webhook)
claude.rs # Anthropic Messages API client:
# - Request/response types with serde
# - HTTP calls with retry on 429
# - Content block enums (Text, ToolUse, ToolResult)
db.rs # SQLite database:
# - Schema creation (chats, messages, scheduled_tasks, sessions)
# - Message storage and retrieval
# - Session save/load/delete for resume
# - Scheduled task CRUD
# - Catch-up query (messages since last bot response)
memory.rs # MemoryManager:
# - Reads/writes CLAUDE.md files (global + per-chat)
# - Builds memory context for system prompts
scheduler.rs # Background scheduler:
# - Spawns a tokio task that polls every 60s
# - Finds due tasks, runs agent loop, sends results
# - Computes next run time for cron tasks
tools/
mod.rs # Tool trait, ToolRegistry, ToolResult.
# Registry takes data_dir, Bot, Arc<Database>.
bash.rs # Shell command execution (tokio::process::Command)
read_file.rs # File reading with line numbers, offset/limit
write_file.rs # File creation/overwrite, auto-creates directories
edit_file.rs # Find/replace editing, validates uniqueness
glob.rs # File pattern matching (glob crate)
grep.rs # Recursive regex search, directory traversal
memory.rs # read_memory / write_memory tools
web_search.rs # DuckDuckGo HTML search, regex result parsing
web_fetch.rs # URL fetching, HTML tag stripping, 20KB limit
send_message.rs # Mid-conversation messaging (Telegram/Discord)
schedule.rs # 5 scheduling tools (create/list/pause/resume/cancel)
sub_agent.rs # Sub-agent tool with restricted tool registry (9 tools)
Platform message (via adapter)
|
v
Store in SQLite (message + chat metadata)
|
v
Determine response: private=always, group=@mention only
|
v
Start typing indicator (tokio::spawn, every 4s)
|
v
Load session or history:
- Try loading saved session (full message state with tool blocks)
- If session exists: append new user messages since last save
- If no session: fall back to DB history
- Private: last N messages
- Group: all messages since last bot response (catch-up)
|
v
Build system prompt (bot identity + memory context + chat_id)
|
v
Compact if needed (messages > max_session_messages):
- Summarize old messages via Claude
- Keep recent messages verbatim
|
v
Agentic loop (up to max_tool_iterations):
1. Call Claude API with messages + tool definitions
2. If stop_reason == "tool_use" -> execute tools -> append results -> loop
3. If stop_reason == "end_turn" -> extract text -> return
|
v
Strip image base64 data, save session to SQLite
|
v
Abort typing indicator
|
v
Send response (split at channel limits: Telegram 4096 / Discord 2000 / Slack 4000 / Feishu 4000)
|
v
Store bot response in SQLite
The same core loop is reused across adapters. Adding a new platform should primarily require a new ingress/egress adapter that maps platform events into the shared process_with_claude flow.
| Type | Location | Description |
|---|---|---|
AppState |
telegram.rs |
Shared state: config, bot, db, memory, claude client, tool registry |
Database |
db.rs |
SQLite wrapper with Mutex<Connection> |
ToolRegistry |
tools/mod.rs |
Holds all Box<dyn Tool>, dispatches by name |
Tool trait |
tools/mod.rs |
name(), definition(), execute() |
ClaudeClient |
claude.rs |
HTTP client for Anthropic API |
MemoryManager |
memory.rs |
CLAUDE.md file reader/writer |
AppState is wrapped in Arc and shared:
- Telegram handler has
Arc<AppState>via dptree dependencies - Discord handler has
Arc<AppState>via serenity event handler state - Scheduler gets
Arc<AppState>at spawn time - Tools that need
BotorDatabasehold their own clones/arcs (passed at construction)
control_chat_idsinrayclaw.config.yamldefines privileged chats.- Tool execution receives trusted caller context from
process_with_claude(not from model-provided args). - Non-control chats can only operate on their own
chat_id. - Control chats can perform cross-chat actions.
write_memorywithscope: "global"is restricted to control chats.- Enforcement currently applies to
send_message, scheduler tools,export_chat,todo_*, and chat-scoped memory operations.
chats:
| Column | Type | Description |
|---|---|---|
| chat_id | INTEGER PK | Channel-scoped chat ID |
| chat_title | TEXT | Chat title (nullable) |
| chat_type | TEXT | "private" or "group" |
| last_message_time | TEXT | ISO 8601 timestamp |
messages:
| Column | Type | Description |
|---|---|---|
| id | TEXT | Message ID (PK with chat_id) |
| chat_id | INTEGER | Channel-scoped chat ID |
| sender_name | TEXT | Username or first name |
| content | TEXT | Message text |
| is_from_bot | INTEGER | 0 or 1 |
| timestamp | TEXT | ISO 8601 timestamp |
scheduled_tasks:
| Column | Type | Description |
|---|---|---|
| id | INTEGER PK | Auto-increment task ID |
| chat_id | INTEGER | Target chat for results |
| prompt | TEXT | Instruction to execute |
| schedule_type | TEXT | "cron" or "once" |
| schedule_value | TEXT | Cron expression or ISO timestamp |
| next_run | TEXT | Next scheduled execution time |
| last_run | TEXT | Last execution time (nullable) |
| status | TEXT | "active", "paused", "completed", "cancelled" |
| created_at | TEXT | Creation timestamp |
sessions:
| Column | Type | Description |
|---|---|---|
| chat_id | INTEGER PK | Channel-scoped chat ID |
| messages_json | TEXT | Serialized Vec JSON (full conversation state) |
| updated_at | TEXT | ISO 8601 timestamp of last save |
task_run_logs:
| Column | Type | Description |
|---|---|---|
| id | INTEGER PK | Auto-increment log ID |
| task_id | INTEGER | Associated scheduled task |
| chat_id | INTEGER | Chat the task ran in |
| started_at | TEXT | Run start timestamp |
| finished_at | TEXT | Run end timestamp |
| duration_ms | INTEGER | Run duration in milliseconds |
| success | INTEGER | 0 or 1 |
| result_summary | TEXT | Summary of run result (nullable) |
- Create
src/tools/my_tool.rs:
use async_trait::async_trait;
use serde_json::json;
use super::{schema_object, Tool, ToolResult};
use crate::claude::ToolDefinition;
pub struct MyTool;
#[async_trait]
impl Tool for MyTool {
fn name(&self) -> &str {
"my_tool"
}
fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: "my_tool".into(),
description: "What this tool does".into(),
input_schema: schema_object(
json!({
"param1": {
"type": "string",
"description": "Description of param1"
}
}),
&["param1"],
),
}
}
async fn execute(&self, input: serde_json::Value) -> ToolResult {
let param1 = match input.get("param1").and_then(|v| v.as_str()) {
Some(p) => p,
None => return ToolResult::error("Missing param1".into()),
};
// Do work...
ToolResult::success(format!("Result: {param1}"))
}
}-
Add
pub mod my_tool;tosrc/tools/mod.rs -
Register in
ToolRegistry::new():
Box::new(my_tool::MyTool),If your tool needs shared state (like Bot or Arc<Database>), add a constructor:
pub struct MyTool {
db: Arc<Database>,
}
impl MyTool {
pub fn new(db: Arc<Database>) -> Self {
MyTool { db }
}
}And pass it in ToolRegistry::new():
Box::new(my_tool::MyTool::new(db.clone())),The core agent flow is already shared. New platform support should be implemented as an adapter around that core:
- Add a new runtime module (for example
src/<platform>.rs) that listens to platform events. - Normalize incoming platform messages into the canonical fields used by persistence and
process_with_claude:- stable
chat_id chat_type(private/group-like semantics)- sender display name
- text/media blocks
- stable
- Reuse
process_with_claude(state, chat_id, sender, chat_type, override_prompt)for inference/tool loop/session handling. - Implement outbound reply sending with:
- platform length splitting policy
- mention semantics for group/server channels
- attachment support parity with
send_messagewhere applicable
- Keep chat identity stable across restarts so
sessions,messages, scheduler, and memory scopes remain consistent. - Enforce existing authorization boundaries (for example
control_chat_ids) in any platform-specific entry points. - Add end-to-end tests to
TEST.mdmirroring existing platform suites (DM/private, group mention, reset, limits, failure handling).
The scheduler is a tokio::spawn task started in run_bot(). Every 60 seconds it:
- Queries
scheduled_tasksfor rows wherestatus = 'active' AND next_run <= NOW() - For each due task, calls
process_with_claude()withoverride_prompt = Some(task.prompt) - Sends the agent's response to the task's
chat_id - For cron tasks: computes next run from the cron expression and updates the row
- For one-shot tasks: sets
status = 'completed'
Cron expressions use the cron crate's 6-field format: sec min hour dom month dow.
# Verbose logging
RUST_LOG=debug cargo run -- start
# Just rayclaw logs
RUST_LOG=rayclaw=debug cargo run -- start
# Check database directly
sqlite3 rayclaw.data/runtime/rayclaw.db
sqlite> SELECT * FROM messages ORDER BY timestamp DESC LIMIT 10;
sqlite> SELECT * FROM scheduled_tasks;
sqlite> SELECT * FROM chats;| Task | How |
|---|---|
| Change the model | Set model: "claude-sonnet-4-20250514" in rayclaw.config.yaml |
| Increase context window | Set max_history_messages: 100 in rayclaw.config.yaml (uses more tokens) |
| Increase tool iterations | Set max_tool_iterations: 200 in rayclaw.config.yaml |
| Reset memory | Delete files under rayclaw.data/runtime/groups/ |
| Reset all data | Delete the rayclaw.data/ directory |
| Tune compaction threshold | Set max_session_messages: 60 in rayclaw.config.yaml (higher = more context before compaction) |
| Keep more recent messages | Set compact_keep_recent: 30 in rayclaw.config.yaml (more recent messages kept verbatim) |
| Reset a chat session | Send /reset in the chat, or: sqlite3 rayclaw.data/runtime/rayclaw.db "DELETE FROM sessions WHERE chat_id=XXXX;" |
| Cancel all scheduled tasks | sqlite3 rayclaw.data/runtime/rayclaw.db "UPDATE scheduled_tasks SET status='cancelled' WHERE status='active';" |
cargo build # Dev build (fast compile, slow runtime)
cargo build --release # Release build (slow compile, fast runtime)
cargo run -- start # Run dev build
cargo run -- help # Show CLI helpThe release binary is fully self-contained -- no runtime dependencies, no database server, no config files beyond rayclaw.config.yaml.
| Crate | Version | Purpose |
|---|---|---|
| teloxide | 0.17 | Telegram Bot API |
| serenity | 0.12 | Discord Gateway/API |
| tokio | 1 | Async runtime |
| reqwest | 0.12 | HTTP client (Anthropic API, web fetch/search) |
| rusqlite | 0.32 | SQLite (bundled) |
| serde / serde_json | 1 | Serialization |
| async-trait | 0.1 | Async trait support |
| chrono | 0.4 | Date/time handling |
| cron | 0.13 | Cron expression parsing |
| urlencoding | 2 | URL encoding for web search |
| regex | 1 | Regex for grep tool and HTML parsing |
| glob | 0.3 | File pattern matching |
| uuid | 1 | Message ID generation |
| thiserror | 2 | Error derive macro |
| anyhow | 1 | Error propagation |
| tracing | 0.1 | Logging |