diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..24bdacc
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 nanobot contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/README.md b/README.md
index 81336bc..92b3646 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,285 @@
-# nanobot
-"nanobot: Extremely Simple and Lightweight Clawdbot Implementation"
+
+

+
nanobot: Your Lightweight Personal AI Assistant
+
+
+
+
+
+
+
+π **nanobot** is an **ultra-lightweight** personal AI assistant inspired by [Clawdbot](https://github.com/openclaw/openclaw)
+
+β‘οΈ Delivers core agent functionality in just **~4,000** lines of code β **99% smaller** than Clawdbot's 430k+ lines.
+
+## π’ News
+
+- **2025-02-01** π nanobot launched! Welcome to try π nanobot!
+
+## Key Features of nanobot:
+
+β‘οΈ **Ultra-Lightweight**: At just ~4,000 lines of code, nanobot is 99% smaller than comparable AI assistants while delivering full functionality.
+
+π¬ **Research-Ready**: Clean, readable architecture makes it perfect for researchers and developers who need to understand, modify, or extend the codebase.
+
+β‘οΈ **Lightning Fast**: Minimal footprint means faster startup, lower resource usage, and quicker iteration cycles.
+
+## ποΈ Architecture
+
+
+
+
+
+## β¨ Features
+
+
+
+ π Web Search |
+ π» Code & Files |
+ π
Scheduled Tasks |
+ π§ Memory |
+
+
+ 
|
+ 
|
+ 
|
+ 
|
+
+
+ | Search real-time info |
+ Read, write, execute |
+ Cron jobs & reminders |
+ Remember context |
+
+
+
+## π¦ Install
+
+**Install from PyPi**
+
+```bash
+pip install nanobot-ai
+```
+
+**Install from source** (recommended for development)
+
+```bash
+git clone https://github.com/HKUDS/nanobot.git
+cd nanobot
+pip install -e .
+```
+
+## π Quick Start
+
+> [!TIP]
+> Set your API key in `~/.nanobot/config.json`. Get one at [OpenRouter](https://openrouter.ai/keys).
+
+**1. Initialize**
+
+```bash
+nanobot onboard
+```
+
+**2. Configure** (`~/.nanobot/config.json`)
+
+```json
+{
+ "providers": {
+ "openrouter": {
+ "apiKey": "sk-or-v1-xxx"
+ }
+ }
+}
+```
+
+**3. Chat**
+
+```bash
+nanobot agent -m "What is 2+2?"
+```
+
+That's it! You have a working AI assistant in 2 minutes.
+
+## π¬ Chat Apps
+
+Talk to your nanobot through Telegram or WhatsApp β anytime, anywhere.
+
+| Channel | Setup |
+|---------|-------|
+| **Telegram** | Easy (just a token) |
+| **WhatsApp** | Medium (scan QR) |
+
+
+Telegram (Recommended)
+
+**1. Create a bot**
+- Open Telegram, search `@BotFather`
+- Send `/newbot`, follow prompts
+- Copy the token
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allowFrom": ["YOUR_USER_ID"]
+ }
+ }
+}
+```
+
+> Get your user ID from `@userinfobot` on Telegram.
+
+**3. Run**
+
+```bash
+nanobot gateway
+```
+
+
+
+
+WhatsApp
+
+Requires **Node.js β₯18**.
+
+**1. Link device**
+
+```bash
+nanobot channels login
+# Scan QR with WhatsApp β Settings β Linked Devices
+```
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "whatsapp": {
+ "enabled": true,
+ "allowFrom": ["+1234567890"]
+ }
+ }
+}
+```
+
+**3. Run** (two terminals)
+
+```bash
+# Terminal 1
+nanobot channels login
+
+# Terminal 2
+nanobot gateway
+```
+
+
+
+## βοΈ Configuration
+
+
+Full config example
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "model": "anthropic/claude-opus-4-5"
+ }
+ },
+ "providers": {
+ "openrouter": {
+ "apiKey": "sk-or-v1-xxx"
+ }
+ },
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "123456:ABC...",
+ "allowFrom": ["123456789"]
+ },
+ "whatsapp": {
+ "enabled": false
+ }
+ },
+ "tools": {
+ "web": {
+ "search": {
+ "apiKey": "BSA..."
+ }
+ }
+ }
+}
+```
+
+
+
+**API Keys:**
+- **OpenRouter**: https://openrouter.ai/keys
+- **Brave Search** (optional): https://brave.com/search/api/
+
+## CLI Reference
+
+| Command | Description |
+|---------|-------------|
+| `nanobot onboard` | Initialize config & workspace |
+| `nanobot agent -m "..."` | Chat with the agent |
+| `nanobot agent` | Interactive chat mode |
+| `nanobot gateway` | Start the gateway |
+| `nanobot status` | Show status |
+| `nanobot channels login` | Link WhatsApp (scan QR) |
+| `nanobot channels status` | Show channel status |
+
+
+Scheduled Tasks (Cron)
+
+```bash
+# Add a job
+nanobot cron add --name "daily" --message "Good morning!" --cron "0 9 * * *"
+nanobot cron add --name "hourly" --message "Check status" --every 3600
+
+# List jobs
+nanobot cron list
+
+# Remove a job
+nanobot cron remove
+```
+
+
+
+## π Project Structure
+
+```
+nanobot/
+βββ agent/ # π§ Core agent logic
+β βββ loop.py # Agent loop (LLM β tool execution)
+β βββ context.py # Prompt builder
+β βββ memory.py # Persistent memory
+β βββ skills.py # Skills loader
+β βββ tools/ # Built-in tools
+βββ skills/ # π― Bundled skills (github, weather, tmux...)
+βββ channels/ # π± Telegram, WhatsApp
+βββ bus/ # π Message routing
+βββ cron/ # β° Scheduled tasks
+βββ providers/ # π€ LLM providers (OpenRouter, etc.)
+βββ session/ # π¬ Conversation sessions
+βββ config/ # βοΈ Configuration
+βββ cli/ # π₯οΈ Commands
+```
+
+## πΊοΈ Roadmap
+
+- [ ] **Multi-modal** β See and hear (images, voice, video)
+- [ ] **Long-term memory** β Never forget important context
+- [ ] **Better reasoning** β Multi-step planning and reflection
+- [ ] **More integrations** β Discord, Slack, email, calendar
+- [ ] **Self-improvement** β Learn from feedback and mistakes
+
+**Want to help?** Pick an item and [open a PR](https://github.com/HKUDS/nanobot/pulls)!
+
+## π€ Contribute
+
+PRs welcome! The codebase is intentionally small and readable.
diff --git a/bridge/package.json b/bridge/package.json
new file mode 100644
index 0000000..e29fed8
--- /dev/null
+++ b/bridge/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "nanobot-whatsapp-bridge",
+ "version": "0.1.0",
+ "description": "WhatsApp bridge for nanobot using Baileys",
+ "type": "module",
+ "main": "dist/index.js",
+ "scripts": {
+ "build": "tsc",
+ "start": "node dist/index.js",
+ "dev": "tsc && node dist/index.js"
+ },
+ "dependencies": {
+ "@whiskeysockets/baileys": "7.0.0-rc.9",
+ "ws": "^8.17.0",
+ "qrcode-terminal": "^0.12.0",
+ "pino": "^9.0.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20.14.0",
+ "@types/ws": "^8.5.10",
+ "typescript": "^5.4.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+}
diff --git a/bridge/src/index.ts b/bridge/src/index.ts
new file mode 100644
index 0000000..8db63ef
--- /dev/null
+++ b/bridge/src/index.ts
@@ -0,0 +1,50 @@
+#!/usr/bin/env node
+/**
+ * nanobot WhatsApp Bridge
+ *
+ * This bridge connects WhatsApp Web to nanobot's Python backend
+ * via WebSocket. It handles authentication, message forwarding,
+ * and reconnection logic.
+ *
+ * Usage:
+ * npm run build && npm start
+ *
+ * Or with custom settings:
+ * BRIDGE_PORT=3001 AUTH_DIR=~/.nanobot/whatsapp npm start
+ */
+
+// Polyfill crypto for Baileys in ESM
+import { webcrypto } from 'crypto';
+if (!globalThis.crypto) {
+ (globalThis as any).crypto = webcrypto;
+}
+
+import { BridgeServer } from './server.js';
+import { homedir } from 'os';
+import { join } from 'path';
+
+const PORT = parseInt(process.env.BRIDGE_PORT || '3001', 10);
+const AUTH_DIR = process.env.AUTH_DIR || join(homedir(), '.nanobot', 'whatsapp-auth');
+
+console.log('π nanobot WhatsApp Bridge');
+console.log('========================\n');
+
+const server = new BridgeServer(PORT, AUTH_DIR);
+
+// Handle graceful shutdown
+process.on('SIGINT', async () => {
+ console.log('\n\nShutting down...');
+ await server.stop();
+ process.exit(0);
+});
+
+process.on('SIGTERM', async () => {
+ await server.stop();
+ process.exit(0);
+});
+
+// Start the server
+server.start().catch((error) => {
+ console.error('Failed to start bridge:', error);
+ process.exit(1);
+});
diff --git a/bridge/src/server.ts b/bridge/src/server.ts
new file mode 100644
index 0000000..c6fd599
--- /dev/null
+++ b/bridge/src/server.ts
@@ -0,0 +1,104 @@
+/**
+ * WebSocket server for Python-Node.js bridge communication.
+ */
+
+import { WebSocketServer, WebSocket } from 'ws';
+import { WhatsAppClient, InboundMessage } from './whatsapp.js';
+
+interface SendCommand {
+ type: 'send';
+ to: string;
+ text: string;
+}
+
+interface BridgeMessage {
+ type: 'message' | 'status' | 'qr' | 'error';
+ [key: string]: unknown;
+}
+
+export class BridgeServer {
+ private wss: WebSocketServer | null = null;
+ private wa: WhatsAppClient | null = null;
+ private clients: Set = new Set();
+
+ constructor(private port: number, private authDir: string) {}
+
+ async start(): Promise {
+ // Create WebSocket server
+ this.wss = new WebSocketServer({ port: this.port });
+ console.log(`π Bridge server listening on ws://localhost:${this.port}`);
+
+ // Initialize WhatsApp client
+ this.wa = new WhatsAppClient({
+ authDir: this.authDir,
+ onMessage: (msg) => this.broadcast({ type: 'message', ...msg }),
+ onQR: (qr) => this.broadcast({ type: 'qr', qr }),
+ onStatus: (status) => this.broadcast({ type: 'status', status }),
+ });
+
+ // Handle WebSocket connections
+ this.wss.on('connection', (ws) => {
+ console.log('π Python client connected');
+ this.clients.add(ws);
+
+ ws.on('message', async (data) => {
+ try {
+ const cmd = JSON.parse(data.toString()) as SendCommand;
+ await this.handleCommand(cmd);
+ ws.send(JSON.stringify({ type: 'sent', to: cmd.to }));
+ } catch (error) {
+ console.error('Error handling command:', error);
+ ws.send(JSON.stringify({ type: 'error', error: String(error) }));
+ }
+ });
+
+ ws.on('close', () => {
+ console.log('π Python client disconnected');
+ this.clients.delete(ws);
+ });
+
+ ws.on('error', (error) => {
+ console.error('WebSocket error:', error);
+ this.clients.delete(ws);
+ });
+ });
+
+ // Connect to WhatsApp
+ await this.wa.connect();
+ }
+
+ private async handleCommand(cmd: SendCommand): Promise {
+ if (cmd.type === 'send' && this.wa) {
+ await this.wa.sendMessage(cmd.to, cmd.text);
+ }
+ }
+
+ private broadcast(msg: BridgeMessage): void {
+ const data = JSON.stringify(msg);
+ for (const client of this.clients) {
+ if (client.readyState === WebSocket.OPEN) {
+ client.send(data);
+ }
+ }
+ }
+
+ async stop(): Promise {
+ // Close all client connections
+ for (const client of this.clients) {
+ client.close();
+ }
+ this.clients.clear();
+
+ // Close WebSocket server
+ if (this.wss) {
+ this.wss.close();
+ this.wss = null;
+ }
+
+ // Disconnect WhatsApp
+ if (this.wa) {
+ await this.wa.disconnect();
+ this.wa = null;
+ }
+ }
+}
diff --git a/bridge/src/types.d.ts b/bridge/src/types.d.ts
new file mode 100644
index 0000000..3aeb18b
--- /dev/null
+++ b/bridge/src/types.d.ts
@@ -0,0 +1,3 @@
+declare module 'qrcode-terminal' {
+ export function generate(text: string, options?: { small?: boolean }): void;
+}
diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts
new file mode 100644
index 0000000..4185632
--- /dev/null
+++ b/bridge/src/whatsapp.ts
@@ -0,0 +1,180 @@
+/**
+ * WhatsApp client wrapper using Baileys.
+ * Based on OpenClaw's working implementation.
+ */
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import makeWASocket, {
+ DisconnectReason,
+ useMultiFileAuthState,
+ fetchLatestBaileysVersion,
+ makeCacheableSignalKeyStore,
+} from '@whiskeysockets/baileys';
+
+import { Boom } from '@hapi/boom';
+import qrcode from 'qrcode-terminal';
+import pino from 'pino';
+
+const VERSION = '0.1.0';
+
+export interface InboundMessage {
+ id: string;
+ sender: string;
+ content: string;
+ timestamp: number;
+ isGroup: boolean;
+}
+
+export interface WhatsAppClientOptions {
+ authDir: string;
+ onMessage: (msg: InboundMessage) => void;
+ onQR: (qr: string) => void;
+ onStatus: (status: string) => void;
+}
+
+export class WhatsAppClient {
+ private sock: any = null;
+ private options: WhatsAppClientOptions;
+ private reconnecting = false;
+
+ constructor(options: WhatsAppClientOptions) {
+ this.options = options;
+ }
+
+ async connect(): Promise {
+ const logger = pino({ level: 'silent' });
+ const { state, saveCreds } = await useMultiFileAuthState(this.options.authDir);
+ const { version } = await fetchLatestBaileysVersion();
+
+ console.log(`Using Baileys version: ${version.join('.')}`);
+
+ // Create socket following OpenClaw's pattern
+ this.sock = makeWASocket({
+ auth: {
+ creds: state.creds,
+ keys: makeCacheableSignalKeyStore(state.keys, logger),
+ },
+ version,
+ logger,
+ printQRInTerminal: false,
+ browser: ['nanobot', 'cli', VERSION],
+ syncFullHistory: false,
+ markOnlineOnConnect: false,
+ });
+
+ // Handle WebSocket errors
+ if (this.sock.ws && typeof this.sock.ws.on === 'function') {
+ this.sock.ws.on('error', (err: Error) => {
+ console.error('WebSocket error:', err.message);
+ });
+ }
+
+ // Handle connection updates
+ this.sock.ev.on('connection.update', async (update: any) => {
+ const { connection, lastDisconnect, qr } = update;
+
+ if (qr) {
+ // Display QR code in terminal
+ console.log('\nπ± Scan this QR code with WhatsApp (Linked Devices):\n');
+ qrcode.generate(qr, { small: true });
+ this.options.onQR(qr);
+ }
+
+ if (connection === 'close') {
+ const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode;
+ const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
+
+ console.log(`Connection closed. Status: ${statusCode}, Will reconnect: ${shouldReconnect}`);
+ this.options.onStatus('disconnected');
+
+ if (shouldReconnect && !this.reconnecting) {
+ this.reconnecting = true;
+ console.log('Reconnecting in 5 seconds...');
+ setTimeout(() => {
+ this.reconnecting = false;
+ this.connect();
+ }, 5000);
+ }
+ } else if (connection === 'open') {
+ console.log('β
Connected to WhatsApp');
+ this.options.onStatus('connected');
+ }
+ });
+
+ // Save credentials on update
+ this.sock.ev.on('creds.update', saveCreds);
+
+ // Handle incoming messages
+ this.sock.ev.on('messages.upsert', async ({ messages, type }: { messages: any[]; type: string }) => {
+ if (type !== 'notify') return;
+
+ for (const msg of messages) {
+ // Skip own messages
+ if (msg.key.fromMe) continue;
+
+ // Skip status updates
+ if (msg.key.remoteJid === 'status@broadcast') continue;
+
+ const content = this.extractMessageContent(msg);
+ if (!content) continue;
+
+ const isGroup = msg.key.remoteJid?.endsWith('@g.us') || false;
+
+ this.options.onMessage({
+ id: msg.key.id || '',
+ sender: msg.key.remoteJid || '',
+ content,
+ timestamp: msg.messageTimestamp as number,
+ isGroup,
+ });
+ }
+ });
+ }
+
+ private extractMessageContent(msg: any): string | null {
+ const message = msg.message;
+ if (!message) return null;
+
+ // Text message
+ if (message.conversation) {
+ return message.conversation;
+ }
+
+ // Extended text (reply, link preview)
+ if (message.extendedTextMessage?.text) {
+ return message.extendedTextMessage.text;
+ }
+
+ // Image with caption
+ if (message.imageMessage?.caption) {
+ return `[Image] ${message.imageMessage.caption}`;
+ }
+
+ // Video with caption
+ if (message.videoMessage?.caption) {
+ return `[Video] ${message.videoMessage.caption}`;
+ }
+
+ // Document with caption
+ if (message.documentMessage?.caption) {
+ return `[Document] ${message.documentMessage.caption}`;
+ }
+
+ return null;
+ }
+
+ async sendMessage(to: string, text: string): Promise {
+ if (!this.sock) {
+ throw new Error('Not connected');
+ }
+
+ await this.sock.sendMessage(to, { text });
+ }
+
+ async disconnect(): Promise {
+ if (this.sock) {
+ this.sock.end(undefined);
+ this.sock = null;
+ }
+ }
+}
diff --git a/bridge/tsconfig.json b/bridge/tsconfig.json
new file mode 100644
index 0000000..7f472b2
--- /dev/null
+++ b/bridge/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "node",
+ "esModuleInterop": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "declaration": true,
+ "resolveJsonModule": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/case/code.gif b/case/code.gif
new file mode 100644
index 0000000..159dad8
Binary files /dev/null and b/case/code.gif differ
diff --git a/case/memory.gif b/case/memory.gif
new file mode 100644
index 0000000..fc91f55
Binary files /dev/null and b/case/memory.gif differ
diff --git a/case/scedule.gif b/case/scedule.gif
new file mode 100644
index 0000000..a2e3073
Binary files /dev/null and b/case/scedule.gif differ
diff --git a/case/search.gif b/case/search.gif
new file mode 100644
index 0000000..fd3d067
Binary files /dev/null and b/case/search.gif differ
diff --git a/nanobot/__init__.py b/nanobot/__init__.py
new file mode 100644
index 0000000..ee0445b
--- /dev/null
+++ b/nanobot/__init__.py
@@ -0,0 +1,6 @@
+"""
+nanobot - A lightweight AI agent framework
+"""
+
+__version__ = "0.1.0"
+__logo__ = "π"
diff --git a/nanobot/__main__.py b/nanobot/__main__.py
new file mode 100644
index 0000000..c7f5620
--- /dev/null
+++ b/nanobot/__main__.py
@@ -0,0 +1,8 @@
+"""
+Entry point for running nanobot as a module: python -m nanobot
+"""
+
+from nanobot.cli.commands import app
+
+if __name__ == "__main__":
+ app()
diff --git a/nanobot/agent/__init__.py b/nanobot/agent/__init__.py
new file mode 100644
index 0000000..c3fc97b
--- /dev/null
+++ b/nanobot/agent/__init__.py
@@ -0,0 +1,8 @@
+"""Agent core module."""
+
+from nanobot.agent.loop import AgentLoop
+from nanobot.agent.context import ContextBuilder
+from nanobot.agent.memory import MemoryStore
+from nanobot.agent.skills import SkillsLoader
+
+__all__ = ["AgentLoop", "ContextBuilder", "MemoryStore", "SkillsLoader"]
diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py
new file mode 100644
index 0000000..5383c35
--- /dev/null
+++ b/nanobot/agent/context.py
@@ -0,0 +1,196 @@
+"""Context builder for assembling agent prompts."""
+
+from pathlib import Path
+from typing import Any
+
+from nanobot.agent.memory import MemoryStore
+from nanobot.agent.skills import SkillsLoader
+
+
+class ContextBuilder:
+ """
+ Builds the context (system prompt + messages) for the agent.
+
+ Assembles bootstrap files, memory, skills, and conversation history
+ into a coherent prompt for the LLM.
+ """
+
+ BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"]
+
+ def __init__(self, workspace: Path):
+ self.workspace = workspace
+ self.memory = MemoryStore(workspace)
+ self.skills = SkillsLoader(workspace)
+
+ def build_system_prompt(self, skill_names: list[str] | None = None) -> str:
+ """
+ Build the system prompt from bootstrap files, memory, and skills.
+
+ Args:
+ skill_names: Optional list of skills to include.
+
+ Returns:
+ Complete system prompt.
+ """
+ parts = []
+
+ # Core identity
+ parts.append(self._get_identity())
+
+ # Bootstrap files
+ bootstrap = self._load_bootstrap_files()
+ if bootstrap:
+ parts.append(bootstrap)
+
+ # Memory context
+ memory = self.memory.get_memory_context()
+ if memory:
+ parts.append(f"# Memory\n\n{memory}")
+
+ # Skills - progressive loading
+ # 1. Always-loaded skills: include full content
+ always_skills = self.skills.get_always_skills()
+ if always_skills:
+ always_content = self.skills.load_skills_for_context(always_skills)
+ if always_content:
+ parts.append(f"# Active Skills\n\n{always_content}")
+
+ # 2. Available skills: only show summary (agent uses read_file to load)
+ skills_summary = self.skills.build_skills_summary()
+ if skills_summary:
+ parts.append(f"""# Skills
+
+The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool.
+Skills with available="false" need dependencies installed first - you can try installing them with apt/brew.
+
+{skills_summary}""")
+
+ return "\n\n---\n\n".join(parts)
+
+ def _get_identity(self) -> str:
+ """Get the core identity section."""
+ from datetime import datetime
+ now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
+ workspace_path = str(self.workspace.expanduser().resolve())
+
+ return f"""# nanobot π
+
+You are nanobot, a helpful AI assistant. You have access to tools that allow you to:
+- Read, write, and edit files
+- Execute shell commands
+- Search the web and fetch web pages
+- Send messages to users on chat channels
+
+## Current Time
+{now}
+
+## Workspace
+Your workspace is at: {workspace_path}
+- Memory files: {workspace_path}/memory/MEMORY.md
+- Daily notes: {workspace_path}/memory/YYYY-MM-DD.md
+- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
+
+IMPORTANT: When responding to direct questions or conversations, reply directly with your text response.
+Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp).
+For normal conversation, just respond with text - do not call the message tool.
+
+Always be helpful, accurate, and concise. When using tools, explain what you're doing.
+When remembering something, write to {workspace_path}/memory/MEMORY.md"""
+
+ def _load_bootstrap_files(self) -> str:
+ """Load all bootstrap files from workspace."""
+ parts = []
+
+ for filename in self.BOOTSTRAP_FILES:
+ file_path = self.workspace / filename
+ if file_path.exists():
+ content = file_path.read_text(encoding="utf-8")
+ parts.append(f"## {filename}\n\n{content}")
+
+ return "\n\n".join(parts) if parts else ""
+
+ def build_messages(
+ self,
+ history: list[dict[str, Any]],
+ current_message: str,
+ skill_names: list[str] | None = None
+ ) -> list[dict[str, Any]]:
+ """
+ Build the complete message list for an LLM call.
+
+ Args:
+ history: Previous conversation messages.
+ current_message: The new user message.
+ skill_names: Optional skills to include.
+
+ Returns:
+ List of messages including system prompt.
+ """
+ messages = []
+
+ # System prompt
+ system_prompt = self.build_system_prompt(skill_names)
+ messages.append({"role": "system", "content": system_prompt})
+
+ # History
+ messages.extend(history)
+
+ # Current message
+ messages.append({"role": "user", "content": current_message})
+
+ return messages
+
+ def add_tool_result(
+ self,
+ messages: list[dict[str, Any]],
+ tool_call_id: str,
+ tool_name: str,
+ result: str
+ ) -> list[dict[str, Any]]:
+ """
+ Add a tool result to the message list.
+
+ Args:
+ messages: Current message list.
+ tool_call_id: ID of the tool call.
+ tool_name: Name of the tool.
+ result: Tool execution result.
+
+ Returns:
+ Updated message list.
+ """
+ messages.append({
+ "role": "tool",
+ "tool_call_id": tool_call_id,
+ "name": tool_name,
+ "content": result
+ })
+ return messages
+
+ def add_assistant_message(
+ self,
+ messages: list[dict[str, Any]],
+ content: str | None,
+ tool_calls: list[dict[str, Any]] | None = None
+ ) -> list[dict[str, Any]]:
+ """
+ Add an assistant message to the message list.
+
+ Args:
+ messages: Current message list.
+ content: Message content.
+ tool_calls: Optional tool calls.
+
+ Returns:
+ Updated message list.
+ """
+ msg: dict[str, Any] = {"role": "assistant"}
+
+ if content:
+ msg["content"] = content
+
+ if tool_calls:
+ msg["tool_calls"] = tool_calls
+
+ messages.append(msg)
+ return messages
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
new file mode 100644
index 0000000..4ad76a6
--- /dev/null
+++ b/nanobot/agent/loop.py
@@ -0,0 +1,213 @@
+"""Agent loop: the core processing engine."""
+
+import asyncio
+import json
+from pathlib import Path
+from typing import Any
+
+from loguru import logger
+
+from nanobot.bus.events import InboundMessage, OutboundMessage
+from nanobot.bus.queue import MessageBus
+from nanobot.providers.base import LLMProvider
+from nanobot.agent.context import ContextBuilder
+from nanobot.agent.tools.registry import ToolRegistry
+from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool
+from nanobot.agent.tools.shell import ExecTool
+from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
+from nanobot.agent.tools.message import MessageTool
+from nanobot.session.manager import SessionManager
+
+
+class AgentLoop:
+ """
+ The agent loop is the core processing engine.
+
+ It:
+ 1. Receives messages from the bus
+ 2. Builds context with history, memory, skills
+ 3. Calls the LLM
+ 4. Executes tool calls
+ 5. Sends responses back
+ """
+
+ def __init__(
+ self,
+ bus: MessageBus,
+ provider: LLMProvider,
+ workspace: Path,
+ model: str | None = None,
+ max_iterations: int = 20,
+ brave_api_key: str | None = None
+ ):
+ self.bus = bus
+ self.provider = provider
+ self.workspace = workspace
+ self.model = model or provider.get_default_model()
+ self.max_iterations = max_iterations
+ self.brave_api_key = brave_api_key
+
+ self.context = ContextBuilder(workspace)
+ self.sessions = SessionManager(workspace)
+ self.tools = ToolRegistry()
+
+ self._running = False
+ self._register_default_tools()
+
+ def _register_default_tools(self) -> None:
+ """Register the default set of tools."""
+ # File tools
+ self.tools.register(ReadFileTool())
+ self.tools.register(WriteFileTool())
+ self.tools.register(EditFileTool())
+ self.tools.register(ListDirTool())
+
+ # Shell tool
+ self.tools.register(ExecTool(working_dir=str(self.workspace)))
+
+ # Web tools
+ self.tools.register(WebSearchTool(api_key=self.brave_api_key))
+ self.tools.register(WebFetchTool())
+
+ # Message tool
+ message_tool = MessageTool(send_callback=self.bus.publish_outbound)
+ self.tools.register(message_tool)
+
+ async def run(self) -> None:
+ """Run the agent loop, processing messages from the bus."""
+ self._running = True
+ logger.info("Agent loop started")
+
+ while self._running:
+ try:
+ # Wait for next message
+ msg = await asyncio.wait_for(
+ self.bus.consume_inbound(),
+ timeout=1.0
+ )
+
+ # Process it
+ try:
+ response = await self._process_message(msg)
+ if response:
+ await self.bus.publish_outbound(response)
+ except Exception as e:
+ logger.error(f"Error processing message: {e}")
+ # Send error response
+ await self.bus.publish_outbound(OutboundMessage(
+ channel=msg.channel,
+ chat_id=msg.chat_id,
+ content=f"Sorry, I encountered an error: {str(e)}"
+ ))
+ except asyncio.TimeoutError:
+ continue
+
+ def stop(self) -> None:
+ """Stop the agent loop."""
+ self._running = False
+ logger.info("Agent loop stopping")
+
+ async def _process_message(self, msg: InboundMessage) -> OutboundMessage | None:
+ """
+ Process a single inbound message.
+
+ Args:
+ msg: The inbound message to process.
+
+ Returns:
+ The response message, or None if no response needed.
+ """
+ logger.info(f"Processing message from {msg.channel}:{msg.sender_id}")
+
+ # Get or create session
+ session = self.sessions.get_or_create(msg.session_key)
+
+ # Update message tool context
+ message_tool = self.tools.get("message")
+ if isinstance(message_tool, MessageTool):
+ message_tool.set_context(msg.channel, msg.chat_id)
+
+ # Build initial messages (use get_history for LLM-formatted messages)
+ messages = self.context.build_messages(
+ history=session.get_history(),
+ current_message=msg.content
+ )
+
+ # Agent loop
+ iteration = 0
+ final_content = None
+
+ while iteration < self.max_iterations:
+ iteration += 1
+
+ # Call LLM
+ response = await self.provider.chat(
+ messages=messages,
+ tools=self.tools.get_definitions(),
+ model=self.model
+ )
+
+ # Handle tool calls
+ if response.has_tool_calls:
+ # Add assistant message with tool calls
+ tool_call_dicts = [
+ {
+ "id": tc.id,
+ "type": "function",
+ "function": {
+ "name": tc.name,
+ "arguments": json.dumps(tc.arguments) # Must be JSON string
+ }
+ }
+ for tc in response.tool_calls
+ ]
+ messages = self.context.add_assistant_message(
+ messages, response.content, tool_call_dicts
+ )
+
+ # Execute tools
+ for tool_call in response.tool_calls:
+ logger.debug(f"Executing tool: {tool_call.name}")
+ result = await self.tools.execute(tool_call.name, tool_call.arguments)
+ messages = self.context.add_tool_result(
+ messages, tool_call.id, tool_call.name, result
+ )
+ else:
+ # No tool calls, we're done
+ final_content = response.content
+ break
+
+ if final_content is None:
+ final_content = "I've completed processing but have no response to give."
+
+ # Save to session
+ session.add_message("user", msg.content)
+ session.add_message("assistant", final_content)
+ self.sessions.save(session)
+
+ return OutboundMessage(
+ channel=msg.channel,
+ chat_id=msg.chat_id,
+ content=final_content
+ )
+
+ async def process_direct(self, content: str, session_key: str = "cli:direct") -> str:
+ """
+ Process a message directly (for CLI usage).
+
+ Args:
+ content: The message content.
+ session_key: Session identifier.
+
+ Returns:
+ The agent's response.
+ """
+ msg = InboundMessage(
+ channel="cli",
+ sender_id="user",
+ chat_id="direct",
+ content=content
+ )
+
+ response = await self._process_message(msg)
+ return response.content if response else ""
diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py
new file mode 100644
index 0000000..58c079b
--- /dev/null
+++ b/nanobot/agent/memory.py
@@ -0,0 +1,110 @@
+"""Memory system for persistent agent memory."""
+
+from pathlib import Path
+from datetime import datetime
+
+from nanobot.utils.helpers import ensure_dir, today_date
+
+
+class MemoryStore:
+ """
+ Memory system for the agent.
+
+ Supports daily notes (memory/YYYY-MM-DD.md) and long-term memory (MEMORY.md).
+ Compatible with clawbot memory format.
+ """
+
+ def __init__(self, workspace: Path):
+ self.workspace = workspace
+ self.memory_dir = ensure_dir(workspace / "memory")
+ self.memory_file = self.memory_dir / "MEMORY.md"
+
+ def get_today_file(self) -> Path:
+ """Get path to today's memory file."""
+ return self.memory_dir / f"{today_date()}.md"
+
+ def read_today(self) -> str:
+ """Read today's memory notes."""
+ today_file = self.get_today_file()
+ if today_file.exists():
+ return today_file.read_text(encoding="utf-8")
+ return ""
+
+ def append_today(self, content: str) -> None:
+ """Append content to today's memory notes."""
+ today_file = self.get_today_file()
+
+ if today_file.exists():
+ existing = today_file.read_text(encoding="utf-8")
+ content = existing + "\n" + content
+ else:
+ # Add header for new day
+ header = f"# {today_date()}\n\n"
+ content = header + content
+
+ today_file.write_text(content, encoding="utf-8")
+
+ def read_long_term(self) -> str:
+ """Read long-term memory (MEMORY.md)."""
+ if self.memory_file.exists():
+ return self.memory_file.read_text(encoding="utf-8")
+ return ""
+
+ def write_long_term(self, content: str) -> None:
+ """Write to long-term memory (MEMORY.md)."""
+ self.memory_file.write_text(content, encoding="utf-8")
+
+ def get_recent_memories(self, days: int = 7) -> str:
+ """
+ Get memories from the last N days.
+
+ Args:
+ days: Number of days to look back.
+
+ Returns:
+ Combined memory content.
+ """
+ from datetime import timedelta
+
+ memories = []
+ today = datetime.now().date()
+
+ for i in range(days):
+ date = today - timedelta(days=i)
+ date_str = date.strftime("%Y-%m-%d")
+ file_path = self.memory_dir / f"{date_str}.md"
+
+ if file_path.exists():
+ content = file_path.read_text(encoding="utf-8")
+ memories.append(content)
+
+ return "\n\n---\n\n".join(memories)
+
+ def list_memory_files(self) -> list[Path]:
+ """List all memory files sorted by date (newest first)."""
+ if not self.memory_dir.exists():
+ return []
+
+ files = list(self.memory_dir.glob("????-??-??.md"))
+ return sorted(files, reverse=True)
+
+ def get_memory_context(self) -> str:
+ """
+ Get memory context for the agent.
+
+ Returns:
+ Formatted memory context including long-term and recent memories.
+ """
+ parts = []
+
+ # Long-term memory
+ long_term = self.read_long_term()
+ if long_term:
+ parts.append("## Long-term Memory\n" + long_term)
+
+ # Today's notes
+ today = self.read_today()
+ if today:
+ parts.append("## Today's Notes\n" + today)
+
+ return "\n\n".join(parts) if parts else ""
diff --git a/nanobot/agent/skills.py b/nanobot/agent/skills.py
new file mode 100644
index 0000000..7b04924
--- /dev/null
+++ b/nanobot/agent/skills.py
@@ -0,0 +1,228 @@
+"""Skills loader for agent capabilities."""
+
+import json
+import os
+import re
+import shutil
+from pathlib import Path
+
+# Default builtin skills directory (relative to this file)
+BUILTIN_SKILLS_DIR = Path(__file__).parent.parent / "skills"
+
+
+class SkillsLoader:
+ """
+ Loader for agent skills.
+
+ Skills are markdown files (SKILL.md) that teach the agent how to use
+ specific tools or perform certain tasks.
+ """
+
+ def __init__(self, workspace: Path, builtin_skills_dir: Path | None = None):
+ self.workspace = workspace
+ self.workspace_skills = workspace / "skills"
+ self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR
+
+ def list_skills(self, filter_unavailable: bool = True) -> list[dict[str, str]]:
+ """
+ List all available skills.
+
+ Args:
+ filter_unavailable: If True, filter out skills with unmet requirements.
+
+ Returns:
+ List of skill info dicts with 'name', 'path', 'source'.
+ """
+ skills = []
+
+ # Workspace skills (highest priority)
+ if self.workspace_skills.exists():
+ for skill_dir in self.workspace_skills.iterdir():
+ if skill_dir.is_dir():
+ skill_file = skill_dir / "SKILL.md"
+ if skill_file.exists():
+ skills.append({"name": skill_dir.name, "path": str(skill_file), "source": "workspace"})
+
+ # Built-in skills
+ if self.builtin_skills and self.builtin_skills.exists():
+ for skill_dir in self.builtin_skills.iterdir():
+ if skill_dir.is_dir():
+ skill_file = skill_dir / "SKILL.md"
+ if skill_file.exists() and not any(s["name"] == skill_dir.name for s in skills):
+ skills.append({"name": skill_dir.name, "path": str(skill_file), "source": "builtin"})
+
+ # Filter by requirements
+ if filter_unavailable:
+ return [s for s in skills if self._check_requirements(self._get_ocmeta(s["name"]))]
+ return skills
+
+ def load_skill(self, name: str) -> str | None:
+ """
+ Load a skill by name.
+
+ Args:
+ name: Skill name (directory name).
+
+ Returns:
+ Skill content or None if not found.
+ """
+ # Check workspace first
+ workspace_skill = self.workspace_skills / name / "SKILL.md"
+ if workspace_skill.exists():
+ return workspace_skill.read_text(encoding="utf-8")
+
+ # Check built-in
+ if self.builtin_skills:
+ builtin_skill = self.builtin_skills / name / "SKILL.md"
+ if builtin_skill.exists():
+ return builtin_skill.read_text(encoding="utf-8")
+
+ return None
+
+ def load_skills_for_context(self, skill_names: list[str]) -> str:
+ """
+ Load specific skills for inclusion in agent context.
+
+ Args:
+ skill_names: List of skill names to load.
+
+ Returns:
+ Formatted skills content.
+ """
+ parts = []
+ for name in skill_names:
+ content = self.load_skill(name)
+ if content:
+ content = self._strip_frontmatter(content)
+ parts.append(f"### Skill: {name}\n\n{content}")
+
+ return "\n\n---\n\n".join(parts) if parts else ""
+
+ def build_skills_summary(self) -> str:
+ """
+ Build a summary of all skills (name, description, path, availability).
+
+ This is used for progressive loading - the agent can read the full
+ skill content using read_file when needed.
+
+ Returns:
+ XML-formatted skills summary.
+ """
+ all_skills = self.list_skills(filter_unavailable=False)
+ if not all_skills:
+ return ""
+
+ def escape_xml(s: str) -> str:
+ return s.replace("&", "&").replace("<", "<").replace(">", ">")
+
+ lines = [""]
+ for s in all_skills:
+ name = escape_xml(s["name"])
+ path = s["path"]
+ desc = escape_xml(self._get_skill_description(s["name"]))
+ ocmeta = self._get_ocmeta(s["name"])
+ available = self._check_requirements(ocmeta)
+
+ lines.append(f" ")
+ lines.append(f" {name}")
+ lines.append(f" {desc}")
+ lines.append(f" {path}")
+
+ # Show missing requirements for unavailable skills
+ if not available:
+ missing = self._get_missing_requirements(ocmeta)
+ if missing:
+ lines.append(f" {escape_xml(missing)}")
+
+ lines.append(f" ")
+ lines.append("")
+
+ return "\n".join(lines)
+
+ def _get_missing_requirements(self, ocmeta: dict) -> str:
+ """Get a description of missing requirements."""
+ missing = []
+ requires = ocmeta.get("requires", {})
+ for b in requires.get("bins", []):
+ if not shutil.which(b):
+ missing.append(f"CLI: {b}")
+ for env in requires.get("env", []):
+ if not os.environ.get(env):
+ missing.append(f"ENV: {env}")
+ return ", ".join(missing)
+
+ def _get_skill_description(self, name: str) -> str:
+ """Get the description of a skill from its frontmatter."""
+ meta = self.get_skill_metadata(name)
+ if meta and meta.get("description"):
+ return meta["description"]
+ return name # Fallback to skill name
+
+ def _strip_frontmatter(self, content: str) -> str:
+ """Remove YAML frontmatter from markdown content."""
+ if content.startswith("---"):
+ match = re.match(r"^---\n.*?\n---\n", content, re.DOTALL)
+ if match:
+ return content[match.end():].strip()
+ return content
+
+ def _parse_openclaw_metadata(self, raw: str) -> dict:
+ """Parse openclaw metadata JSON from frontmatter."""
+ try:
+ data = json.loads(raw)
+ return data.get("openclaw", {}) if isinstance(data, dict) else {}
+ except (json.JSONDecodeError, TypeError):
+ return {}
+
+ def _check_requirements(self, ocmeta: dict) -> bool:
+ """Check if skill requirements are met (bins, env vars)."""
+ requires = ocmeta.get("requires", {})
+ for b in requires.get("bins", []):
+ if not shutil.which(b):
+ return False
+ for env in requires.get("env", []):
+ if not os.environ.get(env):
+ return False
+ return True
+
+ def _get_ocmeta(self, name: str) -> dict:
+ """Get openclaw metadata for a skill (cached in frontmatter)."""
+ meta = self.get_skill_metadata(name) or {}
+ return self._parse_openclaw_metadata(meta.get("metadata", ""))
+
+ def get_always_skills(self) -> list[str]:
+ """Get skills marked as always=true that meet requirements."""
+ result = []
+ for s in self.list_skills(filter_unavailable=True):
+ meta = self.get_skill_metadata(s["name"]) or {}
+ ocmeta = self._parse_openclaw_metadata(meta.get("metadata", ""))
+ if ocmeta.get("always") or meta.get("always"):
+ result.append(s["name"])
+ return result
+
+ def get_skill_metadata(self, name: str) -> dict | None:
+ """
+ Get metadata from a skill's frontmatter.
+
+ Args:
+ name: Skill name.
+
+ Returns:
+ Metadata dict or None.
+ """
+ content = self.load_skill(name)
+ if not content:
+ return None
+
+ if content.startswith("---"):
+ match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
+ if match:
+ # Simple YAML parsing
+ metadata = {}
+ for line in match.group(1).split("\n"):
+ if ":" in line:
+ key, value = line.split(":", 1)
+ metadata[key.strip()] = value.strip().strip('"\'')
+ return metadata
+
+ return None
diff --git a/nanobot/agent/tools/__init__.py b/nanobot/agent/tools/__init__.py
new file mode 100644
index 0000000..aac5d7d
--- /dev/null
+++ b/nanobot/agent/tools/__init__.py
@@ -0,0 +1,6 @@
+"""Agent tools module."""
+
+from nanobot.agent.tools.base import Tool
+from nanobot.agent.tools.registry import ToolRegistry
+
+__all__ = ["Tool", "ToolRegistry"]
diff --git a/nanobot/agent/tools/base.py b/nanobot/agent/tools/base.py
new file mode 100644
index 0000000..6fcfec6
--- /dev/null
+++ b/nanobot/agent/tools/base.py
@@ -0,0 +1,55 @@
+"""Base class for agent tools."""
+
+from abc import ABC, abstractmethod
+from typing import Any
+
+
+class Tool(ABC):
+ """
+ Abstract base class for agent tools.
+
+ Tools are capabilities that the agent can use to interact with
+ the environment, such as reading files, executing commands, etc.
+ """
+
+ @property
+ @abstractmethod
+ def name(self) -> str:
+ """Tool name used in function calls."""
+ pass
+
+ @property
+ @abstractmethod
+ def description(self) -> str:
+ """Description of what the tool does."""
+ pass
+
+ @property
+ @abstractmethod
+ def parameters(self) -> dict[str, Any]:
+ """JSON Schema for tool parameters."""
+ pass
+
+ @abstractmethod
+ async def execute(self, **kwargs: Any) -> str:
+ """
+ Execute the tool with given parameters.
+
+ Args:
+ **kwargs: Tool-specific parameters.
+
+ Returns:
+ String result of the tool execution.
+ """
+ pass
+
+ def to_schema(self) -> dict[str, Any]:
+ """Convert tool to OpenAI function schema format."""
+ return {
+ "type": "function",
+ "function": {
+ "name": self.name,
+ "description": self.description,
+ "parameters": self.parameters,
+ }
+ }
diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py
new file mode 100644
index 0000000..e141fab
--- /dev/null
+++ b/nanobot/agent/tools/filesystem.py
@@ -0,0 +1,191 @@
+"""File system tools: read, write, edit."""
+
+from pathlib import Path
+from typing import Any
+
+from nanobot.agent.tools.base import Tool
+
+
+class ReadFileTool(Tool):
+ """Tool to read file contents."""
+
+ @property
+ def name(self) -> str:
+ return "read_file"
+
+ @property
+ def description(self) -> str:
+ return "Read the contents of a file at the given path."
+
+ @property
+ def parameters(self) -> dict[str, Any]:
+ return {
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "The file path to read"
+ }
+ },
+ "required": ["path"]
+ }
+
+ async def execute(self, path: str, **kwargs: Any) -> str:
+ try:
+ file_path = Path(path).expanduser()
+ if not file_path.exists():
+ return f"Error: File not found: {path}"
+ if not file_path.is_file():
+ return f"Error: Not a file: {path}"
+
+ content = file_path.read_text(encoding="utf-8")
+ return content
+ except PermissionError:
+ return f"Error: Permission denied: {path}"
+ except Exception as e:
+ return f"Error reading file: {str(e)}"
+
+
+class WriteFileTool(Tool):
+ """Tool to write content to a file."""
+
+ @property
+ def name(self) -> str:
+ return "write_file"
+
+ @property
+ def description(self) -> str:
+ return "Write content to a file at the given path. Creates parent directories if needed."
+
+ @property
+ def parameters(self) -> dict[str, Any]:
+ return {
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "The file path to write to"
+ },
+ "content": {
+ "type": "string",
+ "description": "The content to write"
+ }
+ },
+ "required": ["path", "content"]
+ }
+
+ async def execute(self, path: str, content: str, **kwargs: Any) -> str:
+ try:
+ file_path = Path(path).expanduser()
+ file_path.parent.mkdir(parents=True, exist_ok=True)
+ file_path.write_text(content, encoding="utf-8")
+ return f"Successfully wrote {len(content)} bytes to {path}"
+ except PermissionError:
+ return f"Error: Permission denied: {path}"
+ except Exception as e:
+ return f"Error writing file: {str(e)}"
+
+
+class EditFileTool(Tool):
+ """Tool to edit a file by replacing text."""
+
+ @property
+ def name(self) -> str:
+ return "edit_file"
+
+ @property
+ def description(self) -> str:
+ return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file."
+
+ @property
+ def parameters(self) -> dict[str, Any]:
+ return {
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "The file path to edit"
+ },
+ "old_text": {
+ "type": "string",
+ "description": "The exact text to find and replace"
+ },
+ "new_text": {
+ "type": "string",
+ "description": "The text to replace with"
+ }
+ },
+ "required": ["path", "old_text", "new_text"]
+ }
+
+ async def execute(self, path: str, old_text: str, new_text: str, **kwargs: Any) -> str:
+ try:
+ file_path = Path(path).expanduser()
+ if not file_path.exists():
+ return f"Error: File not found: {path}"
+
+ content = file_path.read_text(encoding="utf-8")
+
+ if old_text not in content:
+ return f"Error: old_text not found in file. Make sure it matches exactly."
+
+ # Count occurrences
+ count = content.count(old_text)
+ if count > 1:
+ return f"Warning: old_text appears {count} times. Please provide more context to make it unique."
+
+ new_content = content.replace(old_text, new_text, 1)
+ file_path.write_text(new_content, encoding="utf-8")
+
+ return f"Successfully edited {path}"
+ except PermissionError:
+ return f"Error: Permission denied: {path}"
+ except Exception as e:
+ return f"Error editing file: {str(e)}"
+
+
+class ListDirTool(Tool):
+ """Tool to list directory contents."""
+
+ @property
+ def name(self) -> str:
+ return "list_dir"
+
+ @property
+ def description(self) -> str:
+ return "List the contents of a directory."
+
+ @property
+ def parameters(self) -> dict[str, Any]:
+ return {
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "The directory path to list"
+ }
+ },
+ "required": ["path"]
+ }
+
+ async def execute(self, path: str, **kwargs: Any) -> str:
+ try:
+ dir_path = Path(path).expanduser()
+ if not dir_path.exists():
+ return f"Error: Directory not found: {path}"
+ if not dir_path.is_dir():
+ return f"Error: Not a directory: {path}"
+
+ items = []
+ for item in sorted(dir_path.iterdir()):
+ prefix = "π " if item.is_dir() else "π "
+ items.append(f"{prefix}{item.name}")
+
+ if not items:
+ return f"Directory {path} is empty"
+
+ return "\n".join(items)
+ except PermissionError:
+ return f"Error: Permission denied: {path}"
+ except Exception as e:
+ return f"Error listing directory: {str(e)}"
diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py
new file mode 100644
index 0000000..347830f
--- /dev/null
+++ b/nanobot/agent/tools/message.py
@@ -0,0 +1,86 @@
+"""Message tool for sending messages to users."""
+
+from typing import Any, Callable, Awaitable
+
+from nanobot.agent.tools.base import Tool
+from nanobot.bus.events import OutboundMessage
+
+
+class MessageTool(Tool):
+ """Tool to send messages to users on chat channels."""
+
+ def __init__(
+ self,
+ send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None,
+ default_channel: str = "",
+ default_chat_id: str = ""
+ ):
+ self._send_callback = send_callback
+ self._default_channel = default_channel
+ self._default_chat_id = default_chat_id
+
+ def set_context(self, channel: str, chat_id: str) -> None:
+ """Set the current message context."""
+ self._default_channel = channel
+ self._default_chat_id = chat_id
+
+ def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None:
+ """Set the callback for sending messages."""
+ self._send_callback = callback
+
+ @property
+ def name(self) -> str:
+ return "message"
+
+ @property
+ def description(self) -> str:
+ return "Send a message to the user. Use this when you want to communicate something."
+
+ @property
+ def parameters(self) -> dict[str, Any]:
+ return {
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "string",
+ "description": "The message content to send"
+ },
+ "channel": {
+ "type": "string",
+ "description": "Optional: target channel (telegram, discord, etc.)"
+ },
+ "chat_id": {
+ "type": "string",
+ "description": "Optional: target chat/user ID"
+ }
+ },
+ "required": ["content"]
+ }
+
+ async def execute(
+ self,
+ content: str,
+ channel: str | None = None,
+ chat_id: str | None = None,
+ **kwargs: Any
+ ) -> str:
+ channel = channel or self._default_channel
+ chat_id = chat_id or self._default_chat_id
+
+ if not channel or not chat_id:
+ return "Error: No target channel/chat specified"
+
+ if not self._send_callback:
+ return "Error: Message sending not configured"
+
+ msg = OutboundMessage(
+ channel=channel,
+ chat_id=chat_id,
+ content=content
+ )
+
+ try:
+ await self._send_callback(msg)
+ return f"Message sent to {channel}:{chat_id}"
+ except Exception as e:
+ return f"Error sending message: {str(e)}"
diff --git a/nanobot/agent/tools/registry.py b/nanobot/agent/tools/registry.py
new file mode 100644
index 0000000..1e8f56d
--- /dev/null
+++ b/nanobot/agent/tools/registry.py
@@ -0,0 +1,70 @@
+"""Tool registry for dynamic tool management."""
+
+from typing import Any
+
+from nanobot.agent.tools.base import Tool
+
+
+class ToolRegistry:
+ """
+ Registry for agent tools.
+
+ Allows dynamic registration and execution of tools.
+ """
+
+ def __init__(self):
+ self._tools: dict[str, Tool] = {}
+
+ def register(self, tool: Tool) -> None:
+ """Register a tool."""
+ self._tools[tool.name] = tool
+
+ def unregister(self, name: str) -> None:
+ """Unregister a tool by name."""
+ self._tools.pop(name, None)
+
+ def get(self, name: str) -> Tool | None:
+ """Get a tool by name."""
+ return self._tools.get(name)
+
+ def has(self, name: str) -> bool:
+ """Check if a tool is registered."""
+ return name in self._tools
+
+ def get_definitions(self) -> list[dict[str, Any]]:
+ """Get all tool definitions in OpenAI format."""
+ return [tool.to_schema() for tool in self._tools.values()]
+
+ async def execute(self, name: str, params: dict[str, Any]) -> str:
+ """
+ Execute a tool by name with given parameters.
+
+ Args:
+ name: Tool name.
+ params: Tool parameters.
+
+ Returns:
+ Tool execution result as string.
+
+ Raises:
+ KeyError: If tool not found.
+ """
+ tool = self._tools.get(name)
+ if not tool:
+ return f"Error: Tool '{name}' not found"
+
+ try:
+ return await tool.execute(**params)
+ except Exception as e:
+ return f"Error executing {name}: {str(e)}"
+
+ @property
+ def tool_names(self) -> list[str]:
+ """Get list of registered tool names."""
+ return list(self._tools.keys())
+
+ def __len__(self) -> int:
+ return len(self._tools)
+
+ def __contains__(self, name: str) -> bool:
+ return name in self._tools
diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py
new file mode 100644
index 0000000..bf7f064
--- /dev/null
+++ b/nanobot/agent/tools/shell.py
@@ -0,0 +1,85 @@
+"""Shell execution tool."""
+
+import asyncio
+import os
+from typing import Any
+
+from nanobot.agent.tools.base import Tool
+
+
+class ExecTool(Tool):
+ """Tool to execute shell commands."""
+
+ def __init__(self, timeout: int = 60, working_dir: str | None = None):
+ self.timeout = timeout
+ self.working_dir = working_dir
+
+ @property
+ def name(self) -> str:
+ return "exec"
+
+ @property
+ def description(self) -> str:
+ return "Execute a shell command and return its output. Use with caution."
+
+ @property
+ def parameters(self) -> dict[str, Any]:
+ return {
+ "type": "object",
+ "properties": {
+ "command": {
+ "type": "string",
+ "description": "The shell command to execute"
+ },
+ "working_dir": {
+ "type": "string",
+ "description": "Optional working directory for the command"
+ }
+ },
+ "required": ["command"]
+ }
+
+ async def execute(self, command: str, working_dir: str | None = None, **kwargs: Any) -> str:
+ cwd = working_dir or self.working_dir or os.getcwd()
+
+ try:
+ process = await asyncio.create_subprocess_shell(
+ command,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ cwd=cwd,
+ )
+
+ try:
+ stdout, stderr = await asyncio.wait_for(
+ process.communicate(),
+ timeout=self.timeout
+ )
+ except asyncio.TimeoutError:
+ process.kill()
+ return f"Error: Command timed out after {self.timeout} seconds"
+
+ output_parts = []
+
+ if stdout:
+ output_parts.append(stdout.decode("utf-8", errors="replace"))
+
+ if stderr:
+ stderr_text = stderr.decode("utf-8", errors="replace")
+ if stderr_text.strip():
+ output_parts.append(f"STDERR:\n{stderr_text}")
+
+ if process.returncode != 0:
+ output_parts.append(f"\nExit code: {process.returncode}")
+
+ result = "\n".join(output_parts) if output_parts else "(no output)"
+
+ # Truncate very long output
+ max_len = 10000
+ if len(result) > max_len:
+ result = result[:max_len] + f"\n... (truncated, {len(result) - max_len} more chars)"
+
+ return result
+
+ except Exception as e:
+ return f"Error executing command: {str(e)}"
diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py
new file mode 100644
index 0000000..c9d989c
--- /dev/null
+++ b/nanobot/agent/tools/web.py
@@ -0,0 +1,139 @@
+"""Web tools: web_search and web_fetch."""
+
+import html
+import json
+import os
+import re
+from typing import Any
+
+import httpx
+
+from nanobot.agent.tools.base import Tool
+
+# Shared constants
+USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36"
+
+
+def _strip_tags(text: str) -> str:
+ """Remove HTML tags and decode entities."""
+ text = re.sub(r'