Notice: This tutorial was primarily generated by Claude Code and is subject to review by @lijingrs, MoFAClaw architect. Content may be updated as the project evolves.
Welcome! This tutorial is designed for GSoC students and new contributors who want to understand the mofaclaw architecture and learn how to extend it. By the end, you'll have a deep understanding of how the system works and will have walked through building a new skill, a new tool, and a new channel.
Table of Contents
- Prerequisites
- Build & Run
- Architecture Overview
- The Workspace
- Hands-on: Add a Skill
- Hands-on: Add a Tool
- Hands-on: Add a Channel
- The Gateway: How It All Starts
- Heartbeat & Cron Systems
- Development Workflow
- Project Ideas for GSoC
- Contributing Guidelines
Before starting, make sure you have:
- Rust 1.85+ — Install Rust
- Git — to clone the repository
- An API key — OpenRouter is recommended (gives access to many models)
- A code editor (VS Code with rust-analyzer is a good choice)
Verify your setup:
rustc --version # Should be 1.85.0 or later
cargo --version
git --versiongit clone https://github.com/mofaclaw/mofaclaw.git
cd mofaclaw
cargo build --releasecargo run --release -- onboardThis creates ~/.mofaclaw/config.json and the workspace at ~/.mofaclaw/workspace/, including template files (AGENTS.md, SOUL.md, USER.md), a memory/MEMORY.md for long-term notes, and copies builtin skills to the workspace.
Edit ~/.mofaclaw/config.json:
{
"providers": {
"openrouter": {
"apiKey": "sk-or-v1-YOUR_KEY_HERE"
}
},
"agents": {
"defaults": {
"model": "anthropic/claude-opus-4-5"
}
}
}# Single message mode
cargo run --release -- agent -m "Hello! What can you do?"
# Interactive mode
cargo run --release -- agent
# With a named session (conversation history persists)
cargo run --release -- agent -s my-project| Command | Description |
|---|---|
mofaclaw onboard |
Initialize config & workspace |
mofaclaw agent -m "..." |
Single message chat |
mofaclaw agent |
Interactive chat (stdin) |
mofaclaw agent -s KEY |
Chat with named session |
mofaclaw gateway |
Start the full gateway (channels + agent + heartbeat) |
mofaclaw gateway --port 9000 |
Start gateway on custom port |
mofaclaw status |
Show config, workspace, model, and API key status |
mofaclaw session list |
List all conversation sessions |
mofaclaw session show KEY |
Show session contents |
mofaclaw session delete KEY |
Delete a session |
mofaclaw cron add --name "daily" --message "Hello" --cron "0 9 * * *" |
Add a scheduled task |
mofaclaw cron list |
List scheduled tasks |
mofaclaw channels status |
Show enabled channels and bridge URLs |
Here's how a user message travels through the entire system:
User Input
│
▼
┌──────────────┐ ┌────────────┐ ┌──────────────────────────────────┐
│ CLI / Chat │────▶│ MessageBus │────▶│ AgentLoop │
│ Channel │ │ (tokio │ │ │
│ (DingTalk, │ │ broadcast)│ │ 1. SessionManager │
│ Telegram, │ └────────────┘ │ └─ Load/create session │
│ Feishu, │ │ └─ Get conversation history │
│ WhatsApp) │ │ │
└──────────────┘ │ 2. ContextBuilder │
▲ │ └─ Bootstrap files │
│ │ └─ Skills metadata │
│ │ └─ Memory (via mofa-sdk) │
│ │ │
│ │ 3. LLM Provider (mofa-sdk) │
│ │ └─ Send prompt + history │
│ │ └─ Receive response │
│ │ │
│ │ 4. Tool Execution Loop │
│ │ └─ ToolRegistry dispatches │
│ │ └─ Results fed back to LLM │
│ │ └─ Repeat until done │
│ │ │
│ │ 5. Save session │
│ ┌────────────┐ │ 6. Publish OutboundMessage │
└─────│ MessageBus │◀────────────┤ │
│ (outbound) │ └──────────────────────────────────┘
└────────────┘
| Module | Path | Purpose |
|---|---|---|
| Agent | core/src/agent/ |
Agent loop, context builder, subagent management |
| Tools | core/src/tools/ |
Built-in tools (filesystem, shell, web, spawn, message) |
| Channels | core/src/channels/ |
Chat platform integrations (DingTalk, Telegram, Feishu, WhatsApp) |
| Bus | core/src/bus/ |
Async message routing via tokio broadcast channels |
| Session | core/src/session/ |
Conversation persistence (JSONL files on disk) |
| Config | core/src/config.rs |
JSON-based configuration for providers, channels, tools |
| Provider | core/src/provider/ |
Re-exports mofa-sdk LLM providers (OpenAI-compatible) |
| Skills | skills/ |
Bundled skills that extend agent capabilities (markdown-based) |
| Heartbeat | core/src/heartbeat/ |
Proactive periodic task runner (every 30 min) |
| Cron | core/src/cron/ |
Scheduled task system with cron expressions |
| Messages | core/src/messages.rs |
InboundMessage and OutboundMessage types |
| Types | core/src/types.rs |
Core types: Message, MessageContent, MessageRole |
| Error | core/src/error.rs |
Comprehensive error hierarchy |
| CLI | cli/src/main.rs |
Command-line interface and gateway startup |
The MessageBus (core/src/bus/) is the central nervous system — it decouples channels from the agent using tokio's async broadcast channels.
pub struct MessageBus {
inbound: broadcast::Sender<InboundMessage>, // Channel → Agent
outbound: broadcast::Sender<OutboundMessage>, // Agent → Channel
outbound_subscribers: Arc<RwLock<HashMap<String, Vec<OutboundCallback>>>>,
}Key API:
// Channels publish incoming user messages
bus.publish_inbound(msg: InboundMessage).await;
// AgentLoop subscribes to user messages
let mut rx = bus.subscribe_inbound();
// AgentLoop publishes responses
bus.publish_outbound(msg: OutboundMessage).await;
// Channels subscribe to responses
let mut rx = bus.subscribe_outbound();
// Or register channel-specific callbacks
bus.subscribe_outbound_channel("telegram", callback).await;Message types (core/src/messages.rs):
pub struct InboundMessage {
pub channel: String, // "cli", "telegram", "dingtalk", etc.
pub sender_id: String, // Who sent it
pub chat_id: String, // Conversation identifier
pub content: String, // The actual message text
pub timestamp: DateTime<Utc>,
pub media: Vec<String>, // Paths to attached images/files
pub metadata: HashMap<String, Value>,
}
pub struct OutboundMessage {
pub channel: String, // Target channel
pub chat_id: String, // Target conversation
pub content: String, // Response text
pub reply_to: Option<String>,
pub media: Vec<String>,
pub metadata: HashMap<String, Value>,
}Session keys are derived as "{channel}:{chat_id}" — this is how conversations are grouped and persisted.
The AgentLoop (core/src/agent/loop_.rs) is the core processing engine. It ties everything together:
pub struct AgentLoop {
_agent: Arc<LLMAgent>, // mofa-sdk LLM agent
provider: Arc<dyn MofaLLMProvider>, // LLM provider (OpenRouter, etc.)
tools: Arc<RwLock<ToolRegistry>>, // Registered tools
bus: MessageBus, // Message routing
sessions: Arc<SessionManager>, // Conversation persistence
context: ContextBuilder, // System prompt assembly
running: Arc<RwLock<bool>>, // Running state
task_orchestrator: Arc<TaskOrchestrator>, // Subagent spawning (mofa-sdk)
max_iterations: usize, // Max tool call loops (default: 20)
default_model: String, // e.g. "anthropic/claude-opus-4-5"
temperature: Option<f32>, // Default: 0.7
max_tokens: Option<u32>, // Default: 8192
}Processing flow for a single message:
run()— Main loop, listens on the bus forInboundMessagesprocess_message(msg)— Handles one message:- Determines the response channel and chat ID
- Gets or creates a session via
SessionManager - Loads conversation history (last 50 messages)
- Builds the system prompt via
ContextBuilder - Calls
run_agent_loop()with context + user message
run_agent_loop()— Delegates to mofa-sdk'sAgentLoopwhich handles:- LLM API call
- Tool call detection and execution via
ToolRegistryExecutor - Iterative loop: LLM → tool calls → results → LLM → ... until done or
max_iterations
- Saves the updated session (user message + assistant response)
- Returns
OutboundMessageto publish on the bus
Default tools registered in register_default_tools():
// File tools
registry.register(ReadFileTool::new());
registry.register(WriteFileTool::new());
registry.register(EditFileTool::new());
registry.register(ListDirTool::new());
// Shell tool
registry.register(ExecTool::new());
// Web tools
registry.register(WebSearchTool::new(brave_api_key));
registry.register(WebFetchTool::new());
// Message tool (with bus callback for sending messages)
registry.register(MessageTool::with_callback(...));The SpawnTool is registered separately after the AgentLoop is created, because it needs a reference back to the loop itself (for spawning subagents).
The ContextBuilder (core/src/agent/context.rs) assembles the system prompt that shapes the agent's behavior.
pub struct ContextBuilder {
skills: Arc<SkillsManager>, // from mofa-sdk
workspace: PathBuf, // ~/.mofaclaw/workspace
}System prompt assembly (build_system_prompt()):
-
Bootstrap files from the workspace (loaded by mofa-sdk's
PromptContextBuilder):AGENTS.md— Agent instructions and guidelinesSOUL.md— Personality and valuesUSER.md— User profile and preferencesTOOLS.md— Tool descriptionsIDENTITY.md— Identity info
-
Memory — Long-term memory from
memory/MEMORY.md, handled automatically by mofa-sdk'sPromptContext. -
Skills — Three-level progressive loading:
- Always loaded: Skills marked as "always on" (metadata only, ~100 words each)
- Requested skills: Full SKILL.md body loaded when triggered
- Skills summary: A listing of all available skills injected so the agent knows what's available
Skills discovery — The SkillsManager scans two directories:
skills/in the project root (built-in)~/.mofaclaw/workspace/skills/(user-created)
It also has a fallback chain to find the builtin skills relative to the executable or CARGO_MANIFEST_DIR.
The SessionManager (core/src/session/) handles conversation persistence using JSONL files.
pub struct SessionManager {
inner: MofaSessionManager, // mofa-sdk's manager
sessions_dir: PathBuf, // ~/.mofaclaw/sessions
}Key features:
- Storage: Each session is a JSONL file at
~/.mofaclaw/sessions/{safe_key}.jsonl - Session keys: Format is
"{channel}:{chat_id}"(e.g.,"telegram:12345","cli:default") - History loading:
get_history_as_messages(max)returns recent messages for LLM context - Structured content: Vision/multi-part messages are prefix-encoded (
"__mofaclaw_content__:") to preserve them across sessions - Metadata: Tracks
created_at,updated_at, andschema_version
API:
sessions.get_or_create("cli:default").await; // Get or create
sessions.save(&session).await; // Persist to disk
sessions.list_sessions().await; // List all with metadata
sessions.delete("cli:old").await; // Delete a sessionThe Config struct (core/src/config.rs) represents the full ~/.mofaclaw/config.json:
pub struct Config {
pub agents: AgentsConfig, // Model, tokens, temperature, iterations
pub channels: ChannelsConfig, // Telegram, DingTalk, Feishu, WhatsApp
pub providers: ProvidersConfig, // API keys and base URLs
pub gateway: GatewayConfig, // Host and port
pub tools: ToolsConfig, // Web search, transcription
}Agent defaults:
| Setting | Default | Description |
|---|---|---|
workspace |
~/.mofaclaw/workspace |
Working directory for the agent |
model |
anthropic/claude-opus-4-5 |
Default LLM model |
max_tokens |
8192 | Max response tokens |
temperature |
0.7 | Sampling temperature |
max_tool_iterations |
20 | Max tool call loops before stopping |
Supported providers (each has api_key and optional api_base):
| Provider | Purpose | Notes |
|---|---|---|
openrouter |
Access to all models | Recommended, single key for Claude/GPT/etc. |
anthropic |
Claude direct | |
openai |
GPT direct | |
gemini |
Gemini direct | |
groq |
LLM + voice transcription | Free Whisper transcription |
zhipu |
GLM models | Default base: https://open.bigmodel.cn/api/paas/v4 |
vllm |
Local models | Any OpenAI-compatible server |
API key resolution (get_api_key()): OpenRouter → Anthropic → OpenAI → Gemini → Zhipu → Groq → vLLM (first non-empty wins).
Channel configs — Each channel has an enabled: bool flag plus platform-specific fields:
- Telegram:
token,allow_from(whitelist of user IDs/usernames) - DingTalk:
client_id,client_secret,robot_code,dm_policy,group_policy,debug - Feishu:
app_id,app_secret,encrypt_key,verification_token,debug - WhatsApp:
bridge_url(defaultws://localhost:3001),allow_from
mofaclaw uses thiserror for a comprehensive error hierarchy (core/src/error.rs):
pub type Result<T> = std::result::Result<T, MofaclawError>;
pub enum MofaclawError {
Config(ConfigError), // NotFound, Parse, Invalid, Missing
Tool(ToolError), // NotFound, ExecutionFailed, InvalidParameters, Timeout
Provider(ProviderError), // RequestFailed, AuthenticationFailed, RateLimitExceeded
Session(SessionError), // LoadFailed, SaveFailed, NotFound
Channel(ChannelError), // NotConfigured, SendFailed, ConnectionFailed
Agent(AgentError), // Stopped, MaxIterationsExceeded, ContextFailed
Io(std::io::Error),
Other(String),
}Each variant carries context. For example, ToolError::ExecutionFailed includes the tool name and error message. ChannelError has Python-specific variants (PythonNotInstalled, PythonVersionTooOld, PythonPackageInstallFailed) for the DingTalk/Feishu bridges.
When you run mofaclaw onboard, it creates a workspace that acts as the agent's working environment:
~/.mofaclaw/
├── config.json # Main configuration
├── workspace/ # Agent workspace
│ ├── AGENTS.md # Agent instructions & guidelines
│ ├── SOUL.md # Personality & values definition
│ ├── USER.md # User profile & preferences
│ ├── TOOLS.md # Tool documentation for the agent
│ ├── IDENTITY.md # Agent identity
│ ├── HEARTBEAT.md # Periodic tasks (checked every 30 min)
│ ├── memory/
│ │ └── MEMORY.md # Long-term facts & preferences
│ ├── skills/ # User-created skills
│ └── cron_jobs.json # Scheduled task definitions
├── sessions/ # Conversation history (JSONL files)
│ ├── cli_default.jsonl
│ ├── telegram_12345.jsonl
│ └── ...
└── media/ # Downloaded images/files from channels
Bootstrap files are loaded into the system prompt every time the agent processes a message. You can customize these to change the agent's behavior:
AGENTS.md— Instructions like "Be concise", "Ask for clarification when ambiguous", "Use tools to help accomplish tasks"SOUL.md— Personality: "I am mofaclaw, a personal AI assistant. Helpful, concise, curious."USER.md— User profile: name, timezone, language, preferencesTOOLS.md— Documents all tools from the agent's perspective (what each tool does, its parameters)HEARTBEAT.md— Tasks the agent checks every 30 minutes (add checklist items here)memory/MEMORY.md— Persistent facts the agent remembers across sessions
mofaclaw includes a powerful Role-Based Access Control (RBAC) system that sandboxes the AI's capabilities. This protects your system from accidental damage or malicious prompt injections when using tools like shell execution or file system access.
The agent's permissions are determined by your RBAC configuration: the global default comes from rbac.default_role, and individual channels can override it via their entries under rbac.role_mappings in config.json (for example, rbac.role_mappings.discord.user_overrides for per-user overrides on Discord). The roles below describe typical recommended configurations; the actual behavior depends on your rbac.permissions.* settings (for example, shell access via rbac.permissions.tools.shell.safe_commands.min_role).
- Guest: Highly restricted. Typically configured with read-only access to specific folders and no shell command permissions, unless
rbac.permissions.tools.shell.safe_commands.min_roleis set to allow it. - Member: Standard access. Often configured as the default role. Can usually read/write to the workspace and run safe commands, depending on your RBAC permission settings.
- Admin: Extended access for managing the system.
- SuperAdmin: Highest-privilege role. Bypasses shell command sandboxing, but is still subject to filesystem path whitelist/blacklist rules unless explicitly allowed there.
mofaclaw provides an AuditLogger component that you can use to record RBAC permission checks. When you wire it into your RBAC or tool execution pipeline, denied accesses can be logged with the exact reason (e.g., RBAC Audit: user=system role=Member resource=rm -rf / operation=safe_commands result=Denied).
When RBAC filesystem permissions are configured, mofaclaw can restrict which paths the AI may read or write.
In that case, for all roles (including superadmin), access is allowed only for paths matching the role's configured path_whitelist; if no such permissions are defined, filesystem access is not restricted by RBAC.
Example configuration in config.json:
"rbac": {
"enabled": true,
"permissions": {
"tools": {
"filesystem": {
"read": {
"min_role": "guest",
"path_whitelist": {
"guest": ["${workspace}/**"],
"member": ["${workspace}/**", "${home}/projects/**"]
}
},
"write": {
"min_role": "member",
"path_whitelist": {
"member": ["${workspace}/**"]
}
}
}
}
}
}Variables like ${workspace} and ${home} are automatically resolved.
Skills are the easiest way to extend mofaclaw — they're just markdown files. No Rust code needed!
A skill is a folder containing a SKILL.md file with YAML frontmatter and markdown instructions. Skills are auto-discovered from two locations:
skills/in the project root (built-in skills)~/.mofaclaw/workspace/skills/(user-created skills)
The ContextBuilder uses SkillsManager to scan these directories. Each skill's name and description from the frontmatter are injected into the system prompt, so the agent knows what skills are available. When a skill is triggered, its full markdown body is loaded into context.
Skills use a three-level loading system to manage context efficiently:
- Metadata (name + description) — Always in context, ~100 words per skill. This is what the agent uses to decide relevance.
- SKILL.md body — Loaded when the skill is triggered. Keep under 500 lines.
- Bundled resources (scripts/, references/, assets/) — Loaded on-demand when the agent decides it needs them.
Here's the relevant code from ContextBuilder.build_system_prompt():
// Skills summary is always included — the agent sees all available skills
let skills_summary = self.skills.build_skills_summary().await;
// The summary tells the agent: "To use a skill, read its SKILL.md
// file using the read_file tool to learn how to use it."Create the file skills/hello/SKILL.md:
---
name: hello
description: Greet users warmly and share fun facts. Use when someone says hello, hi, or asks for a greeting.
---
# Hello Skill
When the user greets you, respond with:
1. A warm, friendly greeting
2. A random fun fact about today's date
3. An encouraging message for their day
Keep responses concise (2-3 sentences max).That's it! The skill will be discovered automatically on the next run. You can verify by starting the agent and asking "What skills do you have?"
my-research-skill/
├── SKILL.md
├── scripts/
│ └── search_papers.py # Deterministic script for searching
├── references/
│ └── databases.md # List of academic databases
└── assets/
└── citation-template.txt # Template for formatting citations
The SKILL.md would reference these:
---
name: research-assistant
description: Help find and summarize academic papers. Use when the user asks about research, papers, citations, or academic topics.
---
# Research Assistant
## Quick Search
Run `scripts/search_papers.py` with the user's query.
## Database Reference
For a list of supported databases, see `references/databases.md`.
## Formatting Citations
Use `assets/citation-template.txt` as a template.- Description is everything — The
descriptionfield is the primary trigger mechanism. Include both what the skill does AND when to use it. - Keep SKILL.md lean — The body is loaded into the LLM's context window. Every token counts. Prefer concise examples over verbose explanations.
- Use references/ for detail — Move large documentation into
references/files and reference them from SKILL.md. The agent will load them only when needed. - Don't duplicate knowledge — The LLM is already smart. Only include domain-specific procedures, API details, or workflows the model can't infer on its own.
Look at existing skills for inspiration:
skills/weather/SKILL.md— Usescurlcommands with wttr.in and Open-Meteo for weather data, no API key required. Good example of a simple, self-contained skill.skills/skill-creator/SKILL.md— A meta-skill that guides creation of new skills. Demonstrates progressive disclosure patterns.skills/summarize/— Summarization skillskills/github/— GitHub-related operations
Tools give the agent the ability to take actions — read files, run commands, search the web, etc. Tools are written in Rust and implement the SimpleTool trait from mofa-sdk.
Every tool implements these methods (defined in mofa-sdk, re-exported via core/src/tools/base.rs):
use mofa_sdk::agent::{SimpleTool, ToolCategory};
use mofa_sdk::kernel::{ToolInput, ToolResult};
#[async_trait]
pub trait SimpleTool: Send + Sync {
/// Unique name for this tool (used in LLM tool calls)
fn name(&self) -> &str;
/// Human-readable description (shown to the LLM so it knows when to use this tool)
fn description(&self) -> &str;
/// JSON Schema for the tool's parameters (tells the LLM what arguments to pass)
fn parameters_schema(&self) -> Value;
/// Execute the tool with the given input and return a result
async fn execute(&self, input: ToolInput) -> ToolResult;
/// Optional: categorize the tool (File, Shell, Web, Custom, etc.)
fn category(&self) -> ToolCategory { ToolCategory::Custom }
}How it connects to the LLM: The ToolRegistry converts all registered tools into OpenAI-compatible function definitions:
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read the contents of a file at the given path.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "The file path to read" }
},
"required": ["path"]
}
}
}When the LLM decides to use a tool, it returns a tool call. mofa-sdk's AgentLoop intercepts it, dispatches to the ToolRegistry, feeds the result back to the LLM, and repeats until the LLM is done.
Create core/src/tools/time.rs:
//! Time tool: get the current date and time
use super::base::{SimpleTool, ToolInput, ToolResult};
use async_trait::async_trait;
use serde_json::{Value, json};
/// Tool to get the current date and time
pub struct CurrentTimeTool;
impl CurrentTimeTool {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl SimpleTool for CurrentTimeTool {
fn name(&self) -> &str {
"current_time"
}
fn description(&self) -> &str {
"Get the current date and time. Returns the current UTC timestamp."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"format": {
"type": "string",
"description": "Output format: 'iso' for ISO 8601, 'human' for readable. Defaults to 'human'."
}
},
"required": []
})
}
async fn execute(&self, input: ToolInput) -> ToolResult {
let format = input.get_str("format").unwrap_or("human");
let now = chrono::Utc::now();
let formatted = match format {
"iso" => now.to_rfc3339(),
_ => now.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
};
ToolResult::success_text(format!("Current time: {}", formatted))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tool_metadata() {
let tool = CurrentTimeTool::new();
assert_eq!(tool.name(), "current_time");
assert!(!tool.description().is_empty());
// Verify schema is valid JSON
let schema = tool.parameters_schema();
assert_eq!(schema["type"], "object");
}
#[tokio::test]
async fn test_execute_default_format() {
let tool = CurrentTimeTool::new();
let input = ToolInput::from_json(json!({}));
let result = tool.execute(input).await;
assert!(result.success);
assert!(result.to_string_output().contains("Current time:"));
}
#[tokio::test]
async fn test_execute_iso_format() {
let tool = CurrentTimeTool::new();
let input = ToolInput::from_json(json!({"format": "iso"}));
let result = tool.execute(input).await;
assert!(result.success);
// ISO 8601 contains 'T'
assert!(result.to_string_output().contains("T"));
}
}Add the module to core/src/tools/mod.rs:
pub mod time; // Add this line
pub use time::CurrentTimeTool; // Add this lineIn core/src/agent/loop_.rs, find the register_default_tools method and add your tool:
// Add the import at the top of loop_.rs:
use crate::tools::time::CurrentTimeTool;
// Then in register_default_tools():
fn register_default_tools(
registry: &mut ToolRegistry,
_workspace: &std::path::Path,
brave_api_key: Option<String>,
bus: MessageBus,
) {
// ... existing tools (file, shell, web, message) ...
// Time tool
registry.register(CurrentTimeTool::new());
}# Run your unit tests first
cargo test -p mofaclaw-core test_tool_metadata
cargo test -p mofaclaw-core test_execute
# Build
cargo build --release
# Test with the agent
cargo run --release -- agent -m "What time is it right now?"Study these files for patterns and best practices:
| File | Tools | What to learn |
|---|---|---|
core/src/tools/filesystem.rs |
read_file, write_file, edit_file, list_dir |
Parameter validation, error handling, tilde expansion |
core/src/tools/shell.rs |
exec |
Command execution with 60s timeout, dangerous command blocking (rm -rf /, mkfs, fork bombs), output truncation at 10,000 chars, cross-platform (sh -c on Unix, cmd /C on Windows) |
core/src/tools/web.rs |
web_search, web_fetch |
External API integration (Brave Search), HTML-to-markdown conversion, content truncation, env var fallback for API keys |
core/src/tools/message.rs |
message |
Interior mutability with Arc<RwLock<>>, callback-based tool design, context injection (set_context()) |
core/src/tools/spawn.rs |
spawn |
Subagent spawning, SubagentManager trait, async background tasks |
Parameter extraction:
// String parameter (returns Option<&str>)
let path = input.get_str("path");
// With validation
let path = match input.get_str("path") {
Some(p) => p,
None => return ToolResult::failure("Missing 'path' parameter"),
};Success and failure:
// Success with text output
ToolResult::success_text("Operation completed successfully")
// Success with formatted output
ToolResult::success_text(format!("Read {} bytes from {}", content.len(), path))
// Failure with error message
ToolResult::failure("Error: File not found")
ToolResult::failure(format!("Error reading file: {}", e))JSON Schema for parameters:
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"required_param": {
"type": "string",
"description": "Clear description for the LLM"
},
"optional_param": {
"type": "integer",
"description": "An optional number with a default"
}
},
"required": ["required_param"] // Only list truly required params
})
}Tool registration flow:
SimpleTool impl → registry.register(tool) → as_tool() wraps it → Arc<dyn Tool>
│
▼
ToolRegistry.get_definitions() ← SimpleToolRegistry
│
▼
JSON sent to LLM as available functions
Channels connect mofaclaw to messaging platforms. Each channel implements the Channel trait and communicates through the MessageBus.
Defined in core/src/channels/base.rs:
#[async_trait]
pub trait Channel: Send + Sync {
/// Get the name of this channel (e.g., "telegram", "dingtalk")
fn name(&self) -> &str;
/// Start the channel (begin receiving messages)
/// This is called by ChannelManager and should block until stopped
async fn start(&self) -> Result<()>;
/// Stop the channel gracefully
async fn stop(&self) -> Result<()>;
/// Check if the channel is enabled in config
fn is_enabled(&self) -> bool;
}External Platform (e.g., Telegram API)
│
│ (webhook / polling / WebSocket)
▼
┌─────────────────────┐
│ YourChannel │
│ │
│ fn start() { │
│ // Listen for │
│ // messages │──────────┐
│ } │ │
│ │ ▼
│ // Convert to │ bus.publish_inbound(
│ // InboundMessage │ InboundMessage::new(
│ │ "your_channel",
│ // Subscribe to │ sender_id,
│ // outbound │ chat_id,
│ // │ content,
│ bus.subscribe_ │ )
│ outbound() │ )
│ │ │
│ ▼ │
│ // Send response │
│ // back to platform│
└─────────────────────┘
The ChannelManager (core/src/channels/manager.rs) orchestrates all channels:
pub struct ChannelManager {
config: ChannelsConfig,
channels: Arc<RwLock<Vec<Arc<dyn Channel>>>>,
running: Arc<RwLock<bool>>,
}It spawns each enabled channel in its own tokio task, with automatic reconnection on errors (5-second retry delay).
Channels are registered in cli/src/main.rs inside the gateway command:
// Create the channel manager
let channel_manager = ChannelManager::new(&config, bus.clone());
// Register each enabled channel
if config.channels.dingtalk.enabled {
let dingtalk = DingTalkChannel::new(config.channels.dingtalk.clone(), bus.clone());
channel_manager.register_channel(Arc::new(dingtalk)).await;
}
if config.channels.telegram.enabled {
let telegram = TelegramChannel::new(config.channels.telegram.clone(), bus.clone())?;
channel_manager.register_channel(Arc::new(telegram)).await;
}
if config.channels.feishu.enabled {
let feishu = FeishuChannel::new(config.channels.feishu.clone(), bus.clone());
channel_manager.register_channel(Arc::new(feishu)).await;
}
// Start all enabled channels concurrently
channel_manager.start_all().await?;Let's walk through adding a hypothetical Slack channel.
In core/src/config.rs, add a config struct:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SlackConfig {
pub enabled: bool,
pub bot_token: String,
pub app_token: String,
pub allow_from: Vec<String>, // Whitelist of Slack user IDs
}
impl Default for SlackConfig {
fn default() -> Self {
Self {
enabled: false,
bot_token: String::new(),
app_token: String::new(),
allow_from: Vec::new(),
}
}
}Add it to ChannelsConfig:
pub struct ChannelsConfig {
pub telegram: TelegramConfig,
pub dingtalk: DingTalkConfig,
pub feishu: FeishuConfig,
pub whatsapp: WhatsAppConfig,
pub slack: SlackConfig, // Add this
}Create core/src/channels/slack.rs:
use super::base::Channel;
use crate::bus::MessageBus;
use crate::config::SlackConfig;
use crate::error::Result;
use crate::messages::{InboundMessage, OutboundMessage};
use async_trait::async_trait;
use std::sync::Arc;
use tokio::sync::RwLock;
pub struct SlackChannel {
config: SlackConfig,
bus: MessageBus,
running: Arc<RwLock<bool>>,
}
impl SlackChannel {
pub fn new(config: SlackConfig, bus: MessageBus) -> Self {
Self {
config,
bus,
running: Arc::new(RwLock::new(false)),
}
}
}
#[async_trait]
impl Channel for SlackChannel {
fn name(&self) -> &str {
"slack"
}
async fn start(&self) -> Result<()> {
*self.running.write().await = true;
// Subscribe to outbound messages for this channel
let mut outbound_rx = self.bus.subscribe_outbound();
// Start a task to handle outbound messages
let running = self.running.clone();
tokio::spawn(async move {
while *running.read().await {
if let Ok(msg) = outbound_rx.recv().await {
if msg.channel == "slack" {
// TODO: Send msg.content to Slack via API
}
}
}
});
// Main loop: listen for incoming Slack messages
while *self.running.read().await {
// TODO: Connect to Slack Socket Mode API
// When a message arrives:
// let inbound = InboundMessage::new(
// "slack",
// &user_id,
// &channel_id,
// &message_text,
// );
// self.bus.publish_inbound(inbound).await?;
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}
Ok(())
}
async fn stop(&self) -> Result<()> {
*self.running.write().await = false;
Ok(())
}
fn is_enabled(&self) -> bool {
self.config.enabled
}
}In core/src/channels/mod.rs:
pub mod slack;
pub use slack::SlackChannel;In cli/src/main.rs, add registration logic:
if config.channels.slack.enabled {
let slack = SlackChannel::new(config.channels.slack.clone(), bus.clone());
channel_manager.register_channel(Arc::new(slack)).await;
println!("Slack: enabled");
}In core/src/channels/manager.rs, add to enabled_channels() and has_enabled_channels():
if self.config.slack.enabled {
enabled.push("slack".to_string());
}| File | Channel | Architecture |
|---|---|---|
core/src/channels/telegram.rs |
Telegram | Uses teloxide crate for bot polling. Handles markdown→HTML conversion, voice transcription (Groq/Whisper), media downloads to ~/.mofaclaw/media/ |
core/src/channels/dingtalk.rs |
DingTalk | Embeds a Python bridge script that uses the dingtalk-stream SDK, communicates via WebSocket on ws://localhost:3002 |
core/src/channels/feishu.rs |
Feishu/Lark | Similar to DingTalk — embedded Python bridge with lark-oapi SDK, WebSocket on ws://localhost:3004 |
core/src/channels/whatsapp.rs |
WebSocket bridge on ws://localhost:3001 |
The mofaclaw gateway command is the full production mode. Understanding its startup sequence helps you see how all the pieces connect:
async fn command_gateway(port: u16, verbose: bool) -> Result<()> {
// 1. Load config from ~/.mofaclaw/config.json
let config = load_config().await?;
// 2. Create the LLM provider (OpenAI-compatible API)
let openai_config = OpenAIConfig::new(&api_key)
.with_model(&model)
.with_base_url(api_base); // Works with OpenRouter, vLLM, etc.
let provider = Arc::new(OpenAIProvider::with_config(openai_config));
// 3. Create core infrastructure
let bus = MessageBus::new();
let sessions = Arc::new(SessionManager::new(&config));
let tools = Arc::new(RwLock::new(ToolRegistry::new()));
// 4. Register tools
AgentLoop::register_default_tools(&mut tools, &workspace, brave_api_key, bus.clone());
// 5. Build the LLMAgent (mofa-sdk)
let llm_agent = LLMAgentBuilder::new()
.with_id("mofaclaw-gateway")
.with_name("Mofaclaw Gateway Agent")
.with_provider(provider.clone())
.with_system_prompt(system_prompt)
.with_tool_executor(tool_executor)
.build_async().await;
// 6. Create the AgentLoop
let agent = AgentLoop::with_agent_and_tools(
&config, llm_agent, provider, bus.clone(), sessions, tools
).await?;
// 7. Register the spawn tool (needs agent reference)
let subagent_manager = SubagentManager::new(agent.clone());
agent.register_spawn_tool(subagent_manager).await;
// 8. Register channels
let channel_manager = ChannelManager::new(&config, bus.clone());
// ... register Telegram, DingTalk, Feishu, WhatsApp if enabled ...
// 9. Start the heartbeat service (every 30 minutes)
let heartbeat = HeartbeatService::new(workspace, 30 * 60)
.with_callback(|prompt| agent.process_direct(&prompt, "heartbeat"));
// 10. Run everything concurrently
tokio::select! {
result = agent.run() => result?,
result = channel_manager.start_all() => result?,
_ = tokio::signal::ctrl_c() => { /* graceful shutdown */ }
}
}Key insight: Everything runs concurrently via tokio::select! — the agent loop, all channels, and the heartbeat service all share the same MessageBus.
The HeartbeatService (core/src/heartbeat/) proactively wakes the agent every 30 minutes to check HEARTBEAT.md:
pub struct HeartbeatService {
workspace: PathBuf,
interval_s: u64, // Default: 1800 (30 min)
on_heartbeat: Option<HeartbeatCallback>,
running: Arc<RwLock<bool>>,
}How it works:
- Every
interval_sseconds, readsHEARTBEAT.mdfrom the workspace. - Skips if the file is empty or only has headers/comments.
- If it has actionable content, sends this prompt to the agent:
"Read HEARTBEAT.md in your workspace. Follow any instructions or tasks listed there." - The agent processes it like a regular message and can use tools (read files, execute commands, etc.).
- If nothing needs attention, the agent responds with
HEARTBEAT_OK.
Use case: Add recurring tasks to HEARTBEAT.md like "Check if the server is up" or "Summarize today's emails".
The CronService (core/src/cron/) handles scheduled tasks with more flexibility:
pub struct CronJob {
pub id: String,
pub name: String,
pub enabled: bool,
pub schedule: CronSchedule,
pub payload: CronPayload,
pub state: CronJobState,
}
pub enum CronSchedule {
At { at_ms: Option<i64> }, // One-time at timestamp
Every { every_ms: Option<u64> }, // Every N milliseconds
Cron { expr: Option<String>, tz: Option<String> }, // Cron expression
}Examples:
# Every day at 9am
mofaclaw cron add --name "morning" --message "Good morning!" --cron "0 9 * * *"
# Every 2 hours
mofaclaw cron add --name "check" --message "Check server status" --every 7200
# One-time at specific time
mofaclaw cron add --name "meeting" --message "Meeting!" --at "2025-01-31T15:00:00"Jobs are stored in ~/.mofaclaw/workspace/cron_jobs.json and can optionally route responses to channels.
# Build in debug mode (faster compilation)
cargo build
# Build in release mode (optimized, for testing performance)
cargo build --release
# Run all tests
cargo test
# Run tests for a specific crate
cargo test -p mofaclaw-core
# Run a specific test by name
cargo test test_channel_trait
# Run tests with output visible
cargo test -- --nocapture
# Check for compiler warnings
cargo clippy
# Format code
cargo fmt# Run with verbose logging
RUST_LOG=debug cargo run -- agent -m "test"
# Or for the gateway with trace-level logging
RUST_LOG=trace cargo run -- gateway --verbose
# See only mofaclaw logs (filter out dependencies)
RUST_LOG=mofaclaw_core=debug cargo run -- agent -m "test"The codebase uses the tracing crate for structured logging:
use tracing::{info, debug, warn, error};
info!("AgentLoop started");
debug!("Processing message from {}", msg.channel);
warn!("Failed to publish outbound message: {}", e);
error!("Channel {} error: {}", name, e);mofaclaw/
├── core/ # Core library crate (mofaclaw-core)
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs # Public API exports
│ ├── config.rs # Configuration types & loading
│ ├── error.rs # Error hierarchy (thiserror)
│ ├── messages.rs # InboundMessage, OutboundMessage
│ ├── types.rs # Message, MessageContent, MessageRole
│ ├── agent/
│ │ ├── mod.rs
│ │ ├── loop_.rs # AgentLoop — the core engine
│ │ ├── context.rs # ContextBuilder — system prompt assembly
│ │ └── subagent.rs # SubagentManager
│ ├── tools/
│ │ ├── mod.rs # Module declarations & re-exports
│ │ ├── base.rs # SimpleTool trait re-exports from mofa-sdk
│ │ ├── registry.rs # ToolRegistry & ToolRegistryExecutor
│ │ ├── filesystem.rs # read_file, write_file, edit_file, list_dir
│ │ ├── shell.rs # exec (shell command execution)
│ │ ├── web.rs # web_search, web_fetch
│ │ ├── message.rs # message (send to user)
│ │ └── spawn.rs # spawn (subagent creation)
│ ├── channels/
│ │ ├── mod.rs
│ │ ├── base.rs # Channel trait
│ │ ├── manager.rs # ChannelManager
│ │ ├── telegram.rs # Telegram (teloxide)
│ │ ├── dingtalk.rs # DingTalk (Python bridge)
│ │ ├── feishu.rs # Feishu/Lark (Python bridge)
│ │ └── whatsapp.rs # WhatsApp (WebSocket bridge)
│ ├── bus/
│ │ ├── mod.rs # MessageBus (tokio broadcast)
│ │ └── queue.rs # Queue utilities
│ ├── session/
│ │ └── mod.rs # SessionManager (JSONL persistence)
│ ├── heartbeat/
│ │ └── service.rs # HeartbeatService
│ └── cron/
│ ├── service.rs # CronService
│ └── types.rs # CronJob, CronSchedule, CronPayload
├── cli/ # CLI binary crate
│ ├── Cargo.toml
│ └── src/
│ └── main.rs # Command handling (clap), gateway startup
├── skills/ # Bundled skills (markdown)
│ ├── weather/SKILL.md
│ ├── skill-creator/SKILL.md
│ ├── summarize/
│ ├── github/
│ └── tmux/
└── workspace/ # Default workspace templates
├── AGENTS.md
├── SOUL.md
├── TOOLS.md
└── ...
| Crate | Purpose |
|---|---|
mofa-sdk |
LLM framework: providers, agents, tools, skills, sessions |
tokio |
Async runtime |
serde / serde_json |
Serialization |
async-trait |
Async trait implementations |
tracing |
Structured logging |
thiserror / anyhow |
Error handling |
reqwest |
HTTP client (for web tools) |
teloxide |
Telegram bot framework |
clap |
CLI argument parsing |
chrono |
Date/time handling |
uuid |
Unique identifiers |
Here are potential project directions for GSoC contributors, ordered roughly by complexity:
-
New Skills — Create domain-specific skills (coding assistant, language tutor, data analysis guide). Pure markdown, great for learning the system.
-
New Tool: Calculator — Implement a
calculatetool for precise math. Good first Rust tool — simpleSimpleToolimplementation witheval-style parsing. -
New Tool: Image Generation — Integrate with DALL-E or Stable Diffusion APIs. Teaches tool development with external API calls.
-
New Channel: Discord — Implement a Discord bot channel using the
serenitycrate. Teaches the Channel trait, MessageBus integration, and async patterns. -
New Channel: Slack — Implement a Slack bot using Socket Mode. Similar to Discord but with a different API surface.
-
Enhanced Session Management — Add session search, export (JSON/markdown), session branching (fork a conversation), or session sharing between channels.
-
Voice Interface — Improve voice transcription (currently Groq/Whisper) and add text-to-speech for spoken responses via the Telegram channel.
-
Multi-Agent Collaboration — Extend the
TaskOrchestratorandSpawnToolto support complex multi-agent workflows: shared context, agent-to-agent communication, task delegation chains. -
Web UI Channel — Build a web-based chat interface as a new channel, using WebSocket communication with the gateway. Include message history, tool call visualization, and skill management.
-
Plugin System — Design a dynamic plugin architecture that allows loading tools and channels at runtime (e.g., via WASM or dynamic libraries) without recompilation.
-
RAG (Retrieval-Augmented Generation) — Add a vector store tool for document embedding and semantic search, enabling the agent to work with large knowledge bases.
- Run
cargo fmtbefore every commit. - Run
cargo clippyand fix all warnings. - Use
async_traitfor async trait implementations. - Keep tools self-contained — one tool per file under
core/src/tools/. - Write doc comments (
///for public items,//!for module-level docs). - Use the
tracingcrate for logging (info!,debug!,warn!,error!). - Follow the existing patterns — look at similar code before writing new code.
Every new tool, channel, or significant feature should have tests:
#[cfg(test)]
mod tests {
use super::*;
// Synchronous tests for metadata/configuration
#[test]
fn test_tool_metadata() {
let tool = MyTool::new();
assert_eq!(tool.name(), "my_tool");
assert!(!tool.description().is_empty());
}
// Async tests for tool execution
#[tokio::test]
async fn test_tool_execute() {
let tool = MyTool::new();
let input = ToolInput::from_json(json!({"param": "value"}));
let result = tool.execute(input).await;
assert!(result.success);
}
// Channel trait tests
#[tokio::test]
async fn test_channel_name() {
let config = MyConfig::default();
let bus = MessageBus::new();
let channel = MyChannel::new(config, bus);
assert_eq!(channel.name(), "my_channel");
}
}# Run all tests
cargo test
# Run tests for core only
cargo test -p mofaclaw-core
# Run a specific test
cargo test test_tool_metadata
# Run tests with output
cargo test -- --nocapture- Fork the repository and create a feature branch (
git checkout -b feature/my-feature). - Write tests for new functionality.
- Run
cargo fmt && cargo clippybefore committing. - Run
cargo testand ensure all tests pass. - Keep PRs focused — one feature or fix per PR.
- Describe what your PR does and why in the PR description.
- Open an issue on GitHub for bugs or feature discussions.
- Check existing skills and tools for patterns before building something new.
- Read
workspace/TOOLS.mdfor how tools are documented from the agent's perspective. - Use
RUST_LOG=debugto see what's happening under the hood. - The mofa-sdk documentation covers the LLM provider, agent, and tool abstractions.
Happy hacking!