diff --git a/README.md b/README.md
index 140846ac..4bf13fb2 100644
--- a/README.md
+++ b/README.md
@@ -1,23 +1,25 @@
+
+
-๐ **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 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 and practical deployment paths, so you can go from local setup to a long-running personal agent with minimal overhead.
## ๐ข News
@@ -100,46 +102,96 @@
-> ๐ 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 +216,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 +240,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 +268,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..6a3c9bd0
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,34 @@
+# nanobot Docs
+
+For the latest documentation, visit [nanobot.wiki](https://nanobot.wiki/docs/latest/getting-started/nanobot-overview).
+
+The pages in this directory track the current repository and may move faster than the published website.
+
+## Core Docs
+
+Start here for setup, everyday usage, and deployment.
+
+| Topic | Repo docs | What it covers |
+|---|---|---|
+| Install and quick start | [`quick-start.md`](./quick-start.md) | Installation, onboarding, and first-run setup |
+| Chat apps | [`chat-apps.md`](./chat-apps.md) | Connect nanobot to Telegram, Discord, WeChat, and more |
+| Agent social network | [`agent-social-network.md`](./agent-social-network.md) | Join external agent communities from nanobot |
+| Configuration | [`configuration.md`](./configuration.md) | Providers, tools, channels, MCP, and runtime settings |
+| Multiple instances | [`multiple-instances.md`](./multiple-instances.md) | Run isolated bots with separate configs and workspaces |
+| CLI reference | [`cli-reference.md`](./cli-reference.md) | Core CLI commands and common entrypoints |
+| In-chat commands | [`chat-commands.md`](./chat-commands.md) | Slash commands and periodic task behavior |
+| OpenAI-compatible API | [`openai-api.md`](./openai-api.md) | Local API endpoints, request format, and file uploads |
+| Deployment | [`deployment.md`](./deployment.md) | Docker and Linux service setup |
+
+## Advanced Docs
+
+Use these when you want deeper customization, integration, or extension details.
+
+| Topic | Repo docs | What it covers |
+|---|---|---|
+| Memory | [`memory.md`](./memory.md) | How nanobot stores, consolidates, and restores memory |
+| Python SDK | [`python-sdk.md`](./python-sdk.md) | Use nanobot programmatically from Python |
+| Channel plugin guide | [`channel-plugin-guide.md`](./channel-plugin-guide.md) | Build and test custom chat channel plugins |
+| WebSocket channel | [`websocket.md`](./websocket.md) | Real-time WebSocket access and protocol details |
+| Custom tools | [`my-tool.md`](./my-tool.md) | Inspect and tune runtime state with the `my` tool |
+
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/MEMORY.md b/docs/memory.md
similarity index 97%
rename from docs/MEMORY.md
rename to docs/memory.md
index 414fcdca..763e0643 100644
--- a/docs/MEMORY.md
+++ b/docs/memory.md
@@ -1,7 +1,5 @@
# Memory in nanobot
-> **Note:** This design is currently an experiment in the latest source code version and is planned to officially ship in `v0.1.5`.
-
nanobot's memory is built on a simple belief: memory should feel alive, but it should not feel chaotic.
Good memory is not a pile of notes. It is a quiet system of attention. It notices what is worth keeping, lets go of what no longer needs the spotlight, and turns lived experience into something calm, durable, and useful.
@@ -65,7 +63,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..5ee66a34
--- /dev/null
+++ b/docs/python-sdk.md
@@ -0,0 +1,219 @@
+# Python SDK
+
+Use nanobot as a library โ no CLI, no gateway, just Python.
+
+## Quick Start
+
+```python
+import asyncio
+
+from nanobot import Nanobot
+
+
+async def main() -> None:
+ bot = Nanobot.from_config()
+ result = await bot.run("What time is it in Tokyo?")
+ print(result.content)
+
+
+asyncio.run(main())
+```
+
+`Nanobot.from_config()` reuses your normal `~/.nanobot/config.json`, so the SDK follows the same provider, model, tools, and workspace defaults as the CLI unless you override them.
+
+## Common Patterns
+
+### Use a specific config or workspace
+
+```python
+from nanobot import Nanobot
+
+bot = Nanobot.from_config(
+ config_path="~/.nanobot/config.json",
+ workspace="/my/project",
+)
+```
+
+### Isolate conversations with `session_key`
+
+Different session keys keep independent conversation history:
+
+```python
+await bot.run("hi", session_key="user-alice")
+await bot.run("hi", session_key="task-42")
+```
+
+### Attach hooks for observability
+
+Hooks let you inspect tool calls, streaming, and iteration state without modifying nanobot internals:
+
+```python
+from nanobot.agent import AgentHook, AgentHookContext
+
+
+class AuditHook(AgentHook):
+ async def before_execute_tools(self, context: AgentHookContext) -> None:
+ for tc in context.tool_calls:
+ print(f"[tool] {tc.name}")
+
+
+result = await bot.run("Review this change", hooks=[AuditHook()])
+```
+
+## API Reference
+
+### `Nanobot.from_config(config_path=None, *, workspace=None)`
+
+Create a `Nanobot` instance 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 the workspace directory from config. |
+
+Raises `FileNotFoundError` if an explicit config path does not exist.
+
+### `await bot.run(message, *, session_key="sdk:default", hooks=None)`
+
+Run the agent once and return 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. |
+
+### `RunResult`
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `content` | `str` | The agent's final text response. |
+| `tools_used` | `list[str]` | Reserved for richer SDK introspection; may be empty in current versions. |
+| `messages` | `list[dict]` | Reserved for richer SDK introspection; may be empty in current versions. |
+
+## Hooks
+
+Hooks let you observe or customize the agent loop. Subclass `AgentHook` and override the methods you need.
+
+### Hook lifecycle
+
+| Method | When |
+|--------|------|
+| `wants_streaming()` | Return `True` if you want token-by-token `on_stream()` callbacks |
+| `before_iteration(context)` | Before each LLM call |
+| `on_stream(context, delta)` | On each streamed token when streaming is enabled |
+| `on_stream_end(context, *, resuming)` | When streaming finishes |
+| `before_execute_tools(context)` | Before tool execution |
+| `after_iteration(context)` | After each iteration |
+| `finalize_content(context, content)` | Transform final output text |
+
+Useful fields on `AgentHookContext` include:
+
+- `iteration`
+- `messages`
+- `response`
+- `usage`
+- `tool_calls`
+- `tool_results`
+- `tool_events`
+- `final_content`
+- `stop_reason`
+- `error`
+
+### Example: audit tool calls
+
+```python
+from nanobot.agent import AgentHook, AgentHookContext
+
+
+class AuditHook(AgentHook):
+ def __init__(self) -> None:
+ super().__init__()
+ self.calls: list[str] = []
+
+ async def before_execute_tools(self, context: AgentHookContext) -> None:
+ for tc in context.tool_calls:
+ self.calls.append(tc.name)
+ print(f"[audit] {tc.name}({tc.arguments})")
+```
+
+```python
+hook = AuditHook()
+result = await bot.run("List files in /tmp", hooks=[hook])
+print(result.content)
+print(f"Tools observed: {hook.calls}")
+```
+
+### Example: receive streaming tokens
+
+```python
+from nanobot.agent import AgentHook, AgentHookContext
+
+
+class StreamingHook(AgentHook):
+ def wants_streaming(self) -> bool:
+ return True
+
+ async def on_stream(self, context: AgentHookContext, delta: str) -> None:
+ print(delta, end="", flush=True)
+
+ async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
+ print()
+```
+
+### Compose multiple hooks
+
+Pass multiple hooks when you want to combine behaviors:
+
+```python
+result = await bot.run("hi", hooks=[AuditHook(), MetricsHook()])
+```
+
+Async hook methods are fan-out with error isolation. `finalize_content` is a pipeline: each hook receives the previous hook's output.
+
+### Example: post-process final content
+
+```python
+from nanobot.agent import AgentHook
+
+
+class Censor(AgentHook):
+ def finalize_content(self, context, content):
+ return content.replace("secret", "***") if content else content
+```
+
+## Full Example
+
+```python
+import asyncio
+import time
+
+from nanobot import Nanobot
+from nanobot.agent import AgentHook, AgentHookContext
+
+
+class TimingHook(AgentHook):
+ def __init__(self) -> None:
+ super().__init__()
+ self._started_at = 0.0
+
+ async def before_iteration(self, context: AgentHookContext) -> None:
+ self._started_at = time.perf_counter()
+
+ async def after_iteration(self, context: AgentHookContext) -> None:
+ elapsed_ms = (time.perf_counter() - self._started_at) * 1000
+ print(f"[timing] iteration {context.iteration} took {elapsed_ms:.1f}ms")
+
+
+async def main() -> None:
+ bot = Nanobot.from_config(workspace="/my/project")
+ result = await bot.run(
+ "Explain the main function",
+ session_key="sdk:demo",
+ hooks=[TimingHook()],
+ )
+ print(result.content)
+
+
+asyncio.run(main())
+```
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 00000000..a76f36df
Binary files /dev/null and b/images/GitHub_README.png differ
diff --git a/images/nanobot_arch.png b/images/nanobot_arch.png
new file mode 100644
index 00000000..db4a42cd
Binary files /dev/null and b/images/nanobot_arch.png differ
diff --git a/nanobot_logo.png b/images/nanobot_logo.png
similarity index 100%
rename from nanobot_logo.png
rename to images/nanobot_logo.png
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 3350c447..53cb49d7 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -648,7 +648,10 @@ class AgentLoop:
session, pending = self.auto_compact.prepare_session(session, key)
- await self.consolidator.maybe_consolidate_by_tokens(session)
+ await self.consolidator.maybe_consolidate_by_tokens(
+ session,
+ session_summary=pending,
+ )
# Persist subagent follow-ups into durable history BEFORE prompt
# assembly. ContextBuilder merges adjacent same-role messages for
# provider compatibility, which previously caused the follow-up to
@@ -709,7 +712,10 @@ class AgentLoop:
if result := await self.commands.dispatch(ctx):
return result
- await self.consolidator.maybe_consolidate_by_tokens(session)
+ await self.consolidator.maybe_consolidate_by_tokens(
+ session,
+ session_summary=pending,
+ )
self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id"))
if message_tool := self.tools.get("message"):
diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py
index 5f235e3a..fb630ce1 100644
--- a/nanobot/agent/memory.py
+++ b/nanobot/agent/memory.py
@@ -416,7 +416,12 @@ class Consolidator:
return idx
return None
- def estimate_session_prompt_tokens(self, session: Session) -> tuple[int, str]:
+ def estimate_session_prompt_tokens(
+ self,
+ session: Session,
+ *,
+ session_summary: str | None = None,
+ ) -> tuple[int, str]:
"""Estimate current prompt size for the normal session history view."""
history = session.get_history(max_messages=0)
channel, chat_id = (session.key.split(":", 1) if ":" in session.key else (None, None))
@@ -425,6 +430,7 @@ class Consolidator:
current_message="[token-probe]",
channel=channel,
chat_id=chat_id,
+ session_summary=session_summary,
)
return estimate_prompt_tokens_chain(
self.provider,
@@ -467,7 +473,12 @@ class Consolidator:
self.store.raw_archive(messages)
return None
- async def maybe_consolidate_by_tokens(self, session: Session) -> None:
+ async def maybe_consolidate_by_tokens(
+ self,
+ session: Session,
+ *,
+ session_summary: str | None = None,
+ ) -> None:
"""Loop: archive old messages until prompt fits within safe budget.
The budget reserves space for completion tokens and a safety buffer
@@ -481,7 +492,10 @@ class Consolidator:
budget = self.context_window_tokens - self.max_completion_tokens - self._SAFETY_BUFFER
target = budget // 2
try:
- estimated, source = self.estimate_session_prompt_tokens(session)
+ estimated, source = self.estimate_session_prompt_tokens(
+ session,
+ session_summary=session_summary,
+ )
except Exception:
logger.exception("Token estimation failed for {}", session.key)
estimated, source = 0, "error"
@@ -499,9 +513,10 @@ class Consolidator:
)
return
+ last_summary = None
for round_num in range(self._MAX_CONSOLIDATION_ROUNDS):
if estimated <= target:
- return
+ break
boundary = self.pick_consolidation_boundary(session, max(1, estimated - target))
if boundary is None:
@@ -510,7 +525,7 @@ class Consolidator:
session.key,
round_num,
)
- return
+ break
end_idx = boundary[0]
end_idx = self._cap_consolidation_boundary(session, end_idx)
@@ -520,11 +535,11 @@ class Consolidator:
session.key,
round_num,
)
- return
+ break
chunk = session.messages[session.last_consolidated:end_idx]
if not chunk:
- return
+ break
logger.info(
"Token consolidation round {} for {}: {}/{} via {}, chunk={} msgs",
@@ -535,18 +550,34 @@ class Consolidator:
source,
len(chunk),
)
- if not await self.archive(chunk):
- return
+ summary = await self.archive(chunk)
+ if summary:
+ last_summary = summary
+ else:
+ break
session.last_consolidated = end_idx
self.sessions.save(session)
try:
- estimated, source = self.estimate_session_prompt_tokens(session)
+ estimated, source = self.estimate_session_prompt_tokens(
+ session,
+ session_summary=session_summary,
+ )
except Exception:
logger.exception("Token estimation failed for {}", session.key)
estimated, source = 0, "error"
if estimated <= 0:
- return
+ break
+
+ # Persist the last summary to session metadata so it can be injected
+ # into the runtime context on the next prepare_session() call, aligning
+ # the summary injection strategy with AutoCompact._archive().
+ if last_summary and last_summary != "(nothing)":
+ session.metadata["_last_summary"] = {
+ "text": last_summary,
+ "last_active": session.updated_at.isoformat(),
+ }
+ self.sessions.save(session)
# ---------------------------------------------------------------------------
diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py
index 1a9f295a..83db7e8f 100644
--- a/nanobot/providers/openai_compat_provider.py
+++ b/nanobot/providers/openai_compat_provider.py
@@ -9,11 +9,13 @@ import importlib.util
import os
import secrets
import string
+import time
import uuid
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any
import json_repair
+from loguru import logger
if os.environ.get("LANGFUSE_SECRET_KEY") and importlib.util.find_spec("langfuse"):
from langfuse.openai import AsyncOpenAI
@@ -143,6 +145,10 @@ def _uses_openrouter_attribution(spec: "ProviderSpec | None", api_base: str | No
return bool(api_base and "openrouter" in api_base.lower())
+_RESPONSES_FAILURE_THRESHOLD = 3
+_RESPONSES_PROBE_INTERVAL_S = 300 # 5 minutes
+
+
def _is_direct_openai_base(api_base: str | None) -> bool:
"""Return True for direct OpenAI endpoints, not generic OpenAI-compatible gateways."""
if not api_base:
@@ -151,6 +157,16 @@ def _is_direct_openai_base(api_base: str | None) -> bool:
return "api.openai.com" in normalized and "openrouter" not in normalized
+def _responses_circuit_key(
+ model: str | None,
+ default_model: str,
+ reasoning_effort: str | None,
+) -> str:
+ model_name = (model or default_model).lower()
+ effort = reasoning_effort.lower() if isinstance(reasoning_effort, str) else ""
+ return f"{model_name}:{effort}"
+
+
class OpenAICompatProvider(LLMProvider):
"""Unified provider for all OpenAI-compatible APIs.
@@ -189,6 +205,11 @@ class OpenAICompatProvider(LLMProvider):
max_retries=0,
)
+ # Responses API circuit breaker: skip after repeated failures,
+ # probe again after _RESPONSES_PROBE_INTERVAL_S seconds.
+ self._responses_failures: dict[str, int] = {}
+ self._responses_tripped_at: dict[str, float] = {}
+
def _setup_env(self, api_key: str, api_base: str | None) -> None:
"""Set environment variables based on provider spec."""
spec = self._spec
@@ -414,9 +435,39 @@ class OpenAICompatProvider(LLMProvider):
return False
model_name = (model or self.default_model).lower()
+ wants = False
if reasoning_effort and reasoning_effort.lower() != "none":
- return True
- return any(token in model_name for token in ("gpt-5", "o1", "o3", "o4"))
+ wants = True
+ elif any(token in model_name for token in ("gpt-5", "o1", "o3", "o4")):
+ wants = True
+ if not wants:
+ return False
+
+ # Circuit breaker: skip after repeated failures, probe periodically.
+ key = _responses_circuit_key(model, self.default_model, reasoning_effort)
+ failures = self._responses_failures.get(key, 0)
+ if failures >= _RESPONSES_FAILURE_THRESHOLD:
+ tripped = self._responses_tripped_at.get(key, 0.0)
+ if (time.monotonic() - tripped) < _RESPONSES_PROBE_INTERVAL_S:
+ return False
+ # Half-open: allow one probe attempt
+ return True
+
+ def _record_responses_failure(self, model: str | None, reasoning_effort: str | None) -> None:
+ key = _responses_circuit_key(model, self.default_model, reasoning_effort)
+ count = self._responses_failures.get(key, 0) + 1
+ self._responses_failures[key] = count
+ if count >= _RESPONSES_FAILURE_THRESHOLD:
+ self._responses_tripped_at[key] = time.monotonic()
+ logger.warning(
+ "Responses API circuit open for {} โ falling back to Chat Completions",
+ key,
+ )
+
+ def _record_responses_success(self, model: str | None, reasoning_effort: str | None) -> None:
+ key = _responses_circuit_key(model, self.default_model, reasoning_effort)
+ self._responses_failures.pop(key, None)
+ self._responses_tripped_at.pop(key, None)
@staticmethod
def _should_fallback_from_responses_error(e: Exception) -> bool:
@@ -915,10 +966,13 @@ class OpenAICompatProvider(LLMProvider):
messages, tools, model, max_tokens, temperature,
reasoning_effort, tool_choice,
)
- return parse_response_output(await self._client.responses.create(**body))
+ result = parse_response_output(await self._client.responses.create(**body))
+ self._record_responses_success(model, reasoning_effort)
+ return result
except Exception as responses_error:
if not self._should_fallback_from_responses_error(responses_error):
raise
+ self._record_responses_failure(model, reasoning_effort)
kwargs = self._build_kwargs(
messages, tools, model, max_tokens, temperature,
@@ -965,6 +1019,7 @@ class OpenAICompatProvider(LLMProvider):
_timed_stream(),
on_content_delta,
)
+ self._record_responses_success(model, reasoning_effort)
return LLMResponse(
content=content or None,
tool_calls=tool_calls,
@@ -975,6 +1030,7 @@ class OpenAICompatProvider(LLMProvider):
except Exception as responses_error:
if not self._should_fallback_from_responses_error(responses_error):
raise
+ self._record_responses_failure(model, reasoning_effort)
kwargs = self._build_kwargs(
messages, tools, model, max_tokens, temperature,
diff --git a/nanobot_arch.png b/nanobot_arch.png
deleted file mode 100644
index 09251771..00000000
Binary files a/nanobot_arch.png and /dev/null differ
diff --git a/tests/agent/test_loop_consolidation_tokens.py b/tests/agent/test_loop_consolidation_tokens.py
index 87e159cc..347cab1e 100644
--- a/tests/agent/test_loop_consolidation_tokens.py
+++ b/tests/agent/test_loop_consolidation_tokens.py
@@ -102,7 +102,7 @@ async def test_consolidation_loops_until_target_met(tmp_path, monkeypatch) -> No
loop.sessions.save(session)
call_count = [0]
- def mock_estimate(_session):
+ def mock_estimate(_session, *, session_summary=None):
call_count[0] += 1
if call_count[0] == 1:
return (500, "test")
@@ -139,7 +139,7 @@ async def test_consolidation_continues_below_trigger_until_half_target(tmp_path,
call_count = [0]
- def mock_estimate(_session):
+ def mock_estimate(_session, *, session_summary=None):
call_count[0] += 1
if call_count[0] == 1:
return (500, "test")
@@ -156,6 +156,61 @@ async def test_consolidation_continues_below_trigger_until_half_target(tmp_path,
assert session.last_consolidated == 6
+@pytest.mark.asyncio
+async def test_consolidation_persists_summary_for_next_prepare_session(tmp_path, monkeypatch) -> None:
+ loop = _make_loop(tmp_path, estimated_tokens=0, context_window_tokens=200)
+ loop.consolidator.archive = AsyncMock(return_value="User discussed project status.") # type: ignore[method-assign]
+
+ session = loop.sessions.get_or_create("cli:test")
+ session.messages = [
+ {"role": "user", "content": "u1", "timestamp": "2026-01-01T00:00:00"},
+ {"role": "assistant", "content": "a1", "timestamp": "2026-01-01T00:00:01"},
+ {"role": "user", "content": "u2", "timestamp": "2026-01-01T00:00:02"},
+ ]
+ loop.sessions.save(session)
+
+ call_count = [0]
+
+ def mock_estimate(_session, *, session_summary=None):
+ call_count[0] += 1
+ if call_count[0] == 1:
+ return (500, "test")
+ return (80, "test")
+
+ loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
+ monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 150)
+
+ await loop.consolidator.maybe_consolidate_by_tokens(session)
+
+ reloaded = loop.sessions.get_or_create("cli:test")
+ meta = reloaded.metadata.get("_last_summary")
+ assert meta is not None
+ assert meta["text"] == "User discussed project status."
+
+ reloaded, pending = loop.auto_compact.prepare_session(reloaded, "cli:test")
+ assert pending is not None
+ assert "User discussed project status." in pending
+ assert "_last_summary" not in reloaded.metadata
+
+
+@pytest.mark.asyncio
+async def test_preflight_consolidation_receives_pending_summary(tmp_path) -> None:
+ loop = _make_loop(tmp_path, estimated_tokens=100, context_window_tokens=200)
+ session = loop.sessions.get_or_create("cli:test")
+ loop.auto_compact.prepare_session = MagicMock(
+ return_value=(session, "Previous conversation summary: earlier context")
+ ) # type: ignore[method-assign]
+ loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=None) # type: ignore[method-assign]
+ loop._schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
+
+ await loop.process_direct("hello", session_key="cli:test")
+
+ loop.consolidator.maybe_consolidate_by_tokens.assert_awaited_once_with(
+ session,
+ session_summary="Previous conversation summary: earlier context",
+ )
+
+
@pytest.mark.asyncio
async def test_preflight_consolidation_before_llm_call(tmp_path, monkeypatch) -> None:
"""Verify preflight consolidation runs before the LLM call in process_direct."""
@@ -173,6 +228,7 @@ async def test_preflight_consolidation_before_llm_call(tmp_path, monkeypatch) ->
return LLMResponse(content="ok", tool_calls=[])
loop.provider.chat_with_retry = track_llm
loop.provider.chat_stream_with_retry = track_llm
+ loop._schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
session = loop.sessions.get_or_create("cli:test")
session.messages = [
@@ -184,7 +240,7 @@ async def test_preflight_consolidation_before_llm_call(tmp_path, monkeypatch) ->
monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 500)
call_count = [0]
- def mock_estimate(_session):
+ def mock_estimate(_session, *, session_summary=None):
call_count[0] += 1
return (1000 if call_count[0] <= 1 else 80, "test")
loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
diff --git a/tests/agent/test_unified_session.py b/tests/agent/test_unified_session.py
index 557beaca..acf0b9d6 100644
--- a/tests/agent/test_unified_session.py
+++ b/tests/agent/test_unified_session.py
@@ -395,7 +395,10 @@ class TestConsolidationUnaffectedByUnifiedSession:
await consolidator.maybe_consolidate_by_tokens(session)
# estimate was called (consolidation was attempted)
- consolidator.estimate_session_prompt_tokens.assert_called_once_with(session)
+ consolidator.estimate_session_prompt_tokens.assert_called_once_with(
+ session,
+ session_summary=None,
+ )
# but archive was not called (no valid boundary)
consolidator.archive.assert_not_called()
diff --git a/tests/cli/test_restart_command.py b/tests/cli/test_restart_command.py
index bc714790..eaa3d950 100644
--- a/tests/cli/test_restart_command.py
+++ b/tests/cli/test_restart_command.py
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import os
import time
+from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -31,6 +32,15 @@ def _make_loop():
return loop, bus
+async def _wait_until(predicate, *, timeout: float = 0.2, interval: float = 0.01) -> None:
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ if predicate():
+ return
+ await asyncio.sleep(interval)
+ assert predicate()
+
+
class TestRestartCommand:
@pytest.mark.asyncio
@@ -47,7 +57,23 @@ class TestRestartCommand:
msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/restart")
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/restart", loop=loop)
+ async def _fast_sleep(_delay: float) -> None:
+ return None
+
+ scheduled: list[asyncio.Task] = []
+
+ def _capture_task(coro):
+ task = asyncio.create_task(coro)
+ scheduled.append(task)
+ return task
+
+ fake_asyncio = SimpleNamespace(
+ sleep=_fast_sleep,
+ create_task=_capture_task,
+ )
+
with patch.dict(os.environ, {}, clear=False), \
+ patch("nanobot.command.builtin.asyncio", new=fake_asyncio), \
patch("nanobot.command.builtin.os.execv") as mock_execv:
out = await cmd_restart(ctx)
assert "Restarting" in out.content
@@ -55,7 +81,8 @@ class TestRestartCommand:
assert os.environ.get(RESTART_NOTIFY_CHAT_ID_ENV) == "direct"
assert os.environ.get(RESTART_STARTED_AT_ENV)
- await asyncio.sleep(1.5)
+ assert scheduled
+ await scheduled[0]
mock_execv.assert_called_once()
@pytest.mark.asyncio
diff --git a/tests/cron/test_cron_service.py b/tests/cron/test_cron_service.py
index 747f8ec8..0e83b187 100644
--- a/tests/cron/test_cron_service.py
+++ b/tests/cron/test_cron_service.py
@@ -8,6 +8,15 @@ from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
+async def _wait_until(predicate, *, timeout: float = 1.0, interval: float = 0.01) -> None:
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ if predicate():
+ return
+ await asyncio.sleep(interval)
+ assert predicate()
+
+
def test_add_job_rejects_unknown_timezone(tmp_path) -> None:
service = CronService(tmp_path / "cron" / "jobs.json")
@@ -201,18 +210,18 @@ async def test_start_server_not_jobs(tmp_path):
async def on_job(job):
called.append(job.name)
- service = CronService(store_path, on_job=on_job, max_sleep_ms=1000)
+ service = CronService(store_path, on_job=on_job, max_sleep_ms=100)
await service.start()
assert len(service.list_jobs()) == 0
service2 = CronService(tmp_path / "cron" / "jobs.json")
service2.add_job(
name="hist",
- schedule=CronSchedule(kind="every", every_ms=500),
+ schedule=CronSchedule(kind="every", every_ms=100),
message="hello",
)
assert len(service.list_jobs()) == 1
- await asyncio.sleep(2)
+ await _wait_until(lambda: bool(called), timeout=0.8)
assert len(called) != 0
service.stop()
@@ -248,10 +257,10 @@ async def test_running_service_picks_up_external_add(tmp_path):
async def on_job(job):
called.append(job.name)
- service = CronService(store_path, on_job=on_job)
+ service = CronService(store_path, on_job=on_job, max_sleep_ms=100)
service.add_job(
name="heartbeat",
- schedule=CronSchedule(kind="every", every_ms=150),
+ schedule=CronSchedule(kind="every", every_ms=100),
message="tick",
)
await service.start()
@@ -261,11 +270,11 @@ async def test_running_service_picks_up_external_add(tmp_path):
external = CronService(store_path)
external.add_job(
name="external",
- schedule=CronSchedule(kind="every", every_ms=150),
+ schedule=CronSchedule(kind="every", every_ms=100),
message="ping",
)
- await asyncio.sleep(2)
+ await _wait_until(lambda: "external" in called, timeout=0.8)
assert "external" in called
finally:
service.stop()
@@ -287,16 +296,16 @@ async def test_add_job_during_jobs_exec(tmp_path):
)
run_once = False
- service = CronService(store_path, on_job=on_job)
+ service = CronService(store_path, on_job=on_job, max_sleep_ms=100)
service.add_job(
name="heartbeat",
- schedule=CronSchedule(kind="every", every_ms=150),
+ schedule=CronSchedule(kind="every", every_ms=100),
message="tick",
)
assert len(service.list_jobs()) == 1
await service.start()
try:
- await asyncio.sleep(3)
+ await _wait_until(lambda: len(service.list_jobs()) == 2, timeout=0.8)
jobs = service.list_jobs()
assert len(jobs) == 2
assert "test" in [j.name for j in jobs]
diff --git a/tests/providers/test_litellm_kwargs.py b/tests/providers/test_litellm_kwargs.py
index 8304aae8..47db2039 100644
--- a/tests/providers/test_litellm_kwargs.py
+++ b/tests/providers/test_litellm_kwargs.py
@@ -441,6 +441,35 @@ async def test_direct_openai_responses_404_falls_back_to_chat_completions() -> N
mock_chat.assert_awaited_once()
+@pytest.mark.asyncio
+async def test_direct_openai_open_circuit_skips_responses_api() -> None:
+ mock_chat = AsyncMock(return_value=_fake_chat_response("from chat"))
+ mock_responses = AsyncMock(return_value=_fake_responses_response("from responses"))
+ spec = find_by_name("openai")
+
+ with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
+ client_instance = MockClient.return_value
+ client_instance.chat.completions.create = mock_chat
+ client_instance.responses.create = mock_responses
+
+ provider = OpenAICompatProvider(
+ api_key="sk-test-key",
+ default_model="gpt-5-chat",
+ spec=spec,
+ )
+ for _ in range(3):
+ provider._record_responses_failure("gpt-5-chat", None)
+
+ result = await provider.chat(
+ messages=[{"role": "user", "content": "hello"}],
+ model="gpt-5-chat",
+ )
+
+ assert result.content == "from chat"
+ mock_responses.assert_not_awaited()
+ mock_chat.assert_awaited_once()
+
+
@pytest.mark.asyncio
async def test_direct_openai_stream_responses_unsupported_param_falls_back() -> None:
mock_chat = AsyncMock(return_value=_fake_chat_stream("fallback stream"))
diff --git a/tests/providers/test_responses_circuit_breaker.py b/tests/providers/test_responses_circuit_breaker.py
new file mode 100644
index 00000000..409aea1d
--- /dev/null
+++ b/tests/providers/test_responses_circuit_breaker.py
@@ -0,0 +1,77 @@
+"""Tests for Responses API circuit breaker in OpenAICompatProvider."""
+
+import time
+
+import pytest
+
+from nanobot.providers.openai_compat_provider import (
+ OpenAICompatProvider,
+ _RESPONSES_FAILURE_THRESHOLD,
+ _RESPONSES_PROBE_INTERVAL_S,
+)
+
+
+@pytest.fixture()
+def provider():
+ """A direct-OpenAI provider with Responses API support."""
+ p = OpenAICompatProvider.__new__(OpenAICompatProvider)
+ p.default_model = "gpt-5"
+ p._spec = type("Spec", (), {"name": "openai"})()
+ p._effective_base = "https://api.openai.com/v1"
+ p._responses_failures = {}
+ p._responses_tripped_at = {}
+ return p
+
+
+def test_responses_api_available_by_default(provider):
+ assert provider._should_use_responses_api("gpt-5", None) is True
+
+
+def test_circuit_opens_after_threshold(provider):
+ for _ in range(_RESPONSES_FAILURE_THRESHOLD):
+ provider._record_responses_failure("gpt-5", None)
+ assert provider._should_use_responses_api("gpt-5", None) is False
+
+
+def test_circuit_does_not_affect_other_models(provider):
+ for _ in range(_RESPONSES_FAILURE_THRESHOLD):
+ provider._record_responses_failure("gpt-5", None)
+ assert provider._should_use_responses_api("o4-mini", None) is True
+
+
+def test_success_resets_circuit(provider):
+ for _ in range(_RESPONSES_FAILURE_THRESHOLD):
+ provider._record_responses_failure("gpt-5", None)
+ assert provider._should_use_responses_api("gpt-5", None) is False
+ provider._record_responses_success("gpt-5", None)
+ assert provider._should_use_responses_api("gpt-5", None) is True
+
+
+def test_probe_after_interval(provider, monkeypatch):
+ for _ in range(_RESPONSES_FAILURE_THRESHOLD):
+ provider._record_responses_failure("gpt-5", None)
+ assert provider._should_use_responses_api("gpt-5", None) is False
+
+ # Fast-forward past the probe interval
+ key = "gpt-5:"
+ provider._responses_tripped_at[key] = time.monotonic() - _RESPONSES_PROBE_INTERVAL_S - 1
+ assert provider._should_use_responses_api("gpt-5", None) is True
+
+
+def test_below_threshold_still_allows(provider):
+ provider._record_responses_failure("gpt-5", None)
+ provider._record_responses_failure("gpt-5", None)
+ assert provider._should_use_responses_api("gpt-5", None) is True
+
+
+def test_reasoning_effort_keyed_separately(provider):
+ for _ in range(_RESPONSES_FAILURE_THRESHOLD):
+ provider._record_responses_failure("o3", "high")
+ assert provider._should_use_responses_api("o3", "high") is False
+ assert provider._should_use_responses_api("o3", "low") is True
+
+
+def test_reasoning_effort_key_is_case_insensitive(provider):
+ for _ in range(_RESPONSES_FAILURE_THRESHOLD):
+ provider._record_responses_failure("o3", "High")
+ assert provider._should_use_responses_api("o3", "high") is False