Skip to content

Latest commit

 

History

History
1504 lines (1173 loc) · 53.5 KB

File metadata and controls

1504 lines (1173 loc) · 53.5 KB

mofaclaw Tutorial: Getting Started as a Contributor

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

Before starting, make sure you have:

  • Rust 1.85+Install Rust
  • Git — to clone the repository
  • An API keyOpenRouter 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 --version

Build & Run

1. Clone and build

git clone https://github.com/mofaclaw/mofaclaw.git
cd mofaclaw
cargo build --release

2. Initialize

cargo run --release -- onboard

This 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.

3. Configure your API key

Edit ~/.mofaclaw/config.json:

{
  "providers": {
    "openrouter": {
      "apiKey": "sk-or-v1-YOUR_KEY_HERE"
    }
  },
  "agents": {
    "defaults": {
      "model": "anthropic/claude-opus-4-5"
    }
  }
}

4. First chat

# 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

5. CLI commands overview

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

Architecture Overview

Message Flow

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) │             └──────────────────────────────────┘
             └────────────┘

Key Modules

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

Deep Dive: MessageBus

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.

Deep Dive: AgentLoop

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:

  1. run() — Main loop, listens on the bus for InboundMessages
  2. process_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
  3. run_agent_loop() — Delegates to mofa-sdk's AgentLoop which handles:
    • LLM API call
    • Tool call detection and execution via ToolRegistryExecutor
    • Iterative loop: LLM → tool calls → results → LLM → ... until done or max_iterations
  4. Saves the updated session (user message + assistant response)
  5. Returns OutboundMessage to 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).

Deep Dive: ContextBuilder & Skills

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()):

  1. Bootstrap files from the workspace (loaded by mofa-sdk's PromptContextBuilder):

    • AGENTS.md — Agent instructions and guidelines
    • SOUL.md — Personality and values
    • USER.md — User profile and preferences
    • TOOLS.md — Tool descriptions
    • IDENTITY.md — Identity info
  2. Memory — Long-term memory from memory/MEMORY.md, handled automatically by mofa-sdk's PromptContext.

  3. 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.

Deep Dive: Session Management

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, and schema_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 session

Deep Dive: Configuration System

The 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 (default ws://localhost:3001), allow_from

Deep Dive: Error Handling

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.


The Workspace

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, preferences
  • TOOLS.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

Security & Access Control (RBAC)

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.

Roles

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_role is 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.

📋 Audit Logging

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).

🛡️ Filesystem Sandbox (Path Whitelisting)

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.


Hands-on: Add a Skill

Skills are the easiest way to extend mofaclaw — they're just markdown files. No Rust code needed!

What is a skill?

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.

How skills are loaded (progressive disclosure)

Skills use a three-level loading system to manage context efficiently:

  1. Metadata (name + description) — Always in context, ~100 words per skill. This is what the agent uses to decide relevance.
  2. SKILL.md body — Loaded when the skill is triggered. Keep under 500 lines.
  3. 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."

Example: Create a hello skill

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?"

Example: A more advanced skill with resources

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.

Skill writing tips

  • Description is everything — The description field 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.

Reference skills

Look at existing skills for inspiration:

  • skills/weather/SKILL.md — Uses curl commands 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 skill
  • skills/github/ — GitHub-related operations

Hands-on: Add a Tool

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.

The SimpleTool trait

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.

Example: Create a current_time tool

Step 1: Create the file

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"));
    }
}

Step 2: Register the module

Add the module to core/src/tools/mod.rs:

pub mod time;  // Add this line

pub use time::CurrentTimeTool;  // Add this line

Step 3: Register the tool in the agent loop

In 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());
}

Step 4: Build and test

# 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?"

Reference: Existing tools

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

Key patterns for tool implementation

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

Hands-on: Add a Channel

Channels connect mofaclaw to messaging platforms. Each channel implements the Channel trait and communicates through the MessageBus.

The Channel trait

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;
}

How channels interact with the system

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).

Channel registration

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?;

Adding a new channel: step-by-step

Let's walk through adding a hypothetical Slack channel.

Step 1: Add the config

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
}

Step 2: Implement the channel

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
    }
}

Step 3: Register the module

In core/src/channels/mod.rs:

pub mod slack;
pub use slack::SlackChannel;

Step 4: Register in the gateway

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");
}

Step 5: Update the ChannelManager

In core/src/channels/manager.rs, add to enabled_channels() and has_enabled_channels():

if self.config.slack.enabled {
    enabled.push("slack".to_string());
}

Reference implementations

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 WhatsApp WebSocket bridge on ws://localhost:3001

The Gateway: How It All Starts

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.


Heartbeat & Cron Systems

Heartbeat Service

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:

  1. Every interval_s seconds, reads HEARTBEAT.md from the workspace.
  2. Skips if the file is empty or only has headers/comments.
  3. If it has actionable content, sends this prompt to the agent: "Read HEARTBEAT.md in your workspace. Follow any instructions or tasks listed there."
  4. The agent processes it like a regular message and can use tools (read files, execute commands, etc.).
  5. 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".

Cron Service

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.


Development Workflow

Building and testing

# 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

Debugging

# 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);

Project structure for development

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
    └── ...

Key dependencies

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

Project Ideas for GSoC

Here are potential project directions for GSoC contributors, ordered roughly by complexity:

Beginner-friendly

  1. New Skills — Create domain-specific skills (coding assistant, language tutor, data analysis guide). Pure markdown, great for learning the system.

  2. New Tool: Calculator — Implement a calculate tool for precise math. Good first Rust tool — simple SimpleTool implementation with eval-style parsing.

  3. New Tool: Image Generation — Integrate with DALL-E or Stable Diffusion APIs. Teaches tool development with external API calls.

Intermediate

  1. New Channel: Discord — Implement a Discord bot channel using the serenity crate. Teaches the Channel trait, MessageBus integration, and async patterns.

  2. New Channel: Slack — Implement a Slack bot using Socket Mode. Similar to Discord but with a different API surface.

  3. Enhanced Session Management — Add session search, export (JSON/markdown), session branching (fork a conversation), or session sharing between channels.

  4. Voice Interface — Improve voice transcription (currently Groq/Whisper) and add text-to-speech for spoken responses via the Telegram channel.

Advanced

  1. Multi-Agent Collaboration — Extend the TaskOrchestrator and SpawnTool to support complex multi-agent workflows: shared context, agent-to-agent communication, task delegation chains.

  2. 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.

  3. Plugin System — Design a dynamic plugin architecture that allows loading tools and channels at runtime (e.g., via WASM or dynamic libraries) without recompilation.

  4. RAG (Retrieval-Augmented Generation) — Add a vector store tool for document embedding and semantic search, enabling the agent to work with large knowledge bases.


Contributing Guidelines

Code style

  • Run cargo fmt before every commit.
  • Run cargo clippy and fix all warnings.
  • Use async_trait for 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 tracing crate for logging (info!, debug!, warn!, error!).
  • Follow the existing patterns — look at similar code before writing new code.

Writing tests

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

Submitting a PR

  1. Fork the repository and create a feature branch (git checkout -b feature/my-feature).
  2. Write tests for new functionality.
  3. Run cargo fmt && cargo clippy before committing.
  4. Run cargo test and ensure all tests pass.
  5. Keep PRs focused — one feature or fix per PR.
  6. Describe what your PR does and why in the PR description.

Getting help

  • Open an issue on GitHub for bugs or feature discussions.
  • Check existing skills and tools for patterns before building something new.
  • Read workspace/TOOLS.md for how tools are documented from the agent's perspective.
  • Use RUST_LOG=debug to see what's happening under the hood.
  • The mofa-sdk documentation covers the LLM provider, agent, and tool abstractions.

Happy hacking!