From 8ff7b56cb266de7f2997925de58b2c0a164751e6 Mon Sep 17 00:00:00 2001
From: Xubin Ren
Date: Sun, 19 Apr 2026 09:42:17 +0000
Subject: [PATCH] docs: refactor README into a docs-first landing page
---
README.md | 2196 +----------------
docs/PYTHON_SDK.md | 138 --
docs/README.md | 30 +
docs/agent-social-network.md | 10 +
...LUGIN_GUIDE.md => channel-plugin-guide.md} | 2 +-
docs/chat-apps.md | 661 +++++
docs/chat-commands.md | 33 +
docs/cli-reference.md | 21 +
docs/configuration.md | 809 ++++++
docs/deployment.md | 94 +
docs/features-showcase.md | 22 +
docs/{MEMORY.md => memory.md} | 2 +-
docs/multiple-instances.md | 126 +
docs/{MY_TOOL.md => my-tool.md} | 20 +-
docs/openai-api.md | 121 +
docs/python-sdk.md | 31 +
docs/quick-start.md | 104 +
docs/{WEBSOCKET.md => websocket.md} | 4 +-
images/GitHub_README.png | Bin 0 -> 192486 bytes
images/nanobot_arch.png | Bin 0 -> 502042 bytes
nanobot_logo.png => images/nanobot_logo.png | Bin
nanobot_arch.png | Bin 643326 -> 0 bytes
22 files changed, 2171 insertions(+), 2253 deletions(-)
delete mode 100644 docs/PYTHON_SDK.md
create mode 100644 docs/README.md
create mode 100644 docs/agent-social-network.md
rename docs/{CHANNEL_PLUGIN_GUIDE.md => channel-plugin-guide.md} (99%)
create mode 100644 docs/chat-apps.md
create mode 100644 docs/chat-commands.md
create mode 100644 docs/cli-reference.md
create mode 100644 docs/configuration.md
create mode 100644 docs/deployment.md
create mode 100644 docs/features-showcase.md
rename docs/{MEMORY.md => memory.md} (99%)
create mode 100644 docs/multiple-instances.md
rename docs/{MY_TOOL.md => my-tool.md} (98%)
create mode 100644 docs/openai-api.md
create mode 100644 docs/python-sdk.md
create mode 100644 docs/quick-start.md
rename docs/{WEBSOCKET.md => websocket.md} (99%)
create mode 100644 images/GitHub_README.png
create mode 100644 images/nanobot_arch.png
rename nanobot_logo.png => images/nanobot_logo.png (100%)
delete mode 100644 nanobot_arch.png
diff --git a/README.md b/README.md
index 140846ac..2d785f1d 100644
--- a/README.md
+++ b/README.md
@@ -1,23 +1,21 @@
+
+
-๐ **nanobot** is an **ultra-lightweight** personal AI agent inspired by [OpenClaw](https://github.com/openclaw/openclaw).
-
-โก๏ธ Delivers core agent functionality with **99% fewer lines of code**.
-
-๐ Real-time line count: run `bash core_agent_lines.sh` to verify anytime.
+๐ **nanobot** is an open-source and ultra-lightweight personal AI agent in the spirit of [OpenClaw](https://github.com/openclaw/openclaw), [Claude Code](https://www.anthropic.com/claude-code), and [Codex](https://www.openai.com/codex/). It keeps the core agent loop small and readable while still supporting chat channels, memory, MCP, an OpenAI-compatible API, and practical deployment paths, so you can go from local setup to a long-running personal agent with minimal overhead.
## ๐ข News
@@ -100,46 +98,94 @@
-> ๐ nanobot is for educational, research, and technical exchange purposes only. It is unrelated to crypto and does not involve any official token or coin.
-## Key Features of nanobot:
+## ๐ก Key Features of nanobot
-๐ชถ **Ultra-Lightweight**: A lightweight implementation built for stable, long-running AI agents.
+- **Ultra-lightweight**: stable long-running agent behavior with a small, readable core.
+- **Research-ready**: the codebase is intentionally simple enough to study, modify, and extend.
+- **Practical**: chat channels, API, memory, MCP, and deployment paths are already built in.
+- **Hackable**: you can start fast, then go deeper through repo docs instead of a monolithic landing page.
-๐ฌ **Research-Ready**: Clean, readable code that's easy to understand, modify, and extend for research.
+## ๐ฆ Install
-โก๏ธ **Lightning Fast**: Minimal footprint means faster startup, lower resource usage, and quicker iterations.
+> [!IMPORTANT]
+> If you want the newest features and experiments, install from source. If you want the most stable day-to-day experience, install from PyPI or with `uv`.
-๐ **Easy-to-Use**: One-click to deploy and you're ready to go.
+**Install from source**
+
+```bash
+git clone https://github.com/HKUDS/nanobot.git
+cd nanobot
+pip install -e .
+```
+
+**Install with `uv`**
+
+```bash
+uv tool install nanobot-ai
+```
+
+**Install from PyPI**
+
+```bash
+pip install nanobot-ai
+```
+
+## ๐ Quick Start
+
+**1. Initialize**
+
+```bash
+nanobot onboard
+```
+
+**2. Configure** (`~/.nanobot/config.json`)
+
+Configure these **two parts** in your config (other options have defaults). Add or merge the following blocks into your existing config instead of replacing the whole file.
+
+*Set your API key* (e.g. [OpenRouter](https://openrouter.ai/keys), recommended for global users):
+
+```json
+{
+ "providers": {
+ "openrouter": {
+ "apiKey": "sk-or-v1-xxx"
+ }
+ }
+}
+```
+
+*Set your model* (optionally pin a provider โ defaults to auto-detection):
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "provider": "openrouter",
+ "model": "anthropic/claude-opus-4-6"
+ }
+ }
+}
+```
+
+**3. Chat**
+
+```bash
+nanobot agent
+```
+
+
+- Want different LLM providers, web search, MCP, security settings, or more config options? See [`Configuration`](./docs/configuration.md)
+- Want to run nanobot in chat apps like Telegram, Discord, WeChat or Feishu? See [`Chat Apps`](./docs/chat-apps.md)
+- Want Docker or Linux service deployment? See [`Deployment`](./docs/deployment.md)
## ๐๏ธ Architecture
-
+
-## Table of Contents
-
-- [News](#-news)
-- [Key Features](#key-features-of-nanobot)
-- [Architecture](#๏ธ-architecture)
-- [Features](#-features)
-- [Install](#-install)
-- [Quick Start](#-quick-start)
-- [Chat Apps](#-chat-apps)
-- [Agent Social Network](#-agent-social-network)
-- [Configuration](#๏ธ-configuration)
-- [Multiple Instances](#-multiple-instances)
-- [Memory](#-memory)
-- [CLI Reference](#-cli-reference)
-- [In-Chat Commands](#-in-chat-commands)
-- [Python SDK](#-python-sdk)
-- [OpenAI-Compatible API](#-openai-compatible-api)
-- [Docker](#-docker)
-- [Linux Service](#-linux-service)
-- [Project Structure](#-project-structure)
-- [Contribute & Roadmap](#-contribute--roadmap)
-- [Star History](#-star-history)
+๐ nanobot stays lightweight by centering everything around a small agent loop: messages come in from chat apps, the LLM decides when tools are needed, and memory or skills are pulled in only as context instead of becoming a heavy orchestration layer. That keeps the core path readable and easy to extend, while still letting you add channels, tools, memory, and deployment options without turning the system into a monolith.
## โจ Features
@@ -164,2061 +210,14 @@
-## ๐ฆ Install
+## ๐ Docs
-> [!IMPORTANT]
-> This README may describe features that are available first in the latest source code.
-> If you want the newest features and experiments, install from source.
-> If you want the most stable day-to-day experience, install from PyPI or with `uv`.
+Browse the [repo docs](./docs/README.md) for the latest features and GitHub development version, or visit [nanobot.wiki](https://nanobot.wiki/docs/latest/getting-started/nanobot-overview) for the stable release documentation.
-**Install from source** (latest features, experimental changes may land here first; recommended for development)
-
-```bash
-git clone https://github.com/HKUDS/nanobot.git
-cd nanobot
-pip install -e .
-```
-
-**Install with [uv](https://github.com/astral-sh/uv)** (stable release, fast)
-
-```bash
-uv tool install nanobot-ai
-```
-
-**Install from PyPI** (stable release)
-
-```bash
-pip install nanobot-ai
-```
-
-### Update to latest version
-
-**PyPI / pip**
-
-```bash
-pip install -U nanobot-ai
-nanobot --version
-```
-
-**uv**
-
-```bash
-uv tool upgrade nanobot-ai
-nanobot --version
-```
-
-**Using WhatsApp?** Rebuild the local bridge after upgrading:
-
-```bash
-rm -rf ~/.nanobot/bridge
-nanobot channels login whatsapp
-```
-
-## ๐ Quick Start
-
-> [!TIP]
-> Set your API key in `~/.nanobot/config.json`.
-> Get API keys: [OpenRouter](https://openrouter.ai/keys) (Global)
->
-> For other LLM providers, please see the [Providers](#providers) section.
->
-> For web search capability setup, please see [Web Search](#web-search).
-
-**1. Initialize**
-
-```bash
-nanobot onboard
-```
-
-Use `nanobot onboard --wizard` if you want the interactive setup wizard.
-
-**2. Configure** (`~/.nanobot/config.json`)
-
-Configure these **two parts** in your config (other options have defaults).
-
-*Set your API key* (e.g. OpenRouter, recommended for global users):
-```json
-{
- "providers": {
- "openrouter": {
- "apiKey": "sk-or-v1-xxx"
- }
- }
-}
-```
-
-*Set your model* (optionally pin a provider โ defaults to auto-detection):
-```json
-{
- "agents": {
- "defaults": {
- "model": "anthropic/claude-opus-4-5",
- "provider": "openrouter"
- }
- }
-}
-```
-
-**3. Chat**
-
-```bash
-nanobot agent
-```
-
-That's it! You have a working AI agent in 2 minutes.
-
-## ๐ฌ Chat Apps
-
-Connect nanobot to your favorite chat platform. Want to build your own? See the [Channel Plugin Guide](./docs/CHANNEL_PLUGIN_GUIDE.md).
-
-| Channel | What you need |
-|---------|---------------|
-| **Telegram** | Bot token from @BotFather |
-| **Discord** | Bot token + Message Content intent |
-| **WhatsApp** | QR code scan (`nanobot channels login whatsapp`) |
-| **WeChat (Weixin)** | QR code scan (`nanobot channels login weixin`) |
-| **Feishu** | App ID + App Secret |
-| **DingTalk** | App Key + App Secret |
-| **Slack** | Bot token + App-Level token |
-| **Matrix** | Homeserver URL + Access token |
-| **Email** | IMAP/SMTP credentials |
-| **QQ** | App ID + App Secret |
-| **Wecom** | Bot ID + Bot Secret |
-| **Microsoft Teams** | App ID + App Password + public HTTPS endpoint |
-| **Mochat** | Claw token (auto-setup available) |
-
-
-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"]
- }
- }
-}
-```
-
-> You can find your **User ID** in Telegram settings. It is shown as `@yourUserId`.
-> Copy this value **without the `@` symbol** and paste it into the config file.
-
-
-**3. Run**
-
-```bash
-nanobot gateway
-```
-
-
-
-
-Mochat (Claw IM)
-
-Uses **Socket.IO WebSocket** by default, with HTTP polling fallback.
-
-**1. Ask nanobot to set up Mochat for you**
-
-Simply send this message to nanobot (replace `xxx@xxx` with your real email):
-
-```
-Read https://raw.githubusercontent.com/HKUDS/MoChat/refs/heads/main/skills/nanobot/skill.md and register on MoChat. My Email account is xxx@xxx Bind me as your owner and DM me on MoChat.
-```
-
-nanobot will automatically register, configure `~/.nanobot/config.json`, and connect to Mochat.
-
-**2. Restart gateway**
-
-```bash
-nanobot gateway
-```
-
-That's it โ nanobot handles the rest!
-
-
-
-
-Manual configuration (advanced)
-
-If you prefer to configure manually, add the following to `~/.nanobot/config.json`:
-
-> Keep `claw_token` private. It should only be sent in `X-Claw-Token` header to your Mochat API endpoint.
-
-```json
-{
- "channels": {
- "mochat": {
- "enabled": true,
- "base_url": "https://mochat.io",
- "socket_url": "https://mochat.io",
- "socket_path": "/socket.io",
- "claw_token": "claw_xxx",
- "agent_user_id": "6982abcdef",
- "sessions": ["*"],
- "panels": ["*"],
- "reply_delay_mode": "non-mention",
- "reply_delay_ms": 120000
- }
- }
-}
-```
-
-
-
-
-
-
-
-
-Discord
-
-**1. Create a bot**
-- Go to https://discord.com/developers/applications
-- Create an application โ Bot โ Add Bot
-- Copy the bot token
-
-**2. Enable intents**
-- In the Bot settings, enable **MESSAGE CONTENT INTENT**
-- (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
-
-**3. Get your User ID**
-- Discord Settings โ Advanced โ enable **Developer Mode**
-- Right-click your avatar โ **Copy User ID**
-
-**4. Configure**
-
-```json
-{
- "channels": {
- "discord": {
- "enabled": true,
- "token": "YOUR_BOT_TOKEN",
- "allowFrom": ["YOUR_USER_ID"],
- "allowChannels": [],
- "groupPolicy": "mention",
- "streaming": true
- }
- }
-}
-```
-
-> `groupPolicy` controls how the bot responds in group channels:
-> - `"mention"` (default) โ Only respond when @mentioned
-> - `"open"` โ Respond to all messages
-> DMs always respond when the sender is in `allowFrom`.
-> - If you set group policy to open create new threads as private threads and then @ the bot into it. Otherwise the thread itself and the channel in which you spawned it will spawn a bot session.
-> `allowChannels` restricts the bot to specific Discord channel IDs. Empty (default) means respond in every channel the bot can see. Example: `["1234567890", "0987654321"]`. The filter applies after `allowFrom`, so both must pass.
-> `streaming` defaults to `true`. Disable it only if you explicitly want non-streaming replies.
-
-**5. Invite the bot**
-- OAuth2 โ URL Generator
-- Scopes: `bot`
-- Bot Permissions: `Send Messages`, `Read Message History`
-- Open the generated invite URL and add the bot to your server
-
-**6. Run**
-
-```bash
-nanobot gateway
-```
-
-
-
-
-Matrix (Element)
-
-Install Matrix dependencies first:
-
-```bash
-pip install nanobot-ai[matrix]
-```
-
-> [!NOTE]
-> Matrix is not supported on Windows. `matrix-nio[e2e]` depends on
-> `python-olm`, which has no pre-built Windows wheel and is skipped by the
-> `matrix` extra on `sys_platform == 'win32'`. The command above will still
-> succeed on Windows but without `matrix-nio` installed, so enabling the
-> Matrix channel will fail at startup. Use macOS, Linux, or WSL2.
-
-**1. Create/choose a Matrix account**
-
-- Create or reuse a Matrix account on your homeserver (for example `matrix.org`).
-- Confirm you can log in with Element.
-
-**2. Get credentials**
-
-- You need:
- - `userId` (example: `@nanobot:matrix.org`)
- - `password`
-
-(Note: `accessToken` and `deviceId` are still supported for legacy reasons, but
-for reliable encryption, password login is recommended instead. If the
-`password` is provided, `accessToken` and `deviceId` will be ignored.)
-
-**3. Configure**
-
-```json
-{
- "channels": {
- "matrix": {
- "enabled": true,
- "homeserver": "https://matrix.org",
- "userId": "@nanobot:matrix.org",
- "password": "mypasswordhere",
- "e2eeEnabled": true,
- "allowFrom": ["@your_user:matrix.org"],
- "groupPolicy": "open",
- "groupAllowFrom": [],
- "allowRoomMentions": false,
- "maxMediaBytes": 20971520
- }
- }
-}
-```
-
-> Keep a persistent `matrix-store` โ encrypted session state is lost if these change across restarts.
-
-| Option | Description |
-|--------|-------------|
-| `allowFrom` | User IDs allowed to interact. Empty denies all; use `["*"]` to allow everyone. |
-| `groupPolicy` | `open` (default), `mention`, or `allowlist`. |
-| `groupAllowFrom` | Room allowlist (used when policy is `allowlist`). |
-| `allowRoomMentions` | Accept `@room` mentions in mention mode. |
-| `e2eeEnabled` | E2EE support (default `true`). Set `false` for plaintext-only. |
-| `maxMediaBytes` | Max attachment size (default `20MB`). Set `0` to block all media. |
-
-
-
-
-**4. Run**
-
-```bash
-nanobot gateway
-```
-
-
-
-
-WhatsApp
-
-Requires **Node.js โฅ18**.
-
-**1. Link device**
-
-```bash
-nanobot channels login whatsapp
-# 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 whatsapp
-
-# Terminal 2
-nanobot gateway
-```
-
-> WhatsApp bridge updates are not applied automatically for existing installations.
-> After upgrading nanobot, rebuild the local bridge with:
-> `rm -rf ~/.nanobot/bridge && nanobot channels login whatsapp`
-
-
-
-
-Feishu
-
-Uses **WebSocket** long connection โ no public IP required.
-
-**1. Create a Feishu bot**
-- Visit [Feishu Open Platform](https://open.feishu.cn/app)
-- Create a new app โ Enable **Bot** capability
-- **Permissions**:
- - `im:message` (send messages) and `im:message.p2p_msg:readonly` (receive messages)
- - **Streaming replies** (default in nanobot): add **`cardkit:card:write`** (often labeled **Create and update cards** in the Feishu developer console). Required for CardKit entities and streamed assistant text. Older apps may not have it yet โ open **Permission management**, enable the scope, then **publish** a new app version if the console requires it.
- - If you **cannot** add `cardkit:card:write`, set `"streaming": false` under `channels.feishu` (see below). The bot still works; replies use normal interactive cards without token-by-token streaming.
-- **Events**: Add `im.message.receive_v1` (receive messages)
- - Select **Long Connection** mode (requires running nanobot first to establish connection)
-- Get **App ID** and **App Secret** from "Credentials & Basic Info"
-- Publish the app
-
-**2. Configure**
-
-```json
-{
- "channels": {
- "feishu": {
- "enabled": true,
- "appId": "cli_xxx",
- "appSecret": "xxx",
- "encryptKey": "",
- "verificationToken": "",
- "allowFrom": ["ou_YOUR_OPEN_ID"],
- "groupPolicy": "mention",
- "reactEmoji": "OnIt",
- "doneEmoji": "DONE",
- "toolHintPrefix": "๐ง",
- "streaming": true,
- "domain": "feishu"
- }
- }
-}
-```
-
-> `streaming` defaults to `true`. Use `false` if your app does not have **`cardkit:card:write`** (see permissions above).
-> `encryptKey` and `verificationToken` are optional for Long Connection mode.
-> `allowFrom`: Add your open_id (find it in nanobot logs when you message the bot). Use `["*"]` to allow all users.
-> `groupPolicy`: `"mention"` (default โ respond only when @mentioned), `"open"` (respond to all group messages). Private chats always respond.
-> `reactEmoji`: Emoji for "processing" status (default: `OnIt`). See [available emojis](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce).
-> `doneEmoji`: Optional emoji for "completed" status (e.g., `DONE`, `OK`, `HEART`). When set, bot adds this reaction after removing `reactEmoji`.
-> `toolHintPrefix`: Prefix for inline tool hints in streaming cards (default: `๐ง`).
-> `domain`: `"feishu"` (default) for China (open.feishu.cn), `"lark"` for international Lark (open.larksuite.com).
-
-**3. Run**
-
-```bash
-nanobot gateway
-```
-
-> [!TIP]
-> Feishu uses WebSocket to receive messages โ no webhook or public IP needed!
-
-
-
-
-QQ (QQๅ่)
-
-Uses **botpy SDK** with WebSocket โ no public IP required. Currently supports **private messages only**.
-
-**1. Register & create bot**
-- Visit [QQ Open Platform](https://q.qq.com) โ Register as a developer (personal or enterprise)
-- Create a new bot application
-- Go to **ๅผๅ่ฎพ็ฝฎ (Developer Settings)** โ copy **AppID** and **AppSecret**
-
-**2. Set up sandbox for testing**
-- In the bot management console, find **ๆฒ็ฎฑ้
็ฝฎ (Sandbox Config)**
-- Under **ๅจๆถๆฏๅ่กจ้
็ฝฎ**, click **ๆทปๅ ๆๅ** and add your own QQ number
-- Once added, scan the bot's QR code with mobile QQ โ open the bot profile โ tap "ๅๆถๆฏ" to start chatting
-
-**3. Configure**
-
-> - `allowFrom`: Add your openid (find it in nanobot logs when you message the bot). Use `["*"]` for public access.
-> - `msgFormat`: Optional. Use `"plain"` (default) for maximum compatibility with legacy QQ clients, or `"markdown"` for richer formatting on newer clients.
-> - For production: submit a review in the bot console and publish. See [QQ Bot Docs](https://bot.q.qq.com/wiki/) for the full publishing flow.
-
-```json
-{
- "channels": {
- "qq": {
- "enabled": true,
- "appId": "YOUR_APP_ID",
- "secret": "YOUR_APP_SECRET",
- "allowFrom": ["YOUR_OPENID"],
- "msgFormat": "plain"
- }
- }
-}
-```
-
-**4. Run**
-
-```bash
-nanobot gateway
-```
-
-Now send a message to the bot from QQ โ it should respond!
-
-
-
-
-DingTalk (้้)
-
-Uses **Stream Mode** โ no public IP required.
-
-**1. Create a DingTalk bot**
-- Visit [DingTalk Open Platform](https://open-dev.dingtalk.com/)
-- Create a new app -> Add **Robot** capability
-- **Configuration**:
- - Toggle **Stream Mode** ON
-- **Permissions**: Add necessary permissions for sending messages
-- Get **AppKey** (Client ID) and **AppSecret** (Client Secret) from "Credentials"
-- Publish the app
-
-**2. Configure**
-
-```json
-{
- "channels": {
- "dingtalk": {
- "enabled": true,
- "clientId": "YOUR_APP_KEY",
- "clientSecret": "YOUR_APP_SECRET",
- "allowFrom": ["YOUR_STAFF_ID"]
- }
- }
-}
-```
-
-> `allowFrom`: Add your staff ID. Use `["*"]` to allow all users.
-
-**3. Run**
-
-```bash
-nanobot gateway
-```
-
-
-
-
-Slack
-
-Uses **Socket Mode** โ no public URL required.
-
-**1. Create a Slack app**
-- Go to [Slack API](https://api.slack.com/apps) โ **Create New App** โ "From scratch"
-- Pick a name and select your workspace
-
-**2. Configure the app**
-- **Socket Mode**: Toggle ON โ Generate an **App-Level Token** with `connections:write` scope โ copy it (`xapp-...`)
-- **OAuth & Permissions**: Add bot scopes: `chat:write`, `reactions:write`, `app_mentions:read`
-- **Event Subscriptions**: Toggle ON โ Subscribe to bot events: `message.im`, `message.channels`, `app_mention` โ Save Changes
-- **App Home**: Scroll to **Show Tabs** โ Enable **Messages Tab** โ Check **"Allow users to send Slash commands and messages from the messages tab"**
-- **Install App**: Click **Install to Workspace** โ Authorize โ copy the **Bot Token** (`xoxb-...`)
-
-**3. Configure nanobot**
-
-```json
-{
- "channels": {
- "slack": {
- "enabled": true,
- "botToken": "xoxb-...",
- "appToken": "xapp-...",
- "allowFrom": ["YOUR_SLACK_USER_ID"],
- "groupPolicy": "mention"
- }
- }
-}
-```
-
-**4. Run**
-
-```bash
-nanobot gateway
-```
-
-DM the bot directly or @mention it in a channel โ it should respond!
-
-> [!TIP]
-> - `groupPolicy`: `"mention"` (default โ respond only when @mentioned), `"open"` (respond to all channel messages), or `"allowlist"` (restrict to specific channels).
-> - DM policy defaults to open. Set `"dm": {"enabled": false}` to disable DMs.
-
-
-
-
-Email
-
-Give nanobot its own email account. It polls **IMAP** for incoming mail and replies via **SMTP** โ like a personal email assistant.
-
-**1. Get credentials (Gmail example)**
-- Create a dedicated Gmail account for your bot (e.g. `my-nanobot@gmail.com`)
-- Enable 2-Step Verification โ Create an [App Password](https://myaccount.google.com/apppasswords)
-- Use this app password for both IMAP and SMTP
-
-**2. Configure**
-
-> - `consentGranted` must be `true` to allow mailbox access. This is a safety gate โ set `false` to fully disable.
-> - `allowFrom`: Add your email address. Use `["*"]` to accept emails from anyone.
-> - `smtpUseTls` and `smtpUseSsl` default to `true` / `false` respectively, which is correct for Gmail (port 587 + STARTTLS). No need to set them explicitly.
-> - Set `"autoReplyEnabled": false` if you only want to read/analyze emails without sending automatic replies.
-> - `allowedAttachmentTypes`: Save inbound attachments matching these MIME types โ `["*"]` for all, e.g. `["application/pdf", "image/*"]` (default `[]` = disabled).
-> - `maxAttachmentSize`: Max size per attachment in bytes (default `2000000` / 2MB).
-> - `maxAttachmentsPerEmail`: Max attachments to save per email (default `5`).
-
-```json
-{
- "channels": {
- "email": {
- "enabled": true,
- "consentGranted": true,
- "imapHost": "imap.gmail.com",
- "imapPort": 993,
- "imapUsername": "my-nanobot@gmail.com",
- "imapPassword": "your-app-password",
- "smtpHost": "smtp.gmail.com",
- "smtpPort": 587,
- "smtpUsername": "my-nanobot@gmail.com",
- "smtpPassword": "your-app-password",
- "fromAddress": "my-nanobot@gmail.com",
- "allowFrom": ["your-real-email@gmail.com"],
- "allowedAttachmentTypes": ["application/pdf", "image/*"]
- }
- }
-}
-```
-
-
-**3. Run**
-
-```bash
-nanobot gateway
-```
-
-
-
-
-WeChat (ๅพฎไฟก / Weixin)
-
-Uses **HTTP long-poll** with QR-code login via the ilinkai personal WeChat API. No local WeChat desktop client is required.
-
-**1. Install with WeChat support**
-
-```bash
-pip install "nanobot-ai[weixin]"
-```
-
-**2. Configure**
-
-```json
-{
- "channels": {
- "weixin": {
- "enabled": true,
- "allowFrom": ["YOUR_WECHAT_USER_ID"]
- }
- }
-}
-```
-
-> - `allowFrom`: Add the sender ID you see in nanobot logs for your WeChat account. Use `["*"]` to allow all users.
-> - `token`: Optional. If omitted, log in interactively and nanobot will save the token for you.
-> - `routeTag`: Optional. When your upstream Weixin deployment requires request routing, nanobot will send it as the `SKRouteTag` header.
-> - `stateDir`: Optional. Defaults to nanobot's runtime directory for Weixin state.
-> - `pollTimeout`: Optional long-poll timeout in seconds.
-
-**3. Login**
-
-```bash
-nanobot channels login weixin
-```
-
-Use `--force` to re-authenticate and ignore any saved token:
-
-```bash
-nanobot channels login weixin --force
-```
-
-**4. Run**
-
-```bash
-nanobot gateway
-```
-
-
-
-
-Wecom (ไผไธๅพฎไฟก)
-
-> Here we use [wecom-aibot-sdk-python](https://github.com/chengyongru/wecom_aibot_sdk) (community Python version of the official [@wecom/aibot-node-sdk](https://www.npmjs.com/package/@wecom/aibot-node-sdk)).
->
-> Uses **WebSocket** long connection โ no public IP required.
-
-**1. Install the optional dependency**
-
-```bash
-pip install nanobot-ai[wecom]
-```
-
-**2. Create a WeCom AI Bot**
-
-Go to the WeCom admin console โ Intelligent Robot โ Create Robot โ select **API mode** with **long connection**. Copy the Bot ID and Secret.
-
-**3. Configure**
-
-```json
-{
- "channels": {
- "wecom": {
- "enabled": true,
- "botId": "your_bot_id",
- "secret": "your_bot_secret",
- "allowFrom": ["your_id"]
- }
- }
-}
-```
-
-**4. Run**
-
-```bash
-nanobot gateway
-```
-
-
-
-
-Microsoft Teams (MVP โ DM only)
-
-> Direct-message text in/out, tenant-aware OAuth, conversation reference persistence.
-> Uses a public HTTPS webhook โ no WebSocket; you need a tunnel or reverse proxy.
-
-**1. Install the optional dependency**
-
-```bash
-pip install nanobot-ai[msteams]
-```
-
-**2. Create a Teams / Azure bot app registration**
-
-Create or reuse a Microsoft Teams / Azure bot app registration. Set the bot messaging endpoint to a public HTTPS URL ending in `/api/messages`.
-
-**3. Configure**
-
-```json
-{
- "channels": {
- "msteams": {
- "enabled": true,
- "appId": "YOUR_APP_ID",
- "appPassword": "YOUR_APP_SECRET",
- "tenantId": "YOUR_TENANT_ID",
- "host": "0.0.0.0",
- "port": 3978,
- "path": "/api/messages",
- "allowFrom": ["*"],
- "replyInThread": true,
- "mentionOnlyResponse": "Hi โ what can I help with?",
- "validateInboundAuth": true
- }
- }
-}
-```
-
-> - `replyInThread: true` replies to the triggering Teams activity when a stored `activity_id` is available.
-> - `mentionOnlyResponse` controls what Nanobot receives when a user sends only a bot mention (`Nanobot`). Set to `""` to ignore mention-only messages.
-> - `validateInboundAuth: true` enables inbound Bot Framework bearer-token validation (signature, issuer, audience, lifetime, `serviceUrl`). This is the safe default for public deployments. Only set it to `false` for local development or tightly controlled testing.
-
-**4. Run**
-
-```bash
-nanobot gateway
-```
-
-
-
-## ๐ Agent Social Network
-
-๐ nanobot is capable of linking to the agent social network (agent community). **Just send one message and your nanobot joins automatically!**
-
-| Platform | How to Join (send this message to your bot) |
-|----------|-------------|
-| [**Moltbook**](https://www.moltbook.com/) | `Read https://moltbook.com/skill.md and follow the instructions to join Moltbook` |
-| [**ClawdChat**](https://clawdchat.ai/) | `Read https://clawdchat.ai/skill.md and follow the instructions to join ClawdChat` |
-
-Simply send the command above to your nanobot (via CLI or any chat channel), and it will handle the rest.
-
-## โ๏ธ Configuration
-
-Config file: `~/.nanobot/config.json`
-
-> [!NOTE]
-> If your config file is older than the current schema, you can refresh it without overwriting your existing values:
-> run `nanobot onboard`, then answer `N` when asked whether to overwrite the config.
-> nanobot will merge in missing default fields and keep your current settings.
-
-### Environment Variables for Secrets
-
-Instead of storing secrets directly in `config.json`, you can use `${VAR_NAME}` references that are resolved from environment variables at startup:
-
-```json
-{
- "channels": {
- "telegram": { "token": "${TELEGRAM_TOKEN}" },
- "email": {
- "imapPassword": "${IMAP_PASSWORD}",
- "smtpPassword": "${SMTP_PASSWORD}"
- }
- },
- "providers": {
- "groq": { "apiKey": "${GROQ_API_KEY}" }
- }
-}
-```
-
-For **systemd** deployments, use `EnvironmentFile=` in the service unit to load variables from a file that only the deploying user can read:
-
-```ini
-# /etc/systemd/system/nanobot.service (excerpt)
-[Service]
-EnvironmentFile=/home/youruser/nanobot_secrets.env
-User=nanobot
-ExecStart=...
-```
-
-```bash
-# /home/youruser/nanobot_secrets.env (mode 600, owned by youruser)
-TELEGRAM_TOKEN=your-token-here
-IMAP_PASSWORD=your-password-here
-```
-
-### Providers
-
-> [!TIP]
-> - **Voice transcription**: Voice messages (Telegram, WhatsApp) are automatically transcribed using Whisper. By default Groq is used (free tier). Set `"transcriptionProvider": "openai"` under `channels` to use OpenAI Whisper instead โ the API key is picked from the matching provider config.
-> - **MiniMax Coding Plan**: Exclusive discount links for the nanobot community: [Overseas](https://platform.minimax.io/subscribe/coding-plan?code=9txpdXw04g&source=link) ยท [Mainland China](https://platform.minimaxi.com/subscribe/token-plan?code=GILTJpMTqZ&source=link)
-> - **MiniMax (Mainland China)**: If your API key is from MiniMax's mainland China platform (minimaxi.com), set `"apiBase": "https://api.minimaxi.com/v1"` in your minimax provider config.
-> - **MiniMax thinking mode**: Use `providers.minimaxAnthropic` when you want `reasoningEffort` / thinking mode. MiniMax exposes that capability through its Anthropic-compatible endpoint, so nanobot keeps it as a separate provider instead of guessing MiniMax-specific thinking parameters on the generic OpenAI-compatible `minimax` endpoint. It uses the same `MINIMAX_API_KEY`. Default Anthropic-compatible base URL: `https://api.minimax.io/anthropic`; for mainland China use `https://api.minimaxi.com/anthropic`.
-> - **VolcEngine / BytePlus Coding Plan**: Use dedicated providers `volcengineCodingPlan` or `byteplusCodingPlan` instead of the pay-per-use `volcengine` / `byteplus` providers.
-> - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config.
-> - **Alibaba Cloud BaiLian**: If you're using Alibaba Cloud BaiLian's OpenAI-compatible endpoint, set `"apiBase": "https://dashscope.aliyuncs.com/compatible-mode/v1"` in your dashscope provider config.
-> - **Step Fun (Mainland China)**: If your API key is from Step Fun's mainland China platform (stepfun.com), set `"apiBase": "https://api.stepfun.com/v1"` in your stepfun provider config.
-
-| Provider | Purpose | Get API Key |
-|----------|---------|-------------|
-| `custom` | Any OpenAI-compatible endpoint | โ |
-| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
-| `volcengine` | LLM (VolcEngine, pay-per-use) | [Coding Plan](https://www.volcengine.com/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) ยท [volcengine.com](https://www.volcengine.com) |
-| `byteplus` | LLM (VolcEngine international, pay-per-use) | [Coding Plan](https://www.byteplus.com/en/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) ยท [byteplus.com](https://www.byteplus.com) |
-| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
-| `azure_openai` | LLM (Azure OpenAI) | [portal.azure.com](https://portal.azure.com) |
-| `openai` | LLM + Voice transcription (Whisper) | [platform.openai.com](https://platform.openai.com) |
-| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
-| `groq` | LLM + Voice transcription (Whisper, default) | [console.groq.com](https://console.groq.com) |
-| `minimax` | LLM (MiniMax direct) | [platform.minimaxi.com](https://platform.minimaxi.com) |
-| `minimax_anthropic` | LLM (MiniMax Anthropic-compatible endpoint, thinking mode) | [platform.minimaxi.com](https://platform.minimaxi.com) |
-| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
-| `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) |
-| `siliconflow` | LLM (SiliconFlow/็ก
ๅบๆตๅจ) | [siliconflow.cn](https://siliconflow.cn) |
-| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
-| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) |
-| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
-| `mimo` | LLM (MiMo) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) |
-| `ollama` | LLM (local, Ollama) | โ |
-| `lm_studio` | LLM (local, LM Studio) | โ |
-| `mistral` | LLM | [docs.mistral.ai](https://docs.mistral.ai/) |
-| `stepfun` | LLM (Step Fun/้ถ่ทๆ่พฐ) | [platform.stepfun.com](https://platform.stepfun.com) |
-| `ovms` | LLM (local, OpenVINO Model Server) | [docs.openvino.ai](https://docs.openvino.ai/2026/model-server/ovms_docs_llm_quickstart.html) |
-| `vllm` | LLM (local, any OpenAI-compatible server) | โ |
-| `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex` |
-| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
-| `qianfan` | LLM (Baidu Qianfan) | [cloud.baidu.com](https://cloud.baidu.com/doc/qianfan/s/Hmh4suq26) |
-
-
-
-OpenAI Codex (OAuth)
-
-Codex uses OAuth instead of API keys. Requires a ChatGPT Plus or Pro account.
-No `providers.openaiCodex` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config.
-
-**1. Login:**
-```bash
-nanobot provider login openai-codex
-```
-
-**2. Set model** (merge into `~/.nanobot/config.json`):
-```json
-{
- "agents": {
- "defaults": {
- "model": "openai-codex/gpt-5.1-codex"
- }
- }
-}
-```
-
-**3. Chat:**
-```bash
-nanobot agent -m "Hello!"
-
-# Target a specific workspace/config locally
-nanobot agent -c ~/.nanobot-telegram/config.json -m "Hello!"
-
-# One-off workspace override on top of that config
-nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -m "Hello!"
-```
-
-> Docker users: use `docker run -it` for interactive OAuth login.
-
-
-
-
-
-GitHub Copilot (OAuth)
-
-GitHub Copilot uses OAuth instead of API keys. Requires a [GitHub account with a plan](https://github.com/features/copilot/plans) configured.
-No `providers.githubCopilot` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config.
-
-**1. Login:**
-```bash
-nanobot provider login github-copilot
-```
-
-**2. Set model** (merge into `~/.nanobot/config.json`):
-```json
-{
- "agents": {
- "defaults": {
- "model": "github-copilot/gpt-4.1"
- }
- }
-}
-```
-
-**3. Chat:**
-```bash
-nanobot agent -m "Hello!"
-
-# Target a specific workspace/config locally
-nanobot agent -c ~/.nanobot-telegram/config.json -m "Hello!"
-
-# One-off workspace override on top of that config
-nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -m "Hello!"
-```
-
-> Docker users: use `docker run -it` for interactive OAuth login.
-
-
-
-
-Custom Provider (Any OpenAI-compatible API)
-
-Connects directly to any OpenAI-compatible endpoint โ llama.cpp, Together AI, Fireworks, Azure OpenAI, or any self-hosted server. Model name is passed as-is.
-
-```json
-{
- "providers": {
- "custom": {
- "apiKey": "your-api-key",
- "apiBase": "https://api.your-provider.com/v1"
- }
- },
- "agents": {
- "defaults": {
- "model": "your-model-name"
- }
- }
-}
-```
-
-> For local servers that don't require authentication, set `apiKey` to `null`.
->
-> `custom` is the right choice for providers that expose an OpenAI-compatible **chat completions** API. It does **not** force third-party endpoints onto the OpenAI/Azure **Responses API**.
->
-> If your proxy or gateway is specifically Responses-API-compatible, use the `azure_openai` provider shape instead and point `apiBase` at that endpoint:
->
-> ```json
-> {
-> "providers": {
-> "azure_openai": {
-> "apiKey": "your-api-key",
-> "apiBase": "https://api.your-provider.com",
-> "defaultModel": "your-model-name"
-> }
-> },
-> "agents": {
-> "defaults": {
-> "provider": "azure_openai",
-> "model": "your-model-name"
-> }
-> }
-> }
-> ```
->
-> In short: **chat-completions-compatible endpoint โ `custom`**; **Responses-compatible endpoint โ `azure_openai`**.
-
-
-
-
-Ollama (local)
-
-Run a local model with Ollama, then add to config:
-
-**1. Start Ollama** (example):
-```bash
-ollama run llama3.2
-```
-
-**2. Add to config** (partial โ merge into `~/.nanobot/config.json`):
-```json
-{
- "providers": {
- "ollama": {
- "apiBase": "http://localhost:11434"
- }
- },
- "agents": {
- "defaults": {
- "provider": "ollama",
- "model": "llama3.2"
- }
- }
-}
-```
-
-> `provider: "auto"` also works when `providers.ollama.apiBase` is configured, but setting `"provider": "ollama"` is the clearest option.
-
-
-
-
-LM Studio (local)
-
-[LM Studio](https://lmstudio.ai/) provides a local OpenAI-compatible server for running LLMs. Download models through the LM Studio UI, then start the local server.
-
-**1. Start LM Studio server:**
-- Launch LM Studio
-- Go to the "Local Server" tab
-- Load a model (e.g., Llama, Mistral, Qwen)
-- Click "Start Server" (default port: 1234)
-
-**2. Add to config** (partial โ merge into `~/.nanobot/config.json`):
-```json
-{
- "providers": {
- "lm_studio": {
- "apiKey": null,
- "apiBase": "http://localhost:1234/v1"
- }
- },
- "agents": {
- "defaults": {
- "provider": "lm_studio",
- "model": "local-model"
- }
- }
-}
-```
-
-> **Note:** Set `apiKey` to `null` for LM Studio since it runs locally and doesn't require authentication. The model name should match what's shown in the LM Studio UI.
-> `provider: "auto"` also works when `providers.lm_studio.apiBase` is configured, but setting `"provider": "lm_studio"` is the clearest option.
-
-
-
-
-OpenVINO Model Server (local / OpenAI-compatible)
-
-Run LLMs locally on Intel GPUs using [OpenVINO Model Server](https://docs.openvino.ai/2026/model-server/ovms_docs_llm_quickstart.html). OVMS exposes an OpenAI-compatible API at `/v3`.
-
-> Requires Docker and an Intel GPU with driver access (`/dev/dri`).
-
-**1. Pull the model** (example):
-
-```bash
-mkdir -p ov/models && cd ov
-
-docker run -d \
- --rm \
- --user $(id -u):$(id -g) \
- -v $(pwd)/models:/models \
- openvino/model_server:latest-gpu \
- --pull \
- --model_name openai/gpt-oss-20b \
- --model_repository_path /models \
- --source_model OpenVINO/gpt-oss-20b-int4-ov \
- --task text_generation \
- --tool_parser gptoss \
- --reasoning_parser gptoss \
- --enable_prefix_caching true \
- --target_device GPU
-```
-
-> This downloads the model weights. Wait for the container to finish before proceeding.
-
-**2. Start the server** (example):
-
-```bash
-docker run -d \
- --rm \
- --name ovms \
- --user $(id -u):$(id -g) \
- -p 8000:8000 \
- -v $(pwd)/models:/models \
- --device /dev/dri \
- --group-add=$(stat -c "%g" /dev/dri/render* | head -n 1) \
- openvino/model_server:latest-gpu \
- --rest_port 8000 \
- --model_name openai/gpt-oss-20b \
- --model_repository_path /models \
- --source_model OpenVINO/gpt-oss-20b-int4-ov \
- --task text_generation \
- --tool_parser gptoss \
- --reasoning_parser gptoss \
- --enable_prefix_caching true \
- --target_device GPU
-```
-
-**3. Add to config** (partial โ merge into `~/.nanobot/config.json`):
-
-```json
-{
- "providers": {
- "ovms": {
- "apiBase": "http://localhost:8000/v3"
- }
- },
- "agents": {
- "defaults": {
- "provider": "ovms",
- "model": "openai/gpt-oss-20b"
- }
- }
-}
-```
-
-> OVMS is a local server โ no API key required. Supports tool calling (`--tool_parser gptoss`), reasoning (`--reasoning_parser gptoss`), and streaming.
-> See the [official OVMS docs](https://docs.openvino.ai/2026/model-server/ovms_docs_llm_quickstart.html) for more details.
-
-
-
-vLLM (local / OpenAI-compatible)
-
-Run your own model with vLLM or any OpenAI-compatible server, then add to config:
-
-**1. Start the server** (example):
-```bash
-vllm serve meta-llama/Llama-3.1-8B-Instruct --port 8000
-```
-
-**2. Add to config** (partial โ merge into `~/.nanobot/config.json`):
-
-*Provider (set API key to null for local servers):*
-```json
-{
- "providers": {
- "vllm": {
- "apiKey": null,
- "apiBase": "http://localhost:8000/v1"
- }
- }
-}
-```
-
-*Model:*
-```json
-{
- "agents": {
- "defaults": {
- "model": "meta-llama/Llama-3.1-8B-Instruct"
- }
- }
-}
-```
-
-
-
-
-Adding a New Provider (Developer Guide)
-
-nanobot uses a **Provider Registry** (`nanobot/providers/registry.py`) as the single source of truth.
-Adding a new provider only takes **2 steps** โ no if-elif chains to touch.
-
-**Step 1.** Add a `ProviderSpec` entry to `PROVIDERS` in `nanobot/providers/registry.py`:
-
-```python
-ProviderSpec(
- name="myprovider", # config field name
- keywords=("myprovider", "mymodel"), # model-name keywords for auto-matching
- env_key="MYPROVIDER_API_KEY", # env var name
- display_name="My Provider", # shown in `nanobot status`
- default_api_base="https://api.myprovider.com/v1", # OpenAI-compatible endpoint
-)
-```
-
-**Step 2.** Add a field to `ProvidersConfig` in `nanobot/config/schema.py`:
-
-```python
-class ProvidersConfig(BaseModel):
- ...
- myprovider: ProviderConfig = ProviderConfig()
-```
-
-That's it! Environment variables, model routing, config matching, and `nanobot status` display will all work automatically.
-
-**Common `ProviderSpec` options:**
-
-| Field | Description | Example |
-|-------|-------------|---------|
-| `default_api_base` | OpenAI-compatible base URL | `"https://api.deepseek.com"` |
-| `env_extras` | Additional env vars to set | `(("ZHIPUAI_API_KEY", "{api_key}"),)` |
-| `model_overrides` | Per-model parameter overrides | `(("kimi-k2.5", {"temperature": 1.0}),)` |
-| `is_gateway` | Can route any model (like OpenRouter) | `True` |
-| `detect_by_key_prefix` | Detect gateway by API key prefix | `"sk-or-"` |
-| `detect_by_base_keyword` | Detect gateway by API base URL | `"openrouter"` |
-| `strip_model_prefix` | Strip provider prefix before sending to gateway | `True` (for AiHubMix) |
-| `supports_max_completion_tokens` | Use `max_completion_tokens` instead of `max_tokens`; required for providers that reject both being set simultaneously (e.g. VolcEngine) | `True` |
-
-
-
-### Channel Settings
-
-Global settings that apply to all channels. Configure under the `channels` section in `~/.nanobot/config.json`:
-
-```json
-{
- "channels": {
- "sendProgress": true,
- "sendToolHints": false,
- "sendMaxRetries": 3,
- "transcriptionProvider": "groq",
- "telegram": { ... }
- }
-}
-```
-
-| Setting | Default | Description |
-|---------|---------|-------------|
-| `sendProgress` | `true` | Stream agent's text progress to the channel |
-| `sendToolHints` | `false` | Stream tool-call hints (e.g. `read_file("โฆ")`) |
-| `sendMaxRetries` | `3` | Max delivery attempts per outbound message, including the initial send (0-10 configured, minimum 1 actual attempt) |
-| `transcriptionProvider` | `"groq"` | Voice transcription backend: `"groq"` (free tier, default) or `"openai"`. API key is auto-resolved from the matching provider config. |
-
-#### Retry Behavior
-
-Retry is intentionally simple.
-
-When a channel `send()` raises, nanobot retries at the channel-manager layer. By default, `channels.sendMaxRetries` is `3`, and that count includes the initial send.
-
-- **Attempt 1**: Send immediately
-- **Attempt 2**: Retry after `1s`
-- **Attempt 3**: Retry after `2s`
-- **Higher retry budgets**: Backoff continues as `1s`, `2s`, `4s`, then stays capped at `4s`
-- **Transient failures**: Network hiccups and temporary API limits often recover on the next attempt
-- **Permanent failures**: Invalid tokens, revoked access, or banned channels will exhaust the retry budget and fail cleanly
-
-> [!NOTE]
-> This design is deliberate: channel implementations should raise on delivery failure, and the channel manager owns the shared retry policy.
->
-> Some channels may still apply small API-specific retries internally. For example, Telegram separately retries timeout and flood-control errors before surfacing a final failure to the manager.
->
-> If a channel is completely unreachable, nanobot cannot notify the user through that same channel. Watch logs for `Failed to send to {channel} after N attempts` to spot persistent delivery failures.
-
-### Web Search
-
-> [!TIP]
-> Use `proxy` in `tools.web` to route all web requests (search + fetch) through a proxy:
-> ```json
-> { "tools": { "web": { "proxy": "http://127.0.0.1:7890" } } }
-> ```
-
-nanobot supports multiple web search providers. Configure in `~/.nanobot/config.json` under `tools.web.search`.
-
-By default, web tools are enabled and web search uses `duckduckgo`, so search works out of the box without an API key.
-
-If you want to disable all built-in web tools entirely, set `tools.web.enable` to `false`. This removes both `web_search` and `web_fetch` from the tool list sent to the LLM.
-
-If you need to allow trusted private ranges such as Tailscale / CGNAT addresses, you can explicitly exempt them from SSRF blocking with `tools.ssrfWhitelist`:
-
-```json
-{
- "tools": {
- "ssrfWhitelist": ["100.64.0.0/10"]
- }
-}
-```
-
-| Provider | Config fields | Env var fallback | Free |
-|----------|--------------|------------------|------|
-| `brave` | `apiKey` | `BRAVE_API_KEY` | No |
-| `tavily` | `apiKey` | `TAVILY_API_KEY` | No |
-| `jina` | `apiKey` | `JINA_API_KEY` | Free tier (10M tokens) |
-| `kagi` | `apiKey` | `KAGI_API_KEY` | No |
-| `searxng` | `baseUrl` | `SEARXNG_BASE_URL` | Yes (self-hosted) |
-| `duckduckgo` (default) | โ | โ | Yes |
-
-**Disable all built-in web tools:**
-```json
-{
- "tools": {
- "web": {
- "enable": false
- }
- }
-}
-```
-
-**Brave:**
-```json
-{
- "tools": {
- "web": {
- "search": {
- "provider": "brave",
- "apiKey": "BSA..."
- }
- }
- }
-}
-```
-
-**Tavily:**
-```json
-{
- "tools": {
- "web": {
- "search": {
- "provider": "tavily",
- "apiKey": "tvly-..."
- }
- }
- }
-}
-```
-
-**Jina** (free tier with 10M tokens):
-```json
-{
- "tools": {
- "web": {
- "search": {
- "provider": "jina",
- "apiKey": "jina_..."
- }
- }
- }
-}
-```
-
-**Kagi:**
-```json
-{
- "tools": {
- "web": {
- "search": {
- "provider": "kagi",
- "apiKey": "your-kagi-api-key"
- }
- }
- }
-}
-```
-
-**SearXNG** (self-hosted, no API key needed):
-```json
-{
- "tools": {
- "web": {
- "search": {
- "provider": "searxng",
- "baseUrl": "https://searx.example"
- }
- }
- }
-}
-```
-
-**DuckDuckGo** (zero config):
-```json
-{
- "tools": {
- "web": {
- "search": {
- "provider": "duckduckgo"
- }
- }
- }
-}
-```
-
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `enable` | boolean | `true` | Enable or disable all built-in web tools (`web_search` + `web_fetch`) |
-| `proxy` | string or null | `null` | Proxy for all web requests, for example `http://127.0.0.1:7890` |
-
-#### `tools.web.search`
-
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `provider` | string | `"duckduckgo"` | Search backend: `brave`, `tavily`, `jina`, `searxng`, `duckduckgo` |
-| `apiKey` | string | `""` | API key for Brave or Tavily |
-| `baseUrl` | string | `""` | Base URL for SearXNG |
-| `maxResults` | integer | `5` | Results per search (1โ10) |
-
-### MCP (Model Context Protocol)
-
-> [!TIP]
-> The config format is compatible with Claude Desktop / Cursor. You can copy MCP server configs directly from any MCP server's README.
-
-nanobot supports [MCP](https://modelcontextprotocol.io/) โ connect external tool servers and use them as native agent tools.
-
-Add MCP servers to your `config.json`:
-
-```json
-{
- "tools": {
- "mcpServers": {
- "filesystem": {
- "command": "npx",
- "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]
- },
- "my-remote-mcp": {
- "url": "https://example.com/mcp/",
- "headers": {
- "Authorization": "Bearer xxxxx"
- }
- }
- }
- }
-}
-```
-
-Two transport modes are supported:
-
-| Mode | Config | Example |
-|------|--------|---------|
-| **Stdio** | `command` + `args` | Local process via `npx` / `uvx` |
-| **HTTP** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/sse`) |
-
-Use `toolTimeout` to override the default 30s per-call timeout for slow servers:
-
-```json
-{
- "tools": {
- "mcpServers": {
- "my-slow-server": {
- "url": "https://example.com/mcp/",
- "toolTimeout": 120
- }
- }
- }
-}
-```
-
-Use `enabledTools` to register only a subset of tools from an MCP server:
-
-```json
-{
- "tools": {
- "mcpServers": {
- "filesystem": {
- "command": "npx",
- "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"],
- "enabledTools": ["read_file", "mcp_filesystem_write_file"]
- }
- }
- }
-}
-```
-
-`enabledTools` accepts either the raw MCP tool name (for example `read_file`) or the wrapped nanobot tool name (for example `mcp_filesystem_write_file`).
-
-- Omit `enabledTools`, or set it to `["*"]`, to register all tools.
-- Set `enabledTools` to `[]` to register no tools from that server.
-- Set `enabledTools` to a non-empty list of names to register only that subset.
-
-MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools โ no extra configuration needed.
-
-
-
-
-### Security
-
-> [!TIP]
-> For production deployments, set `"restrictToWorkspace": true` and `"tools.exec.sandbox": "bwrap"` in your config to sandbox the agent.
-> In `v0.1.4.post3` and earlier, an empty `allowFrom` allowed all senders. Since `v0.1.4.post4`, empty `allowFrom` denies all access by default. To allow all senders, set `"allowFrom": ["*"]`.
-
-| Option | Default | Description |
-|--------|---------|-------------|
-| `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. |
-| `tools.exec.sandbox` | `""` | Sandbox backend for shell commands. Set to `"bwrap"` to wrap exec calls in a [bubblewrap](https://github.com/containers/bubblewrap) sandbox โ the process can only see the workspace (read-write) and media directory (read-only); config files and API keys are hidden. Automatically enables `restrictToWorkspace` for file tools. **Linux only** โ requires `bwrap` installed (`apt install bubblewrap`; pre-installed in the Docker image). Not available on macOS or Windows (bwrap depends on Linux kernel namespaces). |
-| `tools.exec.enable` | `true` | When `false`, the shell `exec` tool is not registered at all. Use this to completely disable shell command execution. |
-| `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). |
-| `channels.*.allowFrom` | `[]` (deny all) | Whitelist of user IDs. Empty denies all; use `["*"]` to allow everyone. |
-
-**Docker security**: The official Docker image runs as a non-root user (`nanobot`, UID 1000) with bubblewrap pre-installed. When using `docker-compose.yml`, the container drops all Linux capabilities except `SYS_ADMIN` (required for bwrap's namespace isolation).
-
-
-### Auto Compact
-
-When a user is idle for longer than a configured threshold, nanobot **proactively** compresses the older part of the session context into a summary while keeping a recent legal suffix of live messages. This reduces token cost and first-token latency when the user returns โ instead of re-processing a long stale context with an expired KV cache, the model receives a compact summary, the most recent live context, and fresh input.
-
-```json
-{
- "agents": {
- "defaults": {
- "idleCompactAfterMinutes": 15
- }
- }
-}
-```
-
-| Option | Default | Description |
-|--------|---------|-------------|
-| `agents.defaults.idleCompactAfterMinutes` | `0` (disabled) | Minutes of idle time before auto-compaction starts. Set to `0` to disable. Recommended: `15` โ close to a typical LLM KV cache expiry window, so stale sessions get compacted before the user returns. |
-
-`sessionTtlMinutes` remains accepted as a legacy alias for backward compatibility, but `idleCompactAfterMinutes` is the preferred config key going forward.
-
-How it works:
-1. **Idle detection**: On each idle tick (~1 s), checks all sessions for expiration.
-2. **Background compaction**: Idle sessions summarize the older live prefix via LLM and keep the most recent legal suffix (currently 8 messages).
-3. **Summary injection**: When the user returns, the summary is injected as runtime context (one-shot, not persisted) alongside the retained recent suffix.
-4. **Restart-safe resume**: The summary is also mirrored into session metadata so it can still be recovered after a process restart.
-
-> [!NOTE]
-> Mental model: "summarize older context, keep the freshest live turns, **and overwrite the session file with the compact form.**" It is not a full `session.clear()`, but it is a write โ not a soft cursor move.
->
-> Concretely, auto compact rewrites `sessions/.jsonl` in place: older messages (including their structured `tool_calls` / `tool_call_id` / `reasoning_content`) are replaced by just the retained recent suffix (currently 8 messages), while the archived prefix is preserved only as a plain-text summary appended to `memory/history.jsonl` (or a `[RAW] ...` flattened dump if LLM summarization fails). The original structured JSON of those turns is no longer recoverable from the session file.
->
-> This differs from the **token-driven soft consolidation** that fires when a prompt exceeds the context budget: that path only advances an internal `last_consolidated` cursor and leaves the session file untouched, so the raw tool-call trail stays on disk and can still be replayed or audited. If you rely on that trail for debugging or auditing, leave `idleCompactAfterMinutes` at the default `0` and let only the token-driven path run.
-
-### Timezone
-
-Time is context. Context should be precise.
-
-By default, nanobot uses `UTC` for runtime time context. If you want the agent to think in your local time, set `agents.defaults.timezone` to a valid [IANA timezone name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones):
-
-```json
-{
- "agents": {
- "defaults": {
- "timezone": "Asia/Shanghai"
- }
- }
-}
-```
-
-This affects runtime time strings shown to the model, such as runtime context and heartbeat prompts. It also becomes the default timezone for cron schedules when a cron expression omits `tz`, and for one-shot `at` times when the ISO datetime has no explicit offset.
-
-Common examples: `UTC`, `America/New_York`, `America/Los_Angeles`, `Europe/London`, `Europe/Berlin`, `Asia/Tokyo`, `Asia/Shanghai`, `Asia/Singapore`, `Australia/Sydney`.
-
-> Need another timezone? Browse the full [IANA Time Zone Database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
-
-### Unified Session
-
-By default, each channel ร chat ID combination gets its own session. If you use nanobot across multiple channels (e.g. Telegram + Discord + CLI) and want them to share the same conversation, enable `unifiedSession`:
-
-```json
-{
- "agents": {
- "defaults": {
- "unifiedSession": true
- }
- }
-}
-```
-
-When enabled, all incoming messages โ regardless of which channel they arrive on โ are routed into a single shared session. Switching from Telegram to Discord (or any other channel) continues the same conversation seamlessly.
-
-| Behavior | `false` (default) | `true` |
-|----------|-------------------|--------|
-| Session key | `channel:chat_id` | `unified:default` |
-| Cross-channel continuity | No | Yes |
-| `/new` clears | Current channel session | Shared session |
-| `/stop` finds tasks | By channel session | By shared session |
-| Existing `session_key_override` (e.g. Telegram thread) | Respected | Still respected โ not overwritten |
-
-> This is designed for single-user, multi-device setups. It is **off by default** โ existing users see zero behavior change.
-
-### Disabled Skills
-
-nanobot ships with built-in skills, and your workspace can also define custom skills under `skills/`. If you want to hide specific skills from the agent, set `agents.defaults.disabledSkills` to a list of skill directory names:
-
-```json
-{
- "agents": {
- "defaults": {
- "disabledSkills": ["github", "weather"]
- }
- }
-}
-```
-
-Disabled skills are excluded from the main agent's skill summary, from always-on skill injection, and from subagent skill summaries. This is useful when some bundled skills are unnecessary for your deployment or should not be exposed to end users.
-
-| Option | Default | Description |
-|--------|---------|-------------|
-| `agents.defaults.disabledSkills` | `[]` | List of skill directory names to exclude from loading. Applies to both built-in skills and workspace skills. |
-
-## ๐งฉ Multiple Instances
-
-Run multiple nanobot instances simultaneously with separate configs and runtime data. Use `--config` as the main entrypoint. Optionally pass `--workspace` during `onboard` when you want to initialize or update the saved workspace for a specific instance.
-
-### Quick Start
-
-If you want each instance to have its own dedicated workspace from the start, pass both `--config` and `--workspace` during onboarding.
-
-**Initialize instances:**
-
-```bash
-# Create separate instance configs and workspaces
-nanobot onboard --config ~/.nanobot-telegram/config.json --workspace ~/.nanobot-telegram/workspace
-nanobot onboard --config ~/.nanobot-discord/config.json --workspace ~/.nanobot-discord/workspace
-nanobot onboard --config ~/.nanobot-feishu/config.json --workspace ~/.nanobot-feishu/workspace
-```
-
-**Configure each instance:**
-
-Edit `~/.nanobot-telegram/config.json`, `~/.nanobot-discord/config.json`, etc. with different channel settings. The workspace you passed during `onboard` is saved into each config as that instance's default workspace.
-
-**Run instances:**
-
-```bash
-# Instance A - Telegram bot
-nanobot gateway --config ~/.nanobot-telegram/config.json
-
-# Instance B - Discord bot
-nanobot gateway --config ~/.nanobot-discord/config.json
-
-# Instance C - Feishu bot with custom port
-nanobot gateway --config ~/.nanobot-feishu/config.json --port 18792
-```
-
-### Path Resolution
-
-When using `--config`, nanobot derives its runtime data directory from the config file location. The workspace still comes from `agents.defaults.workspace` unless you override it with `--workspace`.
-
-To open a CLI session against one of these instances locally:
-
-```bash
-nanobot agent -c ~/.nanobot-telegram/config.json -m "Hello from Telegram instance"
-nanobot agent -c ~/.nanobot-discord/config.json -m "Hello from Discord instance"
-
-# Optional one-off workspace override
-nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test
-```
-
-> `nanobot agent` starts a local CLI agent using the selected workspace/config. It does not attach to or proxy through an already running `nanobot gateway` process.
-
-| Component | Resolved From | Example |
-|-----------|---------------|---------|
-| **Config** | `--config` path | `~/.nanobot-A/config.json` |
-| **Workspace** | `--workspace` or config | `~/.nanobot-A/workspace/` |
-| **Cron Jobs** | config directory | `~/.nanobot-A/cron/` |
-| **Media / runtime state** | config directory | `~/.nanobot-A/media/` |
-
-### How It Works
-
-- `--config` selects which config file to load
-- By default, the workspace comes from `agents.defaults.workspace` in that config
-- If you pass `--workspace`, it overrides the workspace from the config file
-
-### Minimal Setup
-
-1. Copy your base config into a new instance directory.
-2. Set a different `agents.defaults.workspace` for that instance.
-3. Start the instance with `--config`.
-
-Example config:
-
-```json
-{
- "agents": {
- "defaults": {
- "workspace": "~/.nanobot-telegram/workspace",
- "model": "anthropic/claude-sonnet-4-6"
- }
- },
- "channels": {
- "telegram": {
- "enabled": true,
- "token": "YOUR_TELEGRAM_BOT_TOKEN"
- }
- },
- "gateway": {
- "host": "127.0.0.1",
- "port": 18790
- }
-}
-```
-
-Start separate instances:
-
-```bash
-nanobot gateway --config ~/.nanobot-telegram/config.json
-nanobot gateway --config ~/.nanobot-discord/config.json
-```
-
-Each gateway instance also exposes a lightweight HTTP health endpoint on
-`gateway.host:gateway.port`. By default, the gateway binds to `127.0.0.1`,
-so the endpoint stays local unless you explicitly set `gateway.host` to a
-public or LAN-facing address.
-
-- `GET /health` returns `{"status":"ok"}`
-- Other paths return `404`
-
-Override workspace for one-off runs when needed:
-
-```bash
-nanobot gateway --config ~/.nanobot-telegram/config.json --workspace /tmp/nanobot-telegram-test
-```
-
-### Common Use Cases
-
-- Run separate bots for Telegram, Discord, Feishu, and other platforms
-- Keep testing and production instances isolated
-- Use different models or providers for different teams
-- Serve multiple tenants with separate configs and runtime data
-
-### Notes
-
-- Each instance must use a different port if they run at the same time
-- Use a different workspace per instance if you want isolated memory, sessions, and skills
-- `--workspace` overrides the workspace defined in the config file
-- Cron jobs and runtime media/state are derived from the config directory
-
-## ๐ง Memory
-
-nanobot uses a layered memory system designed to stay light in the moment and durable over
-time.
-
-- `memory/history.jsonl` stores append-only summarized history
-- `SOUL.md`, `USER.md`, and `memory/MEMORY.md` store long-term knowledge managed by Dream
-- `Dream` can also promote repeated workflows into reusable workspace skills under `skills/`
-- `Dream` runs on a schedule and can also be triggered manually
-- memory changes can be inspected and restored with built-in commands
-
-If you want the full design, see [docs/MEMORY.md](docs/MEMORY.md).
-
-## ๐ป CLI Reference
-
-| Command | Description |
-|---------|-------------|
-| `nanobot onboard` | Initialize config & workspace at `~/.nanobot/` |
-| `nanobot onboard --wizard` | Launch the interactive onboarding wizard |
-| `nanobot onboard -c -w ` | Initialize or refresh a specific instance config and workspace |
-| `nanobot agent -m "..."` | Chat with the agent |
-| `nanobot agent -w ` | Chat against a specific workspace |
-| `nanobot agent -w -c ` | Chat against a specific workspace/config |
-| `nanobot agent` | Interactive chat mode |
-| `nanobot agent --no-markdown` | Show plain-text replies |
-| `nanobot agent --logs` | Show runtime logs during chat |
-| `nanobot serve` | Start the OpenAI-compatible API |
-| `nanobot gateway` | Start the gateway |
-| `nanobot status` | Show status |
-| `nanobot provider login openai-codex` | OAuth login for providers |
-| `nanobot channels login ` | Authenticate a channel interactively |
-| `nanobot channels status` | Show channel status |
-
-Interactive mode exits: `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
-
-## ๐ฌ In-Chat Commands
-
-These commands work inside chat channels and interactive agent sessions:
-
-| Command | Description |
-|---------|-------------|
-| `/new` | Start a new conversation |
-| `/stop` | Stop the current task |
-| `/restart` | Restart the bot |
-| `/status` | Show bot status |
-| `/dream` | Run Dream memory consolidation now |
-| `/dream-log` | Show the latest Dream memory change |
-| `/dream-log ` | Show a specific Dream memory change |
-| `/dream-restore` | List recent Dream memory versions |
-| `/dream-restore ` | Restore memory to the state before a specific change |
-| `/help` | Show available in-chat commands |
-
-
-Heartbeat (Periodic Tasks)
-
-The gateway wakes up every 30 minutes and checks `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). If the file has tasks, the agent executes them and delivers results to your most recently active chat channel.
-
-**Setup:** edit `~/.nanobot/workspace/HEARTBEAT.md` (created automatically by `nanobot onboard`):
-
-```markdown
-## Periodic Tasks
-
-- [ ] Check weather forecast and send a summary
-- [ ] Scan inbox for urgent emails
-```
-
-The agent can also manage this file itself โ ask it to "add a periodic task" and it will update `HEARTBEAT.md` for you.
-
-> **Note:** The gateway must be running (`nanobot gateway`) and you must have chatted with the bot at least once so it knows which channel to deliver to.
-
-
-
-## ๐ Python SDK
-
-Use nanobot as a library โ no CLI, no gateway, just Python:
-
-```python
-from nanobot import Nanobot
-
-bot = Nanobot.from_config()
-result = await bot.run("Summarize the README")
-print(result.content)
-```
-
-Each call carries a `session_key` for conversation isolation โ different keys get independent history:
-
-```python
-await bot.run("hi", session_key="user-alice")
-await bot.run("hi", session_key="task-42")
-```
-
-Add lifecycle hooks to observe or customize the agent:
-
-```python
-from nanobot.agent import AgentHook, AgentHookContext
-
-class AuditHook(AgentHook):
- async def before_execute_tools(self, ctx: AgentHookContext) -> None:
- for tc in ctx.tool_calls:
- print(f"[tool] {tc.name}")
-
-result = await bot.run("Hello", hooks=[AuditHook()])
-```
-
-See [docs/PYTHON_SDK.md](docs/PYTHON_SDK.md) for the full SDK reference.
-
-## ๐ OpenAI-Compatible API
-
-nanobot can expose a minimal OpenAI-compatible endpoint for local integrations:
-
-```bash
-pip install "nanobot-ai[api]"
-nanobot serve
-```
-
-By default, the API binds to `127.0.0.1:8900`. You can change this in `config.json`.
-
-### Behavior
-
-- Session isolation: pass `"session_id"` in the request body to isolate conversations; omit for a shared default session (`api:default`)
-- Single-message input: each request must contain exactly one `user` message
-- Fixed model: omit `model`, or pass the same model shown by `/v1/models`
-- Streaming: set `stream=true` to receive Server-Sent Events (`text/event-stream`) with OpenAI-compatible delta chunks, terminated by `data: [DONE]`; omit or set `stream=false` for a single JSON response
-- **File uploads**: supports images, PDF, Word (.docx), Excel (.xlsx), PowerPoint (.pptx) via JSON base64 or `multipart/form-data` (max 10MB per file)
-- API requests run in the synthetic `api` channel, so the `message` tool does **not** automatically deliver to Telegram/Discord/etc. To proactively send to another chat, call `message` with an explicit `channel` and `chat_id` for an enabled channel.
-
-Example tool call for cross-channel delivery from an API session:
-
-```json
-{
- "content": "Build finished successfully.",
- "channel": "telegram",
- "chat_id": "123456789"
-}
-```
-
-If `channel` points to a channel that is not enabled in your config, nanobot will queue the outbound event but no platform delivery will occur.
-
-### Endpoints
-
-- `GET /health`
-- `GET /v1/models`
-- `POST /v1/chat/completions`
-
-### curl
-
-```bash
-curl http://127.0.0.1:8900/v1/chat/completions \
- -H "Content-Type: application/json" \
- -d '{
- "messages": [{"role": "user", "content": "hi"}],
- "session_id": "my-session"
- }'
-```
-
-### File Upload (JSON base64)
-
-Send images inline using the OpenAI multimodal content format:
-
-```bash
-curl http://127.0.0.1:8900/v1/chat/completions \
- -H "Content-Type: application/json" \
- -d '{
- "messages": [{"role": "user", "content": [
- {"type": "text", "text": "Describe this image"},
- {"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBOR..."}}
- ]}]
- }'
-```
-
-### File Upload (multipart/form-data)
-
-Upload any supported file type (images, PDF, Word, Excel, PPT) via multipart:
-
-```bash
-# Single file
-curl http://127.0.0.1:8900/v1/chat/completions \
- -F "message=Summarize this report" \
- -F "files=@report.docx"
-
-# Multiple files with session isolation
-curl http://127.0.0.1:8900/v1/chat/completions \
- -F "message=Compare these files" \
- -F "files=@chart.png" \
- -F "files=@data.xlsx" \
- -F "session_id=my-session"
-```
-
-Supported file types:
-- **Images**: PNG, JPEG, GIF, WebP (sent to AI as base64 for vision analysis)
-- **Documents**: PDF, Word (.docx), Excel (.xlsx), PowerPoint (.pptx) (text extracted and sent to AI)
-- **Text**: TXT, Markdown, CSV, JSON, etc. (read directly)
-
-### Python (`requests`)
-
-```python
-import requests
-
-resp = requests.post(
- "http://127.0.0.1:8900/v1/chat/completions",
- json={
- "messages": [{"role": "user", "content": "hi"}],
- "session_id": "my-session", # optional: isolate conversation
- },
- timeout=120,
-)
-resp.raise_for_status()
-print(resp.json()["choices"][0]["message"]["content"])
-```
-
-### Python (`openai`)
-
-```python
-from openai import OpenAI
-
-client = OpenAI(
- base_url="http://127.0.0.1:8900/v1",
- api_key="dummy",
-)
-
-resp = client.chat.completions.create(
- model="MiniMax-M2.7",
- messages=[{"role": "user", "content": "hi"}],
- extra_body={"session_id": "my-session"}, # optional: isolate conversation
-)
-print(resp.choices[0].message.content)
-```
-
-## ๐ณ Docker
-
-> [!TIP]
-> The `-v ~/.nanobot:/home/nanobot/.nanobot` flag mounts your local config directory into the container, so your config and workspace persist across container restarts.
-> The container runs as user `nanobot` (UID 1000). If you get **Permission denied**, fix ownership on the host first: `sudo chown -R 1000:1000 ~/.nanobot`, or pass `--user $(id -u):$(id -g)` to match your host UID. Podman users can use `--userns=keep-id` instead.
-
-### Docker Compose
-
-```bash
-docker compose run --rm nanobot-cli onboard # first-time setup
-vim ~/.nanobot/config.json # add API keys
-docker compose up -d nanobot-gateway # start gateway
-```
-
-```bash
-docker compose run --rm nanobot-cli agent -m "Hello!" # run CLI
-docker compose logs -f nanobot-gateway # view logs
-docker compose down # stop
-```
-
-### Docker
-
-```bash
-# Build the image
-docker build -t nanobot .
-
-# Initialize config (first time only)
-docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot onboard
-
-# Edit config on host to add API keys
-vim ~/.nanobot/config.json
-
-# Run gateway (connects to enabled channels, e.g. Telegram/Discord/Mochat)
-docker run -v ~/.nanobot:/home/nanobot/.nanobot -p 18790:18790 nanobot gateway
-
-# Or run a single command
-docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot agent -m "Hello!"
-docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot status
-```
-
-## ๐ง Linux Service
-
-Run the gateway as a systemd user service so it starts automatically and restarts on failure.
-
-**1. Find the nanobot binary path:**
-
-```bash
-which nanobot # e.g. /home/user/.local/bin/nanobot
-```
-
-**2. Create the service file** at `~/.config/systemd/user/nanobot-gateway.service` (replace `ExecStart` path if needed):
-
-```ini
-[Unit]
-Description=Nanobot Gateway
-After=network.target
-
-[Service]
-Type=simple
-ExecStart=%h/.local/bin/nanobot gateway
-Restart=always
-RestartSec=10
-NoNewPrivileges=yes
-ProtectSystem=strict
-ReadWritePaths=%h
-
-[Install]
-WantedBy=default.target
-```
-
-**3. Enable and start:**
-
-```bash
-systemctl --user daemon-reload
-systemctl --user enable --now nanobot-gateway
-```
-
-**Common operations:**
-
-```bash
-systemctl --user status nanobot-gateway # check status
-systemctl --user restart nanobot-gateway # restart after config changes
-journalctl --user -u nanobot-gateway -f # follow logs
-```
-
-If you edit the `.service` file itself, run `systemctl --user daemon-reload` before restarting.
-
-> **Note:** User services only run while you are logged in. To keep the gateway running after logout, enable lingering:
->
-> ```bash
-> loginctl enable-linger $USER
-> ```
-
-## ๐ 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
-โ โโโ subagent.py # Background task execution
-โ โโโ tools/ # Built-in tools (incl. spawn)
-โโโ skills/ # ๐ฏ Bundled skills (github, weather, tmux...)
-โโโ channels/ # ๐ฑ Chat channel integrations (supports plugins)
-โโโ bus/ # ๐ Message routing
-โโโ cron/ # โฐ Scheduled tasks
-โโโ heartbeat/ # ๐ Proactive wake-up
-โโโ providers/ # ๐ค LLM providers (OpenRouter, etc.)
-โโโ session/ # ๐ฌ Conversation sessions
-โโโ config/ # โ๏ธ Configuration
-โโโ cli/ # ๐ฅ๏ธ Commands
-```
+- Talk to your nanobot with familiar chat apps: [`Chat Apps`](./docs/chat-apps.md)
+- Configure providers, web search, MCP, and runtime behavior: [`Configuration`](./docs/configuration.md)
+- Integrate nanobot with local tools and automations: [`OpenAI-Compatible API`](./docs/openai-api.md) ยท [`Python SDK`](./docs/python-sdk.md)
+- Run nanobot with Docker or as a Linux service: [`Deployment`](./docs/deployment.md)
## ๐ค Contribute & Roadmap
@@ -2235,11 +234,11 @@ PRs welcome! The codebase is intentionally small and readable. ๐ค
**Roadmap** โ Pick an item and [open a PR](https://github.com/HKUDS/nanobot/pulls)!
-- [ ] **Multi-modal** โ See and hear (images, voice, video)
-- [ ] **Long-term memory** โ Never forget important context
-- [ ] **Better reasoning** โ Multi-step planning and reflection
-- [ ] **More integrations** โ Calendar and more
-- [ ] **Self-improvement** โ Learn from feedback and mistakes
+- **Multi-modal** โ See and hear (images, voice, video)
+- **Long-term memory** โ Never forget important context
+- **Better reasoning** โ Multi-step planning and reflection
+- **More integrations** โ Calendar and more
+- **Self-improvement** โ Learn from feedback and mistakes
### Contributors
@@ -2263,9 +262,4 @@ PRs welcome! The codebase is intentionally small and readable. ๐ค
Thanks for visiting โจ nanobot!
-
-
-
-
- nanobot is for educational, research, and technical exchange purposes only
-
+
\ No newline at end of file
diff --git a/docs/PYTHON_SDK.md b/docs/PYTHON_SDK.md
deleted file mode 100644
index 2b51055a..00000000
--- a/docs/PYTHON_SDK.md
+++ /dev/null
@@ -1,138 +0,0 @@
-# Python SDK
-
-> **Note:** This interface is currently an experiment in the latest source code version and is planned to officially ship in `v0.1.5`.
-
-Use nanobot programmatically โ load config, run the agent, get results.
-
-## Quick Start
-
-```python
-import asyncio
-from nanobot import Nanobot
-
-async def main():
- bot = Nanobot.from_config()
- result = await bot.run("What time is it in Tokyo?")
- print(result.content)
-
-asyncio.run(main())
-```
-
-## API
-
-### `Nanobot.from_config(config_path?, *, workspace?)`
-
-Create a `Nanobot` from a config file.
-
-| Param | Type | Default | Description |
-|-------|------|---------|-------------|
-| `config_path` | `str \| Path \| None` | `None` | Path to `config.json`. Defaults to `~/.nanobot/config.json`. |
-| `workspace` | `str \| Path \| None` | `None` | Override workspace directory from config. |
-
-Raises `FileNotFoundError` if an explicit path doesn't exist.
-
-### `await bot.run(message, *, session_key?, hooks?)`
-
-Run the agent once. Returns a `RunResult`.
-
-| Param | Type | Default | Description |
-|-------|------|---------|-------------|
-| `message` | `str` | *(required)* | The user message to process. |
-| `session_key` | `str` | `"sdk:default"` | Session identifier for conversation isolation. Different keys get independent history. |
-| `hooks` | `list[AgentHook] \| None` | `None` | Lifecycle hooks for this run only. |
-
-```python
-# Isolated sessions โ each user gets independent conversation history
-await bot.run("hi", session_key="user-alice")
-await bot.run("hi", session_key="user-bob")
-```
-
-### `RunResult`
-
-| Field | Type | Description |
-|-------|------|-------------|
-| `content` | `str` | The agent's final text response. |
-| `tools_used` | `list[str]` | Tool names invoked during the run. |
-| `messages` | `list[dict]` | Raw message history (for debugging). |
-
-## Hooks
-
-Hooks let you observe or modify the agent loop without touching internals.
-
-Subclass `AgentHook` and override any method:
-
-| Method | When |
-|--------|------|
-| `before_iteration(ctx)` | Before each LLM call |
-| `on_stream(ctx, delta)` | On each streamed token |
-| `on_stream_end(ctx)` | When streaming finishes |
-| `before_execute_tools(ctx)` | Before tool execution (inspect `ctx.tool_calls`) |
-| `after_iteration(ctx, response)` | After each LLM response |
-| `finalize_content(ctx, content)` | Transform final output text |
-
-### Example: Audit Hook
-
-```python
-from nanobot.agent import AgentHook, AgentHookContext
-
-class AuditHook(AgentHook):
- def __init__(self):
- self.calls = []
-
- async def before_execute_tools(self, ctx: AgentHookContext) -> None:
- for tc in ctx.tool_calls:
- self.calls.append(tc.name)
- print(f"[audit] {tc.name}({tc.arguments})")
-
-hook = AuditHook()
-result = await bot.run("List files in /tmp", hooks=[hook])
-print(f"Tools used: {hook.calls}")
-```
-
-### Composing Hooks
-
-Pass multiple hooks โ they run in order, errors in one don't block others:
-
-```python
-result = await bot.run("hi", hooks=[AuditHook(), MetricsHook()])
-```
-
-Under the hood this uses `CompositeHook` for fan-out with error isolation.
-
-### `finalize_content` Pipeline
-
-Unlike the async methods (fan-out), `finalize_content` is a pipeline โ each hook's output feeds the next:
-
-```python
-class Censor(AgentHook):
- def finalize_content(self, ctx, content):
- return content.replace("secret", "***") if content else content
-```
-
-## Full Example
-
-```python
-import asyncio
-from nanobot import Nanobot
-from nanobot.agent import AgentHook, AgentHookContext
-
-class TimingHook(AgentHook):
- async def before_iteration(self, ctx: AgentHookContext) -> None:
- import time
- ctx.metadata["_t0"] = time.time()
-
- async def after_iteration(self, ctx, response) -> None:
- import time
- elapsed = time.time() - ctx.metadata.get("_t0", 0)
- print(f"[timing] iteration took {elapsed:.2f}s")
-
-async def main():
- bot = Nanobot.from_config(workspace="/my/project")
- result = await bot.run(
- "Explain the main function",
- hooks=[TimingHook()],
- )
- print(result.content)
-
-asyncio.run(main())
-```
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 00000000..5158f802
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,30 @@
+# nanobot Docs
+
+For the latest documentation, visit [nanobot.wiki](https://nanobot.wiki/docs/0.1.5/getting-started/nanobot-overview).
+
+The pages in this directory track the current repository and may move faster than the published website.
+
+## Core Docs
+
+| Topic | Repo docs |
+|---|---|
+| Install and quick start | [`quick-start.md`](./quick-start.md) |
+| Chat apps | [`chat-apps.md`](./chat-apps.md) |
+| Agent social network | [`agent-social-network.md`](./agent-social-network.md) |
+| Configuration | [`configuration.md`](./configuration.md) |
+| Multiple instances | [`multiple-instances.md`](./multiple-instances.md) |
+| CLI reference | [`cli-reference.md`](./cli-reference.md) |
+| In-chat commands | [`chat-commands.md`](./chat-commands.md) |
+| OpenAI-compatible API | [`openai-api.md`](./openai-api.md) |
+| Deployment | [`deployment.md`](./deployment.md) |
+
+## Advanced Docs
+
+| Topic | Repo docs |
+|---|---|
+| Memory | [`memory.md`](./memory.md) |
+| Python SDK | [`python-sdk.md`](./python-sdk.md) |
+| Channel plugin guide | [`channel-plugin-guide.md`](./channel-plugin-guide.md) |
+| WebSocket channel | [`websocket.md`](./websocket.md) |
+| Custom tools | [`my-tool.md`](./my-tool.md) |
+
diff --git a/docs/agent-social-network.md b/docs/agent-social-network.md
new file mode 100644
index 00000000..74579b8b
--- /dev/null
+++ b/docs/agent-social-network.md
@@ -0,0 +1,10 @@
+# Agent Social Network
+
+๐ nanobot is capable of linking to the agent social network (agent community). **Just send one message and your nanobot joins automatically!**
+
+| Platform | How to Join (send this message to your bot) |
+|----------|-------------|
+| [**Moltbook**](https://www.moltbook.com/) | `Read https://moltbook.com/skill.md and follow the instructions to join Moltbook` |
+| [**ClawdChat**](https://clawdchat.ai/) | `Read https://clawdchat.ai/skill.md and follow the instructions to join ClawdChat` |
+
+Simply send the command above to your nanobot (via CLI or any chat channel), and it will handle the rest.
diff --git a/docs/CHANNEL_PLUGIN_GUIDE.md b/docs/channel-plugin-guide.md
similarity index 99%
rename from docs/CHANNEL_PLUGIN_GUIDE.md
rename to docs/channel-plugin-guide.md
index 12a41685..d37a9288 100644
--- a/docs/CHANNEL_PLUGIN_GUIDE.md
+++ b/docs/channel-plugin-guide.md
@@ -19,7 +19,7 @@ We'll build a minimal webhook channel that receives messages via HTTP POST and s
### Project Structure
-```
+```text
nanobot-channel-webhook/
โโโ nanobot_channel_webhook/
โ โโโ __init__.py # re-export WebhookChannel
diff --git a/docs/chat-apps.md b/docs/chat-apps.md
new file mode 100644
index 00000000..9332bdc0
--- /dev/null
+++ b/docs/chat-apps.md
@@ -0,0 +1,661 @@
+# Chat Apps
+
+Connect nanobot to your favorite chat platform. Want to build your own? See the [Channel Plugin Guide](./channel-plugin-guide.md).
+
+| Channel | What you need |
+|---------|---------------|
+| **Telegram** | Bot token from @BotFather |
+| **Discord** | Bot token + Message Content intent |
+| **WhatsApp** | QR code scan (`nanobot channels login whatsapp`) |
+| **WeChat (Weixin)** | QR code scan (`nanobot channels login weixin`) |
+| **Feishu** | App ID + App Secret |
+| **DingTalk** | App Key + App Secret |
+| **Slack** | Bot token + App-Level token |
+| **Matrix** | Homeserver URL + Access token |
+| **Email** | IMAP/SMTP credentials |
+| **QQ** | App ID + App Secret |
+| **Wecom** | Bot ID + Bot Secret |
+| **Microsoft Teams** | App ID + App Password + public HTTPS endpoint |
+| **Mochat** | Claw token (auto-setup available) |
+
+
+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"]
+ }
+ }
+}
+```
+
+> You can find your **User ID** in Telegram settings. It is shown as `@yourUserId`.
+> Copy this value **without the `@` symbol** and paste it into the config file.
+
+
+**3. Run**
+
+```bash
+nanobot gateway
+```
+
+
+
+
+Mochat (Claw IM)
+
+Uses **Socket.IO WebSocket** by default, with HTTP polling fallback.
+
+**1. Ask nanobot to set up Mochat for you**
+
+Simply send this message to nanobot (replace `xxx@xxx` with your real email):
+
+```
+Read https://raw.githubusercontent.com/HKUDS/MoChat/refs/heads/main/skills/nanobot/skill.md and register on MoChat. My Email account is xxx@xxx Bind me as your owner and DM me on MoChat.
+```
+
+nanobot will automatically register, configure `~/.nanobot/config.json`, and connect to Mochat.
+
+**2. Restart gateway**
+
+```bash
+nanobot gateway
+```
+
+That's it โ nanobot handles the rest!
+
+
+
+
+Manual configuration (advanced)
+
+If you prefer to configure manually, add the following to `~/.nanobot/config.json`:
+
+> Keep `claw_token` private. It should only be sent in `X-Claw-Token` header to your Mochat API endpoint.
+
+```json
+{
+ "channels": {
+ "mochat": {
+ "enabled": true,
+ "base_url": "https://mochat.io",
+ "socket_url": "https://mochat.io",
+ "socket_path": "/socket.io",
+ "claw_token": "claw_xxx",
+ "agent_user_id": "6982abcdef",
+ "sessions": ["*"],
+ "panels": ["*"],
+ "reply_delay_mode": "non-mention",
+ "reply_delay_ms": 120000
+ }
+ }
+}
+```
+
+
+
+
+
+
+
+
+Discord
+
+**1. Create a bot**
+- Go to https://discord.com/developers/applications
+- Create an application โ Bot โ Add Bot
+- Copy the bot token
+
+**2. Enable intents**
+- In the Bot settings, enable **MESSAGE CONTENT INTENT**
+- (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
+
+**3. Get your User ID**
+- Discord Settings โ Advanced โ enable **Developer Mode**
+- Right-click your avatar โ **Copy User ID**
+
+**4. Configure**
+
+```json
+{
+ "channels": {
+ "discord": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allowFrom": ["YOUR_USER_ID"],
+ "allowChannels": [],
+ "groupPolicy": "mention",
+ "streaming": true
+ }
+ }
+}
+```
+
+> `groupPolicy` controls how the bot responds in group channels:
+> - `"mention"` (default) โ Only respond when @mentioned
+> - `"open"` โ Respond to all messages
+> DMs always respond when the sender is in `allowFrom`.
+> - If you set group policy to open create new threads as private threads and then @ the bot into it. Otherwise the thread itself and the channel in which you spawned it will spawn a bot session.
+> `allowChannels` restricts the bot to specific Discord channel IDs. Empty (default) means respond in every channel the bot can see. Example: `["1234567890", "0987654321"]`. The filter applies after `allowFrom`, so both must pass.
+> `streaming` defaults to `true`. Disable it only if you explicitly want non-streaming replies.
+
+**5. Invite the bot**
+- OAuth2 โ URL Generator
+- Scopes: `bot`
+- Bot Permissions: `Send Messages`, `Read Message History`
+- Open the generated invite URL and add the bot to your server
+
+**6. Run**
+
+```bash
+nanobot gateway
+```
+
+
+
+
+Matrix (Element)
+
+Install Matrix dependencies first:
+
+```bash
+pip install nanobot-ai[matrix]
+```
+
+> [!NOTE]
+> Matrix is not supported on Windows. `matrix-nio[e2e]` depends on
+> `python-olm`, which has no pre-built Windows wheel and is skipped by the
+> `matrix` extra on `sys_platform == 'win32'`. The command above will still
+> succeed on Windows but without `matrix-nio` installed, so enabling the
+> Matrix channel will fail at startup. Use macOS, Linux, or WSL2.
+
+**1. Create/choose a Matrix account**
+
+- Create or reuse a Matrix account on your homeserver (for example `matrix.org`).
+- Confirm you can log in with Element.
+
+**2. Get credentials**
+
+- You need:
+ - `userId` (example: `@nanobot:matrix.org`)
+ - `password`
+
+(Note: `accessToken` and `deviceId` are still supported for legacy reasons, but
+for reliable encryption, password login is recommended instead. If the
+`password` is provided, `accessToken` and `deviceId` will be ignored.)
+
+**3. Configure**
+
+```json
+{
+ "channels": {
+ "matrix": {
+ "enabled": true,
+ "homeserver": "https://matrix.org",
+ "userId": "@nanobot:matrix.org",
+ "password": "mypasswordhere",
+ "e2eeEnabled": true,
+ "allowFrom": ["@your_user:matrix.org"],
+ "groupPolicy": "open",
+ "groupAllowFrom": [],
+ "allowRoomMentions": false,
+ "maxMediaBytes": 20971520
+ }
+ }
+}
+```
+
+> Keep a persistent `matrix-store` โ encrypted session state is lost if these change across restarts.
+
+| Option | Description |
+|--------|-------------|
+| `allowFrom` | User IDs allowed to interact. Empty denies all; use `["*"]` to allow everyone. |
+| `groupPolicy` | `open` (default), `mention`, or `allowlist`. |
+| `groupAllowFrom` | Room allowlist (used when policy is `allowlist`). |
+| `allowRoomMentions` | Accept `@room` mentions in mention mode. |
+| `e2eeEnabled` | E2EE support (default `true`). Set `false` for plaintext-only. |
+| `maxMediaBytes` | Max attachment size (default `20MB`). Set `0` to block all media. |
+
+
+
+
+**4. Run**
+
+```bash
+nanobot gateway
+```
+
+
+
+
+WhatsApp
+
+Requires **Node.js โฅ18**.
+
+**1. Link device**
+
+```bash
+nanobot channels login whatsapp
+# 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 whatsapp
+
+# Terminal 2
+nanobot gateway
+```
+
+> WhatsApp bridge updates are not applied automatically for existing installations.
+> After upgrading nanobot, rebuild the local bridge with:
+> `rm -rf ~/.nanobot/bridge && nanobot channels login whatsapp`
+
+
+
+
+Feishu
+
+Uses **WebSocket** long connection โ no public IP required.
+
+**1. Create a Feishu bot**
+- Visit [Feishu Open Platform](https://open.feishu.cn/app)
+- Create a new app โ Enable **Bot** capability
+- **Permissions**:
+ - `im:message` (send messages) and `im:message.p2p_msg:readonly` (receive messages)
+ - **Streaming replies** (default in nanobot): add **`cardkit:card:write`** (often labeled **Create and update cards** in the Feishu developer console). Required for CardKit entities and streamed assistant text. Older apps may not have it yet โ open **Permission management**, enable the scope, then **publish** a new app version if the console requires it.
+ - If you **cannot** add `cardkit:card:write`, set `"streaming": false` under `channels.feishu` (see below). The bot still works; replies use normal interactive cards without token-by-token streaming.
+- **Events**: Add `im.message.receive_v1` (receive messages)
+ - Select **Long Connection** mode (requires running nanobot first to establish connection)
+- Get **App ID** and **App Secret** from "Credentials & Basic Info"
+- Publish the app
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "feishu": {
+ "enabled": true,
+ "appId": "cli_xxx",
+ "appSecret": "xxx",
+ "encryptKey": "",
+ "verificationToken": "",
+ "allowFrom": ["ou_YOUR_OPEN_ID"],
+ "groupPolicy": "mention",
+ "reactEmoji": "OnIt",
+ "doneEmoji": "DONE",
+ "toolHintPrefix": "๐ง",
+ "streaming": true,
+ "domain": "feishu"
+ }
+ }
+}
+```
+
+> `streaming` defaults to `true`. Use `false` if your app does not have **`cardkit:card:write`** (see permissions above).
+> `encryptKey` and `verificationToken` are optional for Long Connection mode.
+> `allowFrom`: Add your open_id (find it in nanobot logs when you message the bot). Use `["*"]` to allow all users.
+> `groupPolicy`: `"mention"` (default โ respond only when @mentioned), `"open"` (respond to all group messages). Private chats always respond.
+> `reactEmoji`: Emoji for "processing" status (default: `OnIt`). See [available emojis](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce).
+> `doneEmoji`: Optional emoji for "completed" status (e.g., `DONE`, `OK`, `HEART`). When set, bot adds this reaction after removing `reactEmoji`.
+> `toolHintPrefix`: Prefix for inline tool hints in streaming cards (default: `๐ง`).
+> `domain`: `"feishu"` (default) for China (open.feishu.cn), `"lark"` for international Lark (open.larksuite.com).
+
+**3. Run**
+
+```bash
+nanobot gateway
+```
+
+> [!TIP]
+> Feishu uses WebSocket to receive messages โ no webhook or public IP needed!
+
+
+
+
+QQ (QQๅ่)
+
+Uses **botpy SDK** with WebSocket โ no public IP required. Currently supports **private messages only**.
+
+**1. Register & create bot**
+- Visit [QQ Open Platform](https://q.qq.com) โ Register as a developer (personal or enterprise)
+- Create a new bot application
+- Go to **ๅผๅ่ฎพ็ฝฎ (Developer Settings)** โ copy **AppID** and **AppSecret**
+
+**2. Set up sandbox for testing**
+- In the bot management console, find **ๆฒ็ฎฑ้
็ฝฎ (Sandbox Config)**
+- Under **ๅจๆถๆฏๅ่กจ้
็ฝฎ**, click **ๆทปๅ ๆๅ** and add your own QQ number
+- Once added, scan the bot's QR code with mobile QQ โ open the bot profile โ tap "ๅๆถๆฏ" to start chatting
+
+**3. Configure**
+
+> - `allowFrom`: Add your openid (find it in nanobot logs when you message the bot). Use `["*"]` for public access.
+> - `msgFormat`: Optional. Use `"plain"` (default) for maximum compatibility with legacy QQ clients, or `"markdown"` for richer formatting on newer clients.
+> - For production: submit a review in the bot console and publish. See [QQ Bot Docs](https://bot.q.qq.com/wiki/) for the full publishing flow.
+
+```json
+{
+ "channels": {
+ "qq": {
+ "enabled": true,
+ "appId": "YOUR_APP_ID",
+ "secret": "YOUR_APP_SECRET",
+ "allowFrom": ["YOUR_OPENID"],
+ "msgFormat": "plain"
+ }
+ }
+}
+```
+
+**4. Run**
+
+```bash
+nanobot gateway
+```
+
+Now send a message to the bot from QQ โ it should respond!
+
+
+
+
+DingTalk (้้)
+
+Uses **Stream Mode** โ no public IP required.
+
+**1. Create a DingTalk bot**
+- Visit [DingTalk Open Platform](https://open-dev.dingtalk.com/)
+- Create a new app -> Add **Robot** capability
+- **Configuration**:
+ - Toggle **Stream Mode** ON
+- **Permissions**: Add necessary permissions for sending messages
+- Get **AppKey** (Client ID) and **AppSecret** (Client Secret) from "Credentials"
+- Publish the app
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "dingtalk": {
+ "enabled": true,
+ "clientId": "YOUR_APP_KEY",
+ "clientSecret": "YOUR_APP_SECRET",
+ "allowFrom": ["YOUR_STAFF_ID"]
+ }
+ }
+}
+```
+
+> `allowFrom`: Add your staff ID. Use `["*"]` to allow all users.
+
+**3. Run**
+
+```bash
+nanobot gateway
+```
+
+
+
+
+Slack
+
+Uses **Socket Mode** โ no public URL required.
+
+**1. Create a Slack app**
+- Go to [Slack API](https://api.slack.com/apps) โ **Create New App** โ "From scratch"
+- Pick a name and select your workspace
+
+**2. Configure the app**
+- **Socket Mode**: Toggle ON โ Generate an **App-Level Token** with `connections:write` scope โ copy it (`xapp-...`)
+- **OAuth & Permissions**: Add bot scopes: `chat:write`, `reactions:write`, `app_mentions:read`
+- **Event Subscriptions**: Toggle ON โ Subscribe to bot events: `message.im`, `message.channels`, `app_mention` โ Save Changes
+- **App Home**: Scroll to **Show Tabs** โ Enable **Messages Tab** โ Check **"Allow users to send Slash commands and messages from the messages tab"**
+- **Install App**: Click **Install to Workspace** โ Authorize โ copy the **Bot Token** (`xoxb-...`)
+
+**3. Configure nanobot**
+
+```json
+{
+ "channels": {
+ "slack": {
+ "enabled": true,
+ "botToken": "xoxb-...",
+ "appToken": "xapp-...",
+ "allowFrom": ["YOUR_SLACK_USER_ID"],
+ "groupPolicy": "mention"
+ }
+ }
+}
+```
+
+**4. Run**
+
+```bash
+nanobot gateway
+```
+
+DM the bot directly or @mention it in a channel โ it should respond!
+
+> [!TIP]
+> - `groupPolicy`: `"mention"` (default โ respond only when @mentioned), `"open"` (respond to all channel messages), or `"allowlist"` (restrict to specific channels).
+> - DM policy defaults to open. Set `"dm": {"enabled": false}` to disable DMs.
+
+
+
+
+Email
+
+Give nanobot its own email account. It polls **IMAP** for incoming mail and replies via **SMTP** โ like a personal email assistant.
+
+**1. Get credentials (Gmail example)**
+- Create a dedicated Gmail account for your bot (e.g. `my-nanobot@gmail.com`)
+- Enable 2-Step Verification โ Create an [App Password](https://myaccount.google.com/apppasswords)
+- Use this app password for both IMAP and SMTP
+
+**2. Configure**
+
+> - `consentGranted` must be `true` to allow mailbox access. This is a safety gate โ set `false` to fully disable.
+> - `allowFrom`: Add your email address. Use `["*"]` to accept emails from anyone.
+> - `smtpUseTls` and `smtpUseSsl` default to `true` / `false` respectively, which is correct for Gmail (port 587 + STARTTLS). No need to set them explicitly.
+> - Set `"autoReplyEnabled": false` if you only want to read/analyze emails without sending automatic replies.
+> - `allowedAttachmentTypes`: Save inbound attachments matching these MIME types โ `["*"]` for all, e.g. `["application/pdf", "image/*"]` (default `[]` = disabled).
+> - `maxAttachmentSize`: Max size per attachment in bytes (default `2000000` / 2MB).
+> - `maxAttachmentsPerEmail`: Max attachments to save per email (default `5`).
+
+```json
+{
+ "channels": {
+ "email": {
+ "enabled": true,
+ "consentGranted": true,
+ "imapHost": "imap.gmail.com",
+ "imapPort": 993,
+ "imapUsername": "my-nanobot@gmail.com",
+ "imapPassword": "your-app-password",
+ "smtpHost": "smtp.gmail.com",
+ "smtpPort": 587,
+ "smtpUsername": "my-nanobot@gmail.com",
+ "smtpPassword": "your-app-password",
+ "fromAddress": "my-nanobot@gmail.com",
+ "allowFrom": ["your-real-email@gmail.com"],
+ "allowedAttachmentTypes": ["application/pdf", "image/*"]
+ }
+ }
+}
+```
+
+
+**3. Run**
+
+```bash
+nanobot gateway
+```
+
+
+
+
+WeChat (ๅพฎไฟก / Weixin)
+
+Uses **HTTP long-poll** with QR-code login via the ilinkai personal WeChat API. No local WeChat desktop client is required.
+
+**1. Install with WeChat support**
+
+```bash
+pip install "nanobot-ai[weixin]"
+```
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "weixin": {
+ "enabled": true,
+ "allowFrom": ["YOUR_WECHAT_USER_ID"]
+ }
+ }
+}
+```
+
+> - `allowFrom`: Add the sender ID you see in nanobot logs for your WeChat account. Use `["*"]` to allow all users.
+> - `token`: Optional. If omitted, log in interactively and nanobot will save the token for you.
+> - `routeTag`: Optional. When your upstream Weixin deployment requires request routing, nanobot will send it as the `SKRouteTag` header.
+> - `stateDir`: Optional. Defaults to nanobot's runtime directory for Weixin state.
+> - `pollTimeout`: Optional long-poll timeout in seconds.
+
+**3. Login**
+
+```bash
+nanobot channels login weixin
+```
+
+Use `--force` to re-authenticate and ignore any saved token:
+
+```bash
+nanobot channels login weixin --force
+```
+
+**4. Run**
+
+```bash
+nanobot gateway
+```
+
+
+
+
+Wecom (ไผไธๅพฎไฟก)
+
+> Here we use [wecom-aibot-sdk-python](https://github.com/chengyongru/wecom_aibot_sdk) (community Python version of the official [@wecom/aibot-node-sdk](https://www.npmjs.com/package/@wecom/aibot-node-sdk)).
+>
+> Uses **WebSocket** long connection โ no public IP required.
+
+**1. Install the optional dependency**
+
+```bash
+pip install nanobot-ai[wecom]
+```
+
+**2. Create a WeCom AI Bot**
+
+Go to the WeCom admin console โ Intelligent Robot โ Create Robot โ select **API mode** with **long connection**. Copy the Bot ID and Secret.
+
+**3. Configure**
+
+```json
+{
+ "channels": {
+ "wecom": {
+ "enabled": true,
+ "botId": "your_bot_id",
+ "secret": "your_bot_secret",
+ "allowFrom": ["your_id"]
+ }
+ }
+}
+```
+
+**4. Run**
+
+```bash
+nanobot gateway
+```
+
+
+
+
+Microsoft Teams (MVP โ DM only)
+
+> Direct-message text in/out, tenant-aware OAuth, conversation reference persistence.
+> Uses a public HTTPS webhook โ no WebSocket; you need a tunnel or reverse proxy.
+
+**1. Install the optional dependency**
+
+```bash
+pip install nanobot-ai[msteams]
+```
+
+**2. Create a Teams / Azure bot app registration**
+
+Create or reuse a Microsoft Teams / Azure bot app registration. Set the bot messaging endpoint to a public HTTPS URL ending in `/api/messages`.
+
+**3. Configure**
+
+```json
+{
+ "channels": {
+ "msteams": {
+ "enabled": true,
+ "appId": "YOUR_APP_ID",
+ "appPassword": "YOUR_APP_SECRET",
+ "tenantId": "YOUR_TENANT_ID",
+ "host": "0.0.0.0",
+ "port": 3978,
+ "path": "/api/messages",
+ "allowFrom": ["*"],
+ "replyInThread": true,
+ "mentionOnlyResponse": "Hi โ what can I help with?",
+ "validateInboundAuth": true
+ }
+ }
+}
+```
+
+> - `replyInThread: true` replies to the triggering Teams activity when a stored `activity_id` is available.
+> - `mentionOnlyResponse` controls what Nanobot receives when a user sends only a bot mention (`Nanobot`). Set to `""` to ignore mention-only messages.
+> - `validateInboundAuth: true` enables inbound Bot Framework bearer-token validation (signature, issuer, audience, lifetime, `serviceUrl`). This is the safe default for public deployments. Only set it to `false` for local development or tightly controlled testing.
+
+**4. Run**
+
+```bash
+nanobot gateway
+```
+
+
\ No newline at end of file
diff --git a/docs/chat-commands.md b/docs/chat-commands.md
new file mode 100644
index 00000000..72707e76
--- /dev/null
+++ b/docs/chat-commands.md
@@ -0,0 +1,33 @@
+# In-Chat Commands
+
+These commands work inside chat channels and interactive agent sessions:
+
+| Command | Description |
+|---------|-------------|
+| `/new` | Start a new conversation |
+| `/stop` | Stop the current task |
+| `/restart` | Restart the bot |
+| `/status` | Show bot status |
+| `/dream` | Run Dream memory consolidation now |
+| `/dream-log` | Show the latest Dream memory change |
+| `/dream-log ` | Show a specific Dream memory change |
+| `/dream-restore` | List recent Dream memory versions |
+| `/dream-restore ` | Restore memory to the state before a specific change |
+| `/help` | Show available in-chat commands |
+
+## Periodic Tasks
+
+The gateway wakes up every 30 minutes and checks `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). If the file has tasks, the agent executes them and delivers results to your most recently active chat channel.
+
+**Setup:** edit `~/.nanobot/workspace/HEARTBEAT.md` (created automatically by `nanobot onboard`):
+
+```markdown
+## Periodic Tasks
+
+- [ ] Check weather forecast and send a summary
+- [ ] Scan inbox for urgent emails
+```
+
+The agent can also manage this file itself โ ask it to "add a periodic task" and it will update `HEARTBEAT.md` for you.
+
+> **Note:** The gateway must be running (`nanobot gateway`) and you must have chatted with the bot at least once so it knows which channel to deliver to.
diff --git a/docs/cli-reference.md b/docs/cli-reference.md
new file mode 100644
index 00000000..667f8c13
--- /dev/null
+++ b/docs/cli-reference.md
@@ -0,0 +1,21 @@
+# CLI Reference
+
+| Command | Description |
+|---------|-------------|
+| `nanobot onboard` | Initialize config & workspace at `~/.nanobot/` |
+| `nanobot onboard --wizard` | Launch the interactive onboarding wizard |
+| `nanobot onboard -c -w ` | Initialize or refresh a specific instance config and workspace |
+| `nanobot agent -m "..."` | Chat with the agent |
+| `nanobot agent -w ` | Chat against a specific workspace |
+| `nanobot agent -w -c ` | Chat against a specific workspace/config |
+| `nanobot agent` | Interactive chat mode |
+| `nanobot agent --no-markdown` | Show plain-text replies |
+| `nanobot agent --logs` | Show runtime logs during chat |
+| `nanobot serve` | Start the OpenAI-compatible API |
+| `nanobot gateway` | Start the gateway |
+| `nanobot status` | Show status |
+| `nanobot provider login openai-codex` | OAuth login for providers |
+| `nanobot channels login ` | Authenticate a channel interactively |
+| `nanobot channels status` | Show channel status |
+
+Interactive mode exits: `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 00000000..96b5fa5b
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,809 @@
+# Configuration
+
+Config file: `~/.nanobot/config.json`
+
+> [!NOTE]
+> If your config file is older than the current schema, you can refresh it without overwriting your existing values:
+> run `nanobot onboard`, then answer `N` when asked whether to overwrite the config.
+> nanobot will merge in missing default fields and keep your current settings.
+
+## Environment Variables for Secrets
+
+Instead of storing secrets directly in `config.json`, you can use `${VAR_NAME}` references that are resolved from environment variables at startup:
+
+```json
+{
+ "channels": {
+ "telegram": { "token": "${TELEGRAM_TOKEN}" },
+ "email": {
+ "imapPassword": "${IMAP_PASSWORD}",
+ "smtpPassword": "${SMTP_PASSWORD}"
+ }
+ },
+ "providers": {
+ "groq": { "apiKey": "${GROQ_API_KEY}" }
+ }
+}
+```
+
+For **systemd** deployments, use `EnvironmentFile=` in the service unit to load variables from a file that only the deploying user can read:
+
+```ini
+# /etc/systemd/system/nanobot.service (excerpt)
+[Service]
+EnvironmentFile=/home/youruser/nanobot_secrets.env
+User=nanobot
+ExecStart=...
+```
+
+```bash
+# /home/youruser/nanobot_secrets.env (mode 600, owned by youruser)
+TELEGRAM_TOKEN=your-token-here
+IMAP_PASSWORD=your-password-here
+```
+
+## Providers
+
+> [!TIP]
+> - **Voice transcription**: Voice messages (Telegram, WhatsApp) are automatically transcribed using Whisper. By default Groq is used (free tier). Set `"transcriptionProvider": "openai"` under `channels` to use OpenAI Whisper instead โ the API key is picked from the matching provider config.
+> - **MiniMax Coding Plan**: Exclusive discount links for the nanobot community: [Overseas](https://platform.minimax.io/subscribe/coding-plan?code=9txpdXw04g&source=link) ยท [Mainland China](https://platform.minimaxi.com/subscribe/token-plan?code=GILTJpMTqZ&source=link)
+> - **MiniMax (Mainland China)**: If your API key is from MiniMax's mainland China platform (minimaxi.com), set `"apiBase": "https://api.minimaxi.com/v1"` in your minimax provider config.
+> - **MiniMax thinking mode**: Use `providers.minimaxAnthropic` when you want `reasoningEffort` / thinking mode. MiniMax exposes that capability through its Anthropic-compatible endpoint, so nanobot keeps it as a separate provider instead of guessing MiniMax-specific thinking parameters on the generic OpenAI-compatible `minimax` endpoint. It uses the same `MINIMAX_API_KEY`. Default Anthropic-compatible base URL: `https://api.minimax.io/anthropic`; for mainland China use `https://api.minimaxi.com/anthropic`.
+> - **VolcEngine / BytePlus Coding Plan**: Use dedicated providers `volcengineCodingPlan` or `byteplusCodingPlan` instead of the pay-per-use `volcengine` / `byteplus` providers.
+> - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config.
+> - **Alibaba Cloud BaiLian**: If you're using Alibaba Cloud BaiLian's OpenAI-compatible endpoint, set `"apiBase": "https://dashscope.aliyuncs.com/compatible-mode/v1"` in your dashscope provider config.
+> - **Step Fun (Mainland China)**: If your API key is from Step Fun's mainland China platform (stepfun.com), set `"apiBase": "https://api.stepfun.com/v1"` in your stepfun provider config.
+
+| Provider | Purpose | Get API Key |
+|----------|---------|-------------|
+| `custom` | Any OpenAI-compatible endpoint | โ |
+| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
+| `volcengine` | LLM (VolcEngine, pay-per-use) | [Coding Plan](https://www.volcengine.com/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) ยท [volcengine.com](https://www.volcengine.com) |
+| `byteplus` | LLM (VolcEngine international, pay-per-use) | [Coding Plan](https://www.byteplus.com/en/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) ยท [byteplus.com](https://www.byteplus.com) |
+| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
+| `azure_openai` | LLM (Azure OpenAI) | [portal.azure.com](https://portal.azure.com) |
+| `openai` | LLM + Voice transcription (Whisper) | [platform.openai.com](https://platform.openai.com) |
+| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `groq` | LLM + Voice transcription (Whisper, default) | [console.groq.com](https://console.groq.com) |
+| `minimax` | LLM (MiniMax direct) | [platform.minimaxi.com](https://platform.minimaxi.com) |
+| `minimax_anthropic` | LLM (MiniMax Anthropic-compatible endpoint, thinking mode) | [platform.minimaxi.com](https://platform.minimaxi.com) |
+| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
+| `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) |
+| `siliconflow` | LLM (SiliconFlow/็ก
ๅบๆตๅจ) | [siliconflow.cn](https://siliconflow.cn) |
+| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
+| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) |
+| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
+| `mimo` | LLM (MiMo) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) |
+| `ollama` | LLM (local, Ollama) | โ |
+| `lm_studio` | LLM (local, LM Studio) | โ |
+| `mistral` | LLM | [docs.mistral.ai](https://docs.mistral.ai/) |
+| `stepfun` | LLM (Step Fun/้ถ่ทๆ่พฐ) | [platform.stepfun.com](https://platform.stepfun.com) |
+| `ovms` | LLM (local, OpenVINO Model Server) | [docs.openvino.ai](https://docs.openvino.ai/2026/model-server/ovms_docs_llm_quickstart.html) |
+| `vllm` | LLM (local, any OpenAI-compatible server) | โ |
+| `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex` |
+| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
+| `qianfan` | LLM (Baidu Qianfan) | [cloud.baidu.com](https://cloud.baidu.com/doc/qianfan/s/Hmh4suq26) |
+
+
+
+OpenAI Codex (OAuth)
+
+Codex uses OAuth instead of API keys. Requires a ChatGPT Plus or Pro account.
+No `providers.openaiCodex` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config.
+
+**1. Login:**
+```bash
+nanobot provider login openai-codex
+```
+
+**2. Set model** (merge into `~/.nanobot/config.json`):
+```json
+{
+ "agents": {
+ "defaults": {
+ "model": "openai-codex/gpt-5.1-codex"
+ }
+ }
+}
+```
+
+**3. Chat:**
+```bash
+nanobot agent -m "Hello!"
+
+# Target a specific workspace/config locally
+nanobot agent -c ~/.nanobot-telegram/config.json -m "Hello!"
+
+# One-off workspace override on top of that config
+nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -m "Hello!"
+```
+
+> Docker users: use `docker run -it` for interactive OAuth login.
+
+
+
+
+
+GitHub Copilot (OAuth)
+
+GitHub Copilot uses OAuth instead of API keys. Requires a [GitHub account with a plan](https://github.com/features/copilot/plans) configured.
+No `providers.githubCopilot` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config.
+
+**1. Login:**
+```bash
+nanobot provider login github-copilot
+```
+
+**2. Set model** (merge into `~/.nanobot/config.json`):
+```json
+{
+ "agents": {
+ "defaults": {
+ "model": "github-copilot/gpt-4.1"
+ }
+ }
+}
+```
+
+**3. Chat:**
+```bash
+nanobot agent -m "Hello!"
+
+# Target a specific workspace/config locally
+nanobot agent -c ~/.nanobot-telegram/config.json -m "Hello!"
+
+# One-off workspace override on top of that config
+nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -m "Hello!"
+```
+
+> Docker users: use `docker run -it` for interactive OAuth login.
+
+
+
+
+Custom Provider (Any OpenAI-compatible API)
+
+Connects directly to any OpenAI-compatible endpoint โ llama.cpp, Together AI, Fireworks, Azure OpenAI, or any self-hosted server. Model name is passed as-is.
+
+```json
+{
+ "providers": {
+ "custom": {
+ "apiKey": "your-api-key",
+ "apiBase": "https://api.your-provider.com/v1"
+ }
+ },
+ "agents": {
+ "defaults": {
+ "model": "your-model-name"
+ }
+ }
+}
+```
+
+> For local servers that don't require authentication, set `apiKey` to `null`.
+>
+> `custom` is the right choice for providers that expose an OpenAI-compatible **chat completions** API. It does **not** force third-party endpoints onto the OpenAI/Azure **Responses API**.
+>
+> If your proxy or gateway is specifically Responses-API-compatible, use the `azure_openai` provider shape instead and point `apiBase` at that endpoint:
+>
+> ```json
+> {
+> "providers": {
+> "azure_openai": {
+> "apiKey": "your-api-key",
+> "apiBase": "https://api.your-provider.com",
+> "defaultModel": "your-model-name"
+> }
+> },
+> "agents": {
+> "defaults": {
+> "provider": "azure_openai",
+> "model": "your-model-name"
+> }
+> }
+> }
+> ```
+>
+> In short: **chat-completions-compatible endpoint โ `custom`**; **Responses-compatible endpoint โ `azure_openai`**.
+
+
+
+
+Ollama (local)
+
+Run a local model with Ollama, then add to config:
+
+**1. Start Ollama** (example):
+```bash
+ollama run llama3.2
+```
+
+**2. Add to config** (partial โ merge into `~/.nanobot/config.json`):
+```json
+{
+ "providers": {
+ "ollama": {
+ "apiBase": "http://localhost:11434"
+ }
+ },
+ "agents": {
+ "defaults": {
+ "provider": "ollama",
+ "model": "llama3.2"
+ }
+ }
+}
+```
+
+> `provider: "auto"` also works when `providers.ollama.apiBase` is configured, but setting `"provider": "ollama"` is the clearest option.
+
+
+
+
+LM Studio (local)
+
+[LM Studio](https://lmstudio.ai/) provides a local OpenAI-compatible server for running LLMs. Download models through the LM Studio UI, then start the local server.
+
+**1. Start LM Studio server:**
+- Launch LM Studio
+- Go to the "Local Server" tab
+- Load a model (e.g., Llama, Mistral, Qwen)
+- Click "Start Server" (default port: 1234)
+
+**2. Add to config** (partial โ merge into `~/.nanobot/config.json`):
+```json
+{
+ "providers": {
+ "lm_studio": {
+ "apiKey": null,
+ "apiBase": "http://localhost:1234/v1"
+ }
+ },
+ "agents": {
+ "defaults": {
+ "provider": "lm_studio",
+ "model": "local-model"
+ }
+ }
+}
+```
+
+> **Note:** Set `apiKey` to `null` for LM Studio since it runs locally and doesn't require authentication. The model name should match what's shown in the LM Studio UI.
+> `provider: "auto"` also works when `providers.lm_studio.apiBase` is configured, but setting `"provider": "lm_studio"` is the clearest option.
+
+
+
+
+OpenVINO Model Server (local / OpenAI-compatible)
+
+Run LLMs locally on Intel GPUs using [OpenVINO Model Server](https://docs.openvino.ai/2026/model-server/ovms_docs_llm_quickstart.html). OVMS exposes an OpenAI-compatible API at `/v3`.
+
+> Requires Docker and an Intel GPU with driver access (`/dev/dri`).
+
+**1. Pull the model** (example):
+
+```bash
+mkdir -p ov/models && cd ov
+
+docker run -d \
+ --rm \
+ --user $(id -u):$(id -g) \
+ -v $(pwd)/models:/models \
+ openvino/model_server:latest-gpu \
+ --pull \
+ --model_name openai/gpt-oss-20b \
+ --model_repository_path /models \
+ --source_model OpenVINO/gpt-oss-20b-int4-ov \
+ --task text_generation \
+ --tool_parser gptoss \
+ --reasoning_parser gptoss \
+ --enable_prefix_caching true \
+ --target_device GPU
+```
+
+> This downloads the model weights. Wait for the container to finish before proceeding.
+
+**2. Start the server** (example):
+
+```bash
+docker run -d \
+ --rm \
+ --name ovms \
+ --user $(id -u):$(id -g) \
+ -p 8000:8000 \
+ -v $(pwd)/models:/models \
+ --device /dev/dri \
+ --group-add=$(stat -c "%g" /dev/dri/render* | head -n 1) \
+ openvino/model_server:latest-gpu \
+ --rest_port 8000 \
+ --model_name openai/gpt-oss-20b \
+ --model_repository_path /models \
+ --source_model OpenVINO/gpt-oss-20b-int4-ov \
+ --task text_generation \
+ --tool_parser gptoss \
+ --reasoning_parser gptoss \
+ --enable_prefix_caching true \
+ --target_device GPU
+```
+
+**3. Add to config** (partial โ merge into `~/.nanobot/config.json`):
+
+```json
+{
+ "providers": {
+ "ovms": {
+ "apiBase": "http://localhost:8000/v3"
+ }
+ },
+ "agents": {
+ "defaults": {
+ "provider": "ovms",
+ "model": "openai/gpt-oss-20b"
+ }
+ }
+}
+```
+
+> OVMS is a local server โ no API key required. Supports tool calling (`--tool_parser gptoss`), reasoning (`--reasoning_parser gptoss`), and streaming.
+> See the [official OVMS docs](https://docs.openvino.ai/2026/model-server/ovms_docs_llm_quickstart.html) for more details.
+
+
+
+vLLM (local / OpenAI-compatible)
+
+Run your own model with vLLM or any OpenAI-compatible server, then add to config:
+
+**1. Start the server** (example):
+```bash
+vllm serve meta-llama/Llama-3.1-8B-Instruct --port 8000
+```
+
+**2. Add to config** (partial โ merge into `~/.nanobot/config.json`):
+
+*Provider (set API key to null for local servers):*
+```json
+{
+ "providers": {
+ "vllm": {
+ "apiKey": null,
+ "apiBase": "http://localhost:8000/v1"
+ }
+ }
+}
+```
+
+*Model:*
+```json
+{
+ "agents": {
+ "defaults": {
+ "model": "meta-llama/Llama-3.1-8B-Instruct"
+ }
+ }
+}
+```
+
+
+
+
+Adding a New Provider (Developer Guide)
+
+nanobot uses a **Provider Registry** (`nanobot/providers/registry.py`) as the single source of truth.
+Adding a new provider only takes **2 steps** โ no if-elif chains to touch.
+
+**Step 1.** Add a `ProviderSpec` entry to `PROVIDERS` in `nanobot/providers/registry.py`:
+
+```python
+ProviderSpec(
+ name="myprovider", # config field name
+ keywords=("myprovider", "mymodel"), # model-name keywords for auto-matching
+ env_key="MYPROVIDER_API_KEY", # env var name
+ display_name="My Provider", # shown in `nanobot status`
+ default_api_base="https://api.myprovider.com/v1", # OpenAI-compatible endpoint
+)
+```
+
+**Step 2.** Add a field to `ProvidersConfig` in `nanobot/config/schema.py`:
+
+```python
+class ProvidersConfig(BaseModel):
+ ...
+ myprovider: ProviderConfig = ProviderConfig()
+```
+
+That's it! Environment variables, model routing, config matching, and `nanobot status` display will all work automatically.
+
+**Common `ProviderSpec` options:**
+
+| Field | Description | Example |
+|-------|-------------|---------|
+| `default_api_base` | OpenAI-compatible base URL | `"https://api.deepseek.com"` |
+| `env_extras` | Additional env vars to set | `(("ZHIPUAI_API_KEY", "{api_key}"),)` |
+| `model_overrides` | Per-model parameter overrides | `(("kimi-k2.5", {"temperature": 1.0}),)` |
+| `is_gateway` | Can route any model (like OpenRouter) | `True` |
+| `detect_by_key_prefix` | Detect gateway by API key prefix | `"sk-or-"` |
+| `detect_by_base_keyword` | Detect gateway by API base URL | `"openrouter"` |
+| `strip_model_prefix` | Strip provider prefix before sending to gateway | `True` (for AiHubMix) |
+| `supports_max_completion_tokens` | Use `max_completion_tokens` instead of `max_tokens`; required for providers that reject both being set simultaneously (e.g. VolcEngine) | `True` |
+
+
+
+## Channel Settings
+
+Global settings that apply to all channels. Configure under the `channels` section in `~/.nanobot/config.json`:
+
+```json
+{
+ "channels": {
+ "sendProgress": true,
+ "sendToolHints": false,
+ "sendMaxRetries": 3,
+ "transcriptionProvider": "groq",
+ "telegram": { ... }
+ }
+}
+```
+
+| Setting | Default | Description |
+|---------|---------|-------------|
+| `sendProgress` | `true` | Stream agent's text progress to the channel |
+| `sendToolHints` | `false` | Stream tool-call hints (e.g. `read_file("โฆ")`) |
+| `sendMaxRetries` | `3` | Max delivery attempts per outbound message, including the initial send (0-10 configured, minimum 1 actual attempt) |
+| `transcriptionProvider` | `"groq"` | Voice transcription backend: `"groq"` (free tier, default) or `"openai"`. API key is auto-resolved from the matching provider config. |
+
+### Retry Behavior
+
+Retry is intentionally simple.
+
+When a channel `send()` raises, nanobot retries at the channel-manager layer. By default, `channels.sendMaxRetries` is `3`, and that count includes the initial send.
+
+- **Attempt 1**: Send immediately
+- **Attempt 2**: Retry after `1s`
+- **Attempt 3**: Retry after `2s`
+- **Higher retry budgets**: Backoff continues as `1s`, `2s`, `4s`, then stays capped at `4s`
+- **Transient failures**: Network hiccups and temporary API limits often recover on the next attempt
+- **Permanent failures**: Invalid tokens, revoked access, or banned channels will exhaust the retry budget and fail cleanly
+
+> [!NOTE]
+> This design is deliberate: channel implementations should raise on delivery failure, and the channel manager owns the shared retry policy.
+>
+> Some channels may still apply small API-specific retries internally. For example, Telegram separately retries timeout and flood-control errors before surfacing a final failure to the manager.
+>
+> If a channel is completely unreachable, nanobot cannot notify the user through that same channel. Watch logs for `Failed to send to {channel} after N attempts` to spot persistent delivery failures.
+
+## Web Search
+
+> [!TIP]
+> Use `proxy` in `tools.web` to route all web requests (search + fetch) through a proxy:
+> ```json
+> { "tools": { "web": { "proxy": "http://127.0.0.1:7890" } } }
+> ```
+
+nanobot supports multiple web search providers. Configure in `~/.nanobot/config.json` under `tools.web.search`.
+
+By default, web tools are enabled and web search uses `duckduckgo`, so search works out of the box without an API key.
+
+If you want to disable all built-in web tools entirely, set `tools.web.enable` to `false`. This removes both `web_search` and `web_fetch` from the tool list sent to the LLM.
+
+If you need to allow trusted private ranges such as Tailscale / CGNAT addresses, you can explicitly exempt them from SSRF blocking with `tools.ssrfWhitelist`:
+
+```json
+{
+ "tools": {
+ "ssrfWhitelist": ["100.64.0.0/10"]
+ }
+}
+```
+
+| Provider | Config fields | Env var fallback | Free |
+|----------|--------------|------------------|------|
+| `brave` | `apiKey` | `BRAVE_API_KEY` | No |
+| `tavily` | `apiKey` | `TAVILY_API_KEY` | No |
+| `jina` | `apiKey` | `JINA_API_KEY` | Free tier (10M tokens) |
+| `kagi` | `apiKey` | `KAGI_API_KEY` | No |
+| `searxng` | `baseUrl` | `SEARXNG_BASE_URL` | Yes (self-hosted) |
+| `duckduckgo` (default) | โ | โ | Yes |
+
+**Disable all built-in web tools:**
+```json
+{
+ "tools": {
+ "web": {
+ "enable": false
+ }
+ }
+}
+```
+
+**Brave:**
+```json
+{
+ "tools": {
+ "web": {
+ "search": {
+ "provider": "brave",
+ "apiKey": "BSA..."
+ }
+ }
+ }
+}
+```
+
+**Tavily:**
+```json
+{
+ "tools": {
+ "web": {
+ "search": {
+ "provider": "tavily",
+ "apiKey": "tvly-..."
+ }
+ }
+ }
+}
+```
+
+**Jina** (free tier with 10M tokens):
+```json
+{
+ "tools": {
+ "web": {
+ "search": {
+ "provider": "jina",
+ "apiKey": "jina_..."
+ }
+ }
+ }
+}
+```
+
+**Kagi:**
+```json
+{
+ "tools": {
+ "web": {
+ "search": {
+ "provider": "kagi",
+ "apiKey": "your-kagi-api-key"
+ }
+ }
+ }
+}
+```
+
+**SearXNG** (self-hosted, no API key needed):
+```json
+{
+ "tools": {
+ "web": {
+ "search": {
+ "provider": "searxng",
+ "baseUrl": "https://searx.example"
+ }
+ }
+ }
+}
+```
+
+**DuckDuckGo** (zero config):
+```json
+{
+ "tools": {
+ "web": {
+ "search": {
+ "provider": "duckduckgo"
+ }
+ }
+ }
+}
+```
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `enable` | boolean | `true` | Enable or disable all built-in web tools (`web_search` + `web_fetch`) |
+| `proxy` | string or null | `null` | Proxy for all web requests, for example `http://127.0.0.1:7890` |
+
+### `tools.web.search`
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `provider` | string | `"duckduckgo"` | Search backend: `brave`, `tavily`, `jina`, `searxng`, `duckduckgo` |
+| `apiKey` | string | `""` | API key for Brave or Tavily |
+| `baseUrl` | string | `""` | Base URL for SearXNG |
+| `maxResults` | integer | `5` | Results per search (1โ10) |
+
+## MCP (Model Context Protocol)
+
+> [!TIP]
+> The config format is compatible with Claude Desktop / Cursor. You can copy MCP server configs directly from any MCP server's README.
+
+nanobot supports [MCP](https://modelcontextprotocol.io/) โ connect external tool servers and use them as native agent tools.
+
+Add MCP servers to your `config.json`:
+
+```json
+{
+ "tools": {
+ "mcpServers": {
+ "filesystem": {
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]
+ },
+ "my-remote-mcp": {
+ "url": "https://example.com/mcp/",
+ "headers": {
+ "Authorization": "Bearer xxxxx"
+ }
+ }
+ }
+ }
+}
+```
+
+Two transport modes are supported:
+
+| Mode | Config | Example |
+|------|--------|---------|
+| **Stdio** | `command` + `args` | Local process via `npx` / `uvx` |
+| **HTTP** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/sse`) |
+
+Use `toolTimeout` to override the default 30s per-call timeout for slow servers:
+
+```json
+{
+ "tools": {
+ "mcpServers": {
+ "my-slow-server": {
+ "url": "https://example.com/mcp/",
+ "toolTimeout": 120
+ }
+ }
+ }
+}
+```
+
+Use `enabledTools` to register only a subset of tools from an MCP server:
+
+```json
+{
+ "tools": {
+ "mcpServers": {
+ "filesystem": {
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"],
+ "enabledTools": ["read_file", "mcp_filesystem_write_file"]
+ }
+ }
+ }
+}
+```
+
+`enabledTools` accepts either the raw MCP tool name (for example `read_file`) or the wrapped nanobot tool name (for example `mcp_filesystem_write_file`).
+
+- Omit `enabledTools`, or set it to `["*"]`, to register all tools.
+- Set `enabledTools` to `[]` to register no tools from that server.
+- Set `enabledTools` to a non-empty list of names to register only that subset.
+
+MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools โ no extra configuration needed.
+
+
+
+
+## Security
+
+> [!TIP]
+> For production deployments, set `"restrictToWorkspace": true` and `"tools.exec.sandbox": "bwrap"` in your config to sandbox the agent.
+> In `v0.1.4.post3` and earlier, an empty `allowFrom` allowed all senders. Since `v0.1.4.post4`, empty `allowFrom` denies all access by default. To allow all senders, set `"allowFrom": ["*"]`.
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. |
+| `tools.exec.sandbox` | `""` | Sandbox backend for shell commands. Set to `"bwrap"` to wrap exec calls in a [bubblewrap](https://github.com/containers/bubblewrap) sandbox โ the process can only see the workspace (read-write) and media directory (read-only); config files and API keys are hidden. Automatically enables `restrictToWorkspace` for file tools. **Linux only** โ requires `bwrap` installed (`apt install bubblewrap`; pre-installed in the Docker image). Not available on macOS or Windows (bwrap depends on Linux kernel namespaces). |
+| `tools.exec.enable` | `true` | When `false`, the shell `exec` tool is not registered at all. Use this to completely disable shell command execution. |
+| `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). |
+| `channels.*.allowFrom` | `[]` (deny all) | Whitelist of user IDs. Empty denies all; use `["*"]` to allow everyone. |
+
+**Docker security**: The official Docker image runs as a non-root user (`nanobot`, UID 1000) with bubblewrap pre-installed. When using `docker-compose.yml`, the container drops all Linux capabilities except `SYS_ADMIN` (required for bwrap's namespace isolation).
+
+
+## Auto Compact
+
+When a user is idle for longer than a configured threshold, nanobot **proactively** compresses the older part of the session context into a summary while keeping a recent legal suffix of live messages. This reduces token cost and first-token latency when the user returns โ instead of re-processing a long stale context with an expired KV cache, the model receives a compact summary, the most recent live context, and fresh input.
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "idleCompactAfterMinutes": 15
+ }
+ }
+}
+```
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `agents.defaults.idleCompactAfterMinutes` | `0` (disabled) | Minutes of idle time before auto-compaction starts. Set to `0` to disable. Recommended: `15` โ close to a typical LLM KV cache expiry window, so stale sessions get compacted before the user returns. |
+
+`sessionTtlMinutes` remains accepted as a legacy alias for backward compatibility, but `idleCompactAfterMinutes` is the preferred config key going forward.
+
+How it works:
+1. **Idle detection**: On each idle tick (~1 s), checks all sessions for expiration.
+2. **Background compaction**: Idle sessions summarize the older live prefix via LLM and keep the most recent legal suffix (currently 8 messages).
+3. **Summary injection**: When the user returns, the summary is injected as runtime context (one-shot, not persisted) alongside the retained recent suffix.
+4. **Restart-safe resume**: The summary is also mirrored into session metadata so it can still be recovered after a process restart.
+
+> [!NOTE]
+> Mental model: "summarize older context, keep the freshest live turns, **and overwrite the session file with the compact form.**" It is not a full `session.clear()`, but it is a write โ not a soft cursor move.
+>
+> Concretely, auto compact rewrites `sessions/.jsonl` in place: older messages (including their structured `tool_calls` / `tool_call_id` / `reasoning_content`) are replaced by just the retained recent suffix (currently 8 messages), while the archived prefix is preserved only as a plain-text summary appended to `memory/history.jsonl` (or a `[RAW] ...` flattened dump if LLM summarization fails). The original structured JSON of those turns is no longer recoverable from the session file.
+>
+> This differs from the **token-driven soft consolidation** that fires when a prompt exceeds the context budget: that path only advances an internal `last_consolidated` cursor and leaves the session file untouched, so the raw tool-call trail stays on disk and can still be replayed or audited. If you rely on that trail for debugging or auditing, leave `idleCompactAfterMinutes` at the default `0` and let only the token-driven path run.
+
+## Timezone
+
+Time is context. Context should be precise.
+
+By default, nanobot uses `UTC` for runtime time context. If you want the agent to think in your local time, set `agents.defaults.timezone` to a valid [IANA timezone name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones):
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "timezone": "Asia/Shanghai"
+ }
+ }
+}
+```
+
+This affects runtime time strings shown to the model, such as runtime context and heartbeat prompts. It also becomes the default timezone for cron schedules when a cron expression omits `tz`, and for one-shot `at` times when the ISO datetime has no explicit offset.
+
+Common examples: `UTC`, `America/New_York`, `America/Los_Angeles`, `Europe/London`, `Europe/Berlin`, `Asia/Tokyo`, `Asia/Shanghai`, `Asia/Singapore`, `Australia/Sydney`.
+
+> Need another timezone? Browse the full [IANA Time Zone Database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
+
+## Unified Session
+
+By default, each channel ร chat ID combination gets its own session. If you use nanobot across multiple channels (e.g. Telegram + Discord + CLI) and want them to share the same conversation, enable `unifiedSession`:
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "unifiedSession": true
+ }
+ }
+}
+```
+
+When enabled, all incoming messages โ regardless of which channel they arrive on โ are routed into a single shared session. Switching from Telegram to Discord (or any other channel) continues the same conversation seamlessly.
+
+| Behavior | `false` (default) | `true` |
+|----------|-------------------|--------|
+| Session key | `channel:chat_id` | `unified:default` |
+| Cross-channel continuity | No | Yes |
+| `/new` clears | Current channel session | Shared session |
+| `/stop` finds tasks | By channel session | By shared session |
+| Existing `session_key_override` (e.g. Telegram thread) | Respected | Still respected โ not overwritten |
+
+> This is designed for single-user, multi-device setups. It is **off by default** โ existing users see zero behavior change.
+
+## Disabled Skills
+
+nanobot ships with built-in skills, and your workspace can also define custom skills under `skills/`. If you want to hide specific skills from the agent, set `agents.defaults.disabledSkills` to a list of skill directory names:
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "disabledSkills": ["github", "weather"]
+ }
+ }
+}
+```
+
+Disabled skills are excluded from the main agent's skill summary, from always-on skill injection, and from subagent skill summaries. This is useful when some bundled skills are unnecessary for your deployment or should not be exposed to end users.
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `agents.defaults.disabledSkills` | `[]` | List of skill directory names to exclude from loading. Applies to both built-in skills and workspace skills. |
diff --git a/docs/deployment.md b/docs/deployment.md
new file mode 100644
index 00000000..ad6283c0
--- /dev/null
+++ b/docs/deployment.md
@@ -0,0 +1,94 @@
+# Deployment
+
+## Docker
+
+> [!TIP]
+> The `-v ~/.nanobot:/home/nanobot/.nanobot` flag mounts your local config directory into the container, so your config and workspace persist across container restarts.
+> The container runs as user `nanobot` (UID 1000). If you get **Permission denied**, fix ownership on the host first: `sudo chown -R 1000:1000 ~/.nanobot`, or pass `--user $(id -u):$(id -g)` to match your host UID. Podman users can use `--userns=keep-id` instead.
+
+### Docker Compose
+
+```bash
+docker compose run --rm nanobot-cli onboard # first-time setup
+vim ~/.nanobot/config.json # add API keys
+docker compose up -d nanobot-gateway # start gateway
+```
+
+```bash
+docker compose run --rm nanobot-cli agent -m "Hello!" # run CLI
+docker compose logs -f nanobot-gateway # view logs
+docker compose down # stop
+```
+
+### Docker
+
+```bash
+# Build the image
+docker build -t nanobot .
+
+# Initialize config (first time only)
+docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot onboard
+
+# Edit config on host to add API keys
+vim ~/.nanobot/config.json
+
+# Run gateway (connects to enabled channels, e.g. Telegram/Discord/Mochat)
+docker run -v ~/.nanobot:/home/nanobot/.nanobot -p 18790:18790 nanobot gateway
+
+# Or run a single command
+docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot agent -m "Hello!"
+docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot status
+```
+
+## Linux Service
+
+Run the gateway as a systemd user service so it starts automatically and restarts on failure.
+
+**1. Find the nanobot binary path:**
+
+```bash
+which nanobot # e.g. /home/user/.local/bin/nanobot
+```
+
+**2. Create the service file** at `~/.config/systemd/user/nanobot-gateway.service` (replace `ExecStart` path if needed):
+
+```ini
+[Unit]
+Description=Nanobot Gateway
+After=network.target
+
+[Service]
+Type=simple
+ExecStart=%h/.local/bin/nanobot gateway
+Restart=always
+RestartSec=10
+NoNewPrivileges=yes
+ProtectSystem=strict
+ReadWritePaths=%h
+
+[Install]
+WantedBy=default.target
+```
+
+**3. Enable and start:**
+
+```bash
+systemctl --user daemon-reload
+systemctl --user enable --now nanobot-gateway
+```
+
+**Common operations:**
+
+```bash
+systemctl --user status nanobot-gateway # check status
+systemctl --user restart nanobot-gateway # restart after config changes
+journalctl --user -u nanobot-gateway -f # follow logs
+```
+
+If you edit the `.service` file itself, run `systemctl --user daemon-reload` before restarting.
+
+> **Note:** User services only run while you are logged in. To keep the gateway running after logout, enable lingering:
+>
+> ```bash
+> loginctl enable-linger $USER
+> ```
diff --git a/docs/features-showcase.md b/docs/features-showcase.md
new file mode 100644
index 00000000..bb988b06
--- /dev/null
+++ b/docs/features-showcase.md
@@ -0,0 +1,22 @@
+# Feature Showcase
+
+
+
+ ๐ 24/7 Real-Time Market Analysis |
+ ๐ Full-Stack Software Engineer |
+ ๐
Smart Daily Routine Manager |
+ ๐ Personal Knowledge Assistant |
+
+
+ 
|
+ 
|
+ 
|
+ 
|
+
+
+ | Discovery โข Insights โข Trends |
+ Develop โข Deploy โข Scale |
+ Schedule โข Automate โข Organize |
+ Learn โข Memory โข Reasoning |
+
+
diff --git a/docs/MEMORY.md b/docs/memory.md
similarity index 99%
rename from docs/MEMORY.md
rename to docs/memory.md
index 414fcdca..2fde1c68 100644
--- a/docs/MEMORY.md
+++ b/docs/memory.md
@@ -65,7 +65,7 @@ This is why nanobot's memory is not just archival. It is interpretive.
## The Files
-```
+```text
workspace/
โโโ SOUL.md # The bot's long-term voice and communication style
โโโ USER.md # Stable knowledge about the user
diff --git a/docs/multiple-instances.md b/docs/multiple-instances.md
new file mode 100644
index 00000000..d7c54cc0
--- /dev/null
+++ b/docs/multiple-instances.md
@@ -0,0 +1,126 @@
+# Multiple Instances
+
+Run multiple nanobot instances simultaneously with separate configs and runtime data. Use `--config` as the main entrypoint. Optionally pass `--workspace` during `onboard` when you want to initialize or update the saved workspace for a specific instance.
+
+## Quick Start
+
+If you want each instance to have its own dedicated workspace from the start, pass both `--config` and `--workspace` during onboarding.
+
+**Initialize instances:**
+
+```bash
+# Create separate instance configs and workspaces
+nanobot onboard --config ~/.nanobot-telegram/config.json --workspace ~/.nanobot-telegram/workspace
+nanobot onboard --config ~/.nanobot-discord/config.json --workspace ~/.nanobot-discord/workspace
+nanobot onboard --config ~/.nanobot-feishu/config.json --workspace ~/.nanobot-feishu/workspace
+```
+
+**Configure each instance:**
+
+Edit `~/.nanobot-telegram/config.json`, `~/.nanobot-discord/config.json`, etc. with different channel settings. The workspace you passed during `onboard` is saved into each config as that instance's default workspace.
+
+**Run instances:**
+
+```bash
+# Instance A - Telegram bot
+nanobot gateway --config ~/.nanobot-telegram/config.json
+
+# Instance B - Discord bot
+nanobot gateway --config ~/.nanobot-discord/config.json
+
+# Instance C - Feishu bot with custom port
+nanobot gateway --config ~/.nanobot-feishu/config.json --port 18792
+```
+
+## Path Resolution
+
+When using `--config`, nanobot derives its runtime data directory from the config file location. The workspace still comes from `agents.defaults.workspace` unless you override it with `--workspace`.
+
+To open a CLI session against one of these instances locally:
+
+```bash
+nanobot agent -c ~/.nanobot-telegram/config.json -m "Hello from Telegram instance"
+nanobot agent -c ~/.nanobot-discord/config.json -m "Hello from Discord instance"
+
+# Optional one-off workspace override
+nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test
+```
+
+> `nanobot agent` starts a local CLI agent using the selected workspace/config. It does not attach to or proxy through an already running `nanobot gateway` process.
+
+| Component | Resolved From | Example |
+|-----------|---------------|---------|
+| **Config** | `--config` path | `~/.nanobot-A/config.json` |
+| **Workspace** | `--workspace` or config | `~/.nanobot-A/workspace/` |
+| **Cron Jobs** | config directory | `~/.nanobot-A/cron/` |
+| **Media / runtime state** | config directory | `~/.nanobot-A/media/` |
+
+## How It Works
+
+- `--config` selects which config file to load
+- By default, the workspace comes from `agents.defaults.workspace` in that config
+- If you pass `--workspace`, it overrides the workspace from the config file
+
+## Minimal Setup
+
+1. Copy your base config into a new instance directory.
+2. Set a different `agents.defaults.workspace` for that instance.
+3. Start the instance with `--config`.
+
+Example config:
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.nanobot-telegram/workspace",
+ "model": "anthropic/claude-sonnet-4-6"
+ }
+ },
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "YOUR_TELEGRAM_BOT_TOKEN"
+ }
+ },
+ "gateway": {
+ "host": "127.0.0.1",
+ "port": 18790
+ }
+}
+```
+
+Start separate instances:
+
+```bash
+nanobot gateway --config ~/.nanobot-telegram/config.json
+nanobot gateway --config ~/.nanobot-discord/config.json
+```
+
+Each gateway instance also exposes a lightweight HTTP health endpoint on
+`gateway.host:gateway.port`. By default, the gateway binds to `127.0.0.1`,
+so the endpoint stays local unless you explicitly set `gateway.host` to a
+public or LAN-facing address.
+
+- `GET /health` returns `{"status":"ok"}`
+- Other paths return `404`
+
+Override workspace for one-off runs when needed:
+
+```bash
+nanobot gateway --config ~/.nanobot-telegram/config.json --workspace /tmp/nanobot-telegram-test
+```
+
+## Common Use Cases
+
+- Run separate bots for Telegram, Discord, Feishu, and other platforms
+- Keep testing and production instances isolated
+- Use different models or providers for different teams
+- Serve multiple tenants with separate configs and runtime data
+
+## Notes
+
+- Each instance must use a different port if they run at the same time
+- Use a different workspace per instance if you want isolated memory, sessions, and skills
+- `--workspace` overrides the workspace defined in the config file
+- Cron jobs and runtime media/state are derived from the config directory
diff --git a/docs/MY_TOOL.md b/docs/my-tool.md
similarity index 98%
rename from docs/MY_TOOL.md
rename to docs/my-tool.md
index a8a273d1..bc22ed5a 100644
--- a/docs/MY_TOOL.md
+++ b/docs/my-tool.md
@@ -36,7 +36,7 @@ All modifications are held in memory only โ restart restores defaults.
Without parameters, returns a key config overview:
-```
+```text
my(action="check")
# โ max_iterations: 40
# context_window_tokens: 65536
@@ -51,7 +51,7 @@ my(action="check")
With a key parameter, drill into a specific config:
-```
+```text
my(action="check", key="_last_usage.prompt_tokens")
# โ How many prompt tokens I've used so far
@@ -79,7 +79,7 @@ my(action="check", key="web_config.enable")
Changes take effect immediately, no restart required.
-```
+```text
my(action="set", key="max_iterations", value=80)
# โ Bump iteration limit from 40 to 80
@@ -92,7 +92,7 @@ my(action="set", key="context_window_tokens", value=131072)
You can also store custom state in your scratchpad:
-```
+```text
my(action="set", key="current_project", value="nanobot")
my(action="set", key="user_style_preference", value="concise")
my(action="set", key="task_complexity", value="high")
@@ -117,21 +117,21 @@ Other parameters (e.g. `workspace`, `provider_retry_mode`, `max_tool_result_char
### "This task is complex, I need more room"
-```
+```text
Agent: This codebase is large, let me expand my context window to handle it.
โ my(action="set", key="context_window_tokens", value=131072)
```
### "Simple question, don't waste compute"
-```
+```text
Agent: This is a straightforward question, let me switch to a faster model.
โ my(action="set", key="model", value="fast-model")
```
### "Remember user preferences across turns"
-```
+```text
Turn 1: my(action="set", key="user_prefers_concise", value=True)
Turn 2: my(action="check", key="user_prefers_concise")
# โ True (still remembers the user likes concise replies)
@@ -139,7 +139,7 @@ Turn 2: my(action="check", key="user_prefers_concise")
### "Self-diagnosis"
-```
+```text
User: "Why aren't you searching the web?"
Agent: Let me check my web config.
โ my(action="check", key="web_config.enable")
@@ -149,7 +149,7 @@ Agent: Web search is disabled โ please set web.enable: true in your config.
### "Token budget management"
-```
+```text
Agent: Let me check how much budget I have left.
โ my(action="check", key="_last_usage")
# โ {"prompt_tokens": 45000, "completion_tokens": 8000}
@@ -158,7 +158,7 @@ Agent: I've used ~53k tokens total so far. I'll keep my remaining replies concis
### "Subagent monitoring"
-```
+```text
Agent: Let me check on the background tasks.
โ my(action="check", key="subagents")
# โ 2 subagent(s):
diff --git a/docs/openai-api.md b/docs/openai-api.md
new file mode 100644
index 00000000..c88a8bed
--- /dev/null
+++ b/docs/openai-api.md
@@ -0,0 +1,121 @@
+# OpenAI-Compatible API
+
+nanobot can expose a minimal OpenAI-compatible endpoint for local integrations:
+
+```bash
+pip install "nanobot-ai[api]"
+nanobot serve
+```
+
+By default, the API binds to `127.0.0.1:8900`. You can change this in `config.json`.
+
+## Behavior
+
+- Session isolation: pass `"session_id"` in the request body to isolate conversations; omit for a shared default session (`api:default`)
+- Single-message input: each request must contain exactly one `user` message
+- Fixed model: omit `model`, or pass the same model shown by `/v1/models`
+- Streaming: set `stream=true` to receive Server-Sent Events (`text/event-stream`) with OpenAI-compatible delta chunks, terminated by `data: [DONE]`; omit or set `stream=false` for a single JSON response
+- **File uploads**: supports images, PDF, Word (.docx), Excel (.xlsx), PowerPoint (.pptx) via JSON base64 or `multipart/form-data` (max 10MB per file)
+- API requests run in the synthetic `api` channel, so the `message` tool does **not** automatically deliver to Telegram/Discord/etc. To proactively send to another chat, call `message` with an explicit `channel` and `chat_id` for an enabled channel.
+
+Example tool call for cross-channel delivery from an API session:
+
+```json
+{
+ "content": "Build finished successfully.",
+ "channel": "telegram",
+ "chat_id": "123456789"
+}
+```
+
+If `channel` points to a channel that is not enabled in your config, nanobot will queue the outbound event but no platform delivery will occur.
+
+## Endpoints
+
+- `GET /health`
+- `GET /v1/models`
+- `POST /v1/chat/completions`
+
+## curl
+
+```bash
+curl http://127.0.0.1:8900/v1/chat/completions \
+ -H "Content-Type: application/json" \
+ -d '{
+ "messages": [{"role": "user", "content": "hi"}],
+ "session_id": "my-session"
+ }'
+```
+
+## File Upload (JSON base64)
+
+Send images inline using the OpenAI multimodal content format:
+
+```bash
+curl http://127.0.0.1:8900/v1/chat/completions \
+ -H "Content-Type: application/json" \
+ -d '{
+ "messages": [{"role": "user", "content": [
+ {"type": "text", "text": "Describe this image"},
+ {"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBOR..."}}
+ ]}]
+ }'
+```
+
+## File Upload (multipart/form-data)
+
+Upload any supported file type (images, PDF, Word, Excel, PPT) via multipart:
+
+```bash
+# Single file
+curl http://127.0.0.1:8900/v1/chat/completions \
+ -F "message=Summarize this report" \
+ -F "files=@report.docx"
+
+# Multiple files with session isolation
+curl http://127.0.0.1:8900/v1/chat/completions \
+ -F "message=Compare these files" \
+ -F "files=@chart.png" \
+ -F "files=@data.xlsx" \
+ -F "session_id=my-session"
+```
+
+Supported file types:
+- **Images**: PNG, JPEG, GIF, WebP (sent to AI as base64 for vision analysis)
+- **Documents**: PDF, Word (.docx), Excel (.xlsx), PowerPoint (.pptx) (text extracted and sent to AI)
+- **Text**: TXT, Markdown, CSV, JSON, etc. (read directly)
+
+## Python (`requests`)
+
+```python
+import requests
+
+resp = requests.post(
+ "http://127.0.0.1:8900/v1/chat/completions",
+ json={
+ "messages": [{"role": "user", "content": "hi"}],
+ "session_id": "my-session", # optional: isolate conversation
+ },
+ timeout=120,
+)
+resp.raise_for_status()
+print(resp.json()["choices"][0]["message"]["content"])
+```
+
+## Python (`openai`)
+
+```python
+from openai import OpenAI
+
+client = OpenAI(
+ base_url="http://127.0.0.1:8900/v1",
+ api_key="dummy",
+)
+
+resp = client.chat.completions.create(
+ model="MiniMax-M2.7",
+ messages=[{"role": "user", "content": "hi"}],
+ extra_body={"session_id": "my-session"}, # optional: isolate conversation
+)
+print(resp.choices[0].message.content)
+```
diff --git a/docs/python-sdk.md b/docs/python-sdk.md
new file mode 100644
index 00000000..d035238d
--- /dev/null
+++ b/docs/python-sdk.md
@@ -0,0 +1,31 @@
+# Python SDK
+
+Use nanobot as a library โ no CLI, no gateway, just Python:
+
+```python
+from nanobot import Nanobot
+
+bot = Nanobot.from_config()
+result = await bot.run("Summarize the README")
+print(result.content)
+```
+
+Each call carries a `session_key` for conversation isolation โ different keys get independent history:
+
+```python
+await bot.run("hi", session_key="user-alice")
+await bot.run("hi", session_key="task-42")
+```
+
+Add lifecycle hooks to observe or customize the agent:
+
+```python
+from nanobot.agent import AgentHook, AgentHookContext
+
+class AuditHook(AgentHook):
+ async def before_execute_tools(self, ctx: AgentHookContext) -> None:
+ for tc in ctx.tool_calls:
+ print(f"[tool] {tc.name}")
+
+result = await bot.run("Hello", hooks=[AuditHook()])
+```
diff --git a/docs/quick-start.md b/docs/quick-start.md
new file mode 100644
index 00000000..7112ba8c
--- /dev/null
+++ b/docs/quick-start.md
@@ -0,0 +1,104 @@
+# Install and Quick Start
+
+## Install
+
+> [!IMPORTANT]
+> This README may describe features that are available first in the latest source code.
+> If you want the newest features and experiments, install from source.
+> If you want the most stable day-to-day experience, install from PyPI or with `uv`.
+
+**Install from source** (latest features, experimental changes may land here first; recommended for development)
+
+```bash
+git clone https://github.com/HKUDS/nanobot.git
+cd nanobot
+pip install -e .
+```
+
+**Install with [uv](https://github.com/astral-sh/uv)** (stable release, fast)
+
+```bash
+uv tool install nanobot-ai
+```
+
+**Install from PyPI** (stable release)
+
+```bash
+pip install nanobot-ai
+```
+
+### Update to latest version
+
+**PyPI / pip**
+
+```bash
+pip install -U nanobot-ai
+nanobot --version
+```
+
+**uv**
+
+```bash
+uv tool upgrade nanobot-ai
+nanobot --version
+```
+
+**Using WhatsApp?** Rebuild the local bridge after upgrading:
+
+```bash
+rm -rf ~/.nanobot/bridge
+nanobot channels login whatsapp
+```
+
+## Quick Start
+
+> [!TIP]
+> Set your API key in `~/.nanobot/config.json`.
+> Get API keys: [OpenRouter](https://openrouter.ai/keys) (Global)
+>
+> For other LLM providers, please see [`configuration.md`](./configuration.md).
+>
+> For web search capability setup, please see the web-search section in [`configuration.md`](./configuration.md#web-search).
+
+**1. Initialize**
+
+```bash
+nanobot onboard
+```
+
+Use `nanobot onboard --wizard` if you want the interactive setup wizard.
+
+**2. Configure** (`~/.nanobot/config.json`)
+
+Configure these **two parts** in your config (other options have defaults).
+
+*Set your API key* (e.g. OpenRouter, recommended for global users):
+```json
+{
+ "providers": {
+ "openrouter": {
+ "apiKey": "sk-or-v1-xxx"
+ }
+ }
+}
+```
+
+*Set your model* (optionally pin a provider โ defaults to auto-detection):
+```json
+{
+ "agents": {
+ "defaults": {
+ "model": "anthropic/claude-opus-4-5",
+ "provider": "openrouter"
+ }
+ }
+}
+```
+
+**3. Chat**
+
+```bash
+nanobot agent
+```
+
+That's it! You have a working AI agent in 2 minutes.
diff --git a/docs/WEBSOCKET.md b/docs/websocket.md
similarity index 99%
rename from docs/WEBSOCKET.md
rename to docs/websocket.md
index 1e0ddbbb..ed1652be 100644
--- a/docs/WEBSOCKET.md
+++ b/docs/websocket.md
@@ -42,7 +42,7 @@ nanobot gateway
You should see:
-```
+```text
WebSocket server listening on ws://127.0.0.1:8765/
```
@@ -68,7 +68,7 @@ asyncio.run(main())
## Connection URL
-```
+```text
ws://{host}:{port}{path}?client_id={id}&token={token}
```
diff --git a/images/GitHub_README.png b/images/GitHub_README.png
new file mode 100644
index 0000000000000000000000000000000000000000..a76f36dff3c6f79771000316627e8b07b2ebd78b
GIT binary patch
literal 192486
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 p)NwQV{HAP$*oQqwdab0GSkblIamdV
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