Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Moltis

Alpha software: use with care

Running an AI assistant on your own machine or server is still new territory. Treat Moltis as alpha software: run it in isolated environments, review enabled tools/providers, keep secrets scoped and rotated, and avoid exposing it publicly without strong authentication and network controls.

A personal AI gateway written in Rust.
One binary, no runtime, no npm.

Moltis compiles your entire AI gateway — web UI, LLM providers, tools, and all assets — into a single self-contained executable. There’s no Node.js to babysit, no node_modules to sync, no V8 garbage collector introducing latency spikes.

# Quick install (macOS / Linux)
curl -fsSL https://www.moltis.org/install.sh | sh

Why Moltis?

FeatureMoltisOther Solutions
DeploymentSingle binaryNode.js + dependencies
Memory SafetyRust ownershipGarbage collection
Secret HandlingZeroed on drop“Eventually collected”
SandboxDocker + Apple ContainerDocker only
StartupMillisecondsSeconds

Key Features

  • Multiple LLM Providers — Anthropic, OpenAI, Google Gemini, DeepSeek, Mistral, Groq, xAI, OpenRouter, Ollama, Local LLM, and more
  • Streaming-First — Responses appear as tokens arrive, not after completion
  • Sandboxed Execution — Commands run in isolated containers (Docker or Apple Container)
  • MCP Support — Connect to Model Context Protocol servers for extended capabilities
  • Multi-Channel — Web UI, Telegram, Discord, API access with synchronized responses
  • Built-in Throttling — Per-IP endpoint limits with strict login protection
  • Long-Term Memory — Embeddings-powered knowledge base with hybrid search
  • Hook System — Observe, modify, or block actions at any lifecycle point
  • Compile-Time Safety — Misconfigurations caught by cargo check, not runtime crashes

See the full list of supported providers.

Quick Start

# Install
curl -fsSL https://www.moltis.org/install.sh | sh

# Run
moltis

On first launch:

  1. Open the URL shown in your browser (e.g., http://localhost:13131)
  2. Add your LLM API key
  3. Start chatting!

Note

Authentication is only required when accessing Moltis from a non-localhost address. On localhost, you can start using it immediately.

Full Quickstart Guide

How It Works

┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐
│  Web UI  │  │ Telegram │  │ Discord  │  │   API    │
└────┬─────┘  └────┬─────┘  └────┬─────┘  └────┬─────┘
     │             │             │             │
     └─────────────┴─────────┬───┴─────────────┘
                             │
                             ▼
        ┌───────────────────────────────┐
        │       Moltis Gateway          │
        │   ┌─────────┐ ┌───────────┐   │
        │   │  Agent  │ │   Tools   │   │
        │   │  Loop   │◄┤  Registry │   │
        │   └────┬────┘ └───────────┘   │
        │        │                      │
        │   ┌────▼────────────────┐     │
        │   │  Provider Registry  │     │
        │   │ Anthropic·OpenAI·Gemini… │   │
        │   └─────────────────────┘     │
        └───────────────────────────────┘
                        │
                ┌───────▼───────┐
                │    Sandbox    │
                │ Docker/Apple  │
                └───────────────┘

Documentation

Getting Started

Features

  • Providers — Configure LLM providers
  • MCP Servers — Extend with Model Context Protocol
  • Hooks — Lifecycle hooks for customization
  • Local LLMs — Run models on your machine

Deployment

  • Docker — Container deployment

Architecture

Security

Moltis applies defense in depth:

  • Authentication — Password or passkey (WebAuthn) required for non-localhost access
  • SSRF Protection — Blocks requests to internal networks
  • Secret Handlingsecrecy::Secret zeroes memory on drop
  • Sandboxed Execution — Commands never run on the host
  • Origin Validation — Prevents Cross-Site WebSocket Hijacking
  • No Unsafe Codeunsafe is denied workspace-wide

Community

License

MIT — Free for personal and commercial use.

Quickstart

Get Moltis running in under 5 minutes.

1. Install

curl -fsSL https://www.moltis.org/install.sh | sh

Or via Homebrew:

brew install moltis-org/tap/moltis

2. Start

moltis

You’ll see output like:

🚀 Moltis gateway starting...
🌐 Open http://localhost:13131 in your browser

3. Configure a Provider

You need an LLM provider configured to chat. The fastest options:

Option A: API Key (Anthropic, OpenAI, Gemini, etc.)

  1. Set an API key as an environment variable and restart Moltis:
    export ANTHROPIC_API_KEY="sk-ant-..."   # Anthropic
    export OPENAI_API_KEY="sk-..."          # OpenAI
    export GEMINI_API_KEY="..."             # Google Gemini
    
  2. Models appear automatically in the model picker.

Or configure via the web UI: SettingsProviders → enter your API key.

Option B: OAuth (Codex / Copilot)

  1. In Moltis, go to SettingsProviders
  2. Click OpenAI Codex or GitHub CopilotConnect
  3. Complete the OAuth flow

Option C: Local LLM (Offline)

  1. In Moltis, go to SettingsProviders
  2. Click Local LLM
  3. Choose a model and save

See Providers for the full list of supported providers.

4. Chat!

Go to the Chat tab and start a conversation:

You: Write a Python function to check if a number is prime

Agent: Here's a Python function to check if a number is prime:

def is_prime(n):
    if n < 2:
        return False
    for i in range(2, int(n ** 0.5) + 1):
        if n % i == 0:
            return False
    return True

What’s Next?

Enable Tool Use

Moltis can execute code, browse the web, and more. Tools are enabled by default with sandbox protection.

Try:

You: Create a hello.py file that prints "Hello, World!" and run it

Connect Telegram

Chat with your agent from anywhere:

  1. Create a bot via @BotFather
  2. Copy the bot token
  3. In Moltis: SettingsTelegram → Enter token → Save
  4. Message your bot!

Connect Discord

  1. Create a bot in the Discord Developer Portal
  2. Enable Message Content Intent and copy the bot token
  3. In Moltis: SettingsChannelsConnect Discord → Enter token → Connect
  4. Invite the bot to your server and @mention it!

Full Discord setup guide

Add MCP Servers

Extend capabilities with MCP servers:

# In moltis.toml
[mcp]
[mcp.servers.github]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
env = { GITHUB_TOKEN = "ghp_..." }

Set Up Memory

Enable long-term memory for context across sessions:

# In moltis.toml
[memory]
provider = "openai"
model = "text-embedding-3-small"

Add knowledge by placing Markdown files in ~/.moltis/memory/.

Useful Commands

CommandDescription
/newStart a new session
/model <name>Switch models
/clearClear chat history
/helpShow available commands

File Locations

PathContents
~/.config/moltis/moltis.tomlConfiguration
~/.config/moltis/provider_keys.jsonAPI keys
~/.moltis/Data (sessions, memory, logs)

Getting Help

Installation

Moltis is distributed as a single self-contained binary. Choose the installation method that works best for your setup.

The fastest way to get started on macOS or Linux:

curl -fsSL https://www.moltis.org/install.sh | sh

This downloads the latest release for your platform and installs it to ~/.local/bin.

Package Managers

Homebrew (macOS / Linux)

brew install moltis-org/tap/moltis

Linux Packages

Debian / Ubuntu (.deb)

# Download the latest .deb package
curl -LO https://github.com/moltis-org/moltis/releases/latest/download/moltis_amd64.deb

# Install
sudo dpkg -i moltis_amd64.deb

Fedora / RHEL (.rpm)

# Download the latest .rpm package
curl -LO https://github.com/moltis-org/moltis/releases/latest/download/moltis.x86_64.rpm

# Install
sudo rpm -i moltis.x86_64.rpm

Arch Linux (.pkg.tar.zst)

# Download the latest package
curl -LO https://github.com/moltis-org/moltis/releases/latest/download/moltis.pkg.tar.zst

# Install
sudo pacman -U moltis.pkg.tar.zst

Snap

sudo snap install moltis

AppImage

# Download
curl -LO https://github.com/moltis-org/moltis/releases/latest/download/moltis.AppImage
chmod +x moltis.AppImage

# Run
./moltis.AppImage

Docker

Multi-architecture images (amd64/arm64) are published to GitHub Container Registry:

docker pull ghcr.io/moltis-org/moltis:latest

See Docker Deployment for full instructions on running Moltis in a container.

Build from Source

Prerequisites

  • Rust 1.91 or later
  • A C compiler (for some dependencies)
  • just (command runner)
  • Node.js (for building Tailwind CSS)

Clone and Build

git clone https://github.com/moltis-org/moltis.git
cd moltis
just build-css           # Build Tailwind CSS for the web UI
just build-release       # Build in release mode

For a full release build including WASM sandbox tools:

just build-release-with-wasm

The binary will be at target/release/moltis.

Install via Cargo

cargo install moltis --git https://github.com/moltis-org/moltis

First Run

After installation, start Moltis:

moltis

On first launch:

  1. Open http://localhost:<port> in your browser (the port is shown in the terminal output)
  2. Configure your LLM provider (API key)
  3. Start chatting!

Tip

Moltis picks a random available port on first install to avoid conflicts. The port is saved in your config and reused on subsequent runs.

Note

Authentication is only required when accessing Moltis from a non-localhost address (e.g., over the network). When this happens, a one-time setup code is printed to the terminal for initial authentication setup.

Verify Installation

moltis --version

Updating

Homebrew

brew upgrade moltis

From Source

cd moltis
git pull
just build-css
just build-release

Uninstalling

Homebrew

brew uninstall moltis

Remove Data

Moltis stores data in two directories:

# Configuration
rm -rf ~/.config/moltis

# Data (sessions, databases, memory)
rm -rf ~/.moltis

Warning

Removing these directories deletes all your conversations, memory, and settings permanently.

Comparison

How Moltis compares to other open-source AI agent frameworks.

Disclaimer: This comparison reflects publicly available information at the time of writing. Projects evolve quickly — check each project’s repository for the latest details. Contributions to keep this page accurate are welcome.

At a Glance

OpenClawPicoClawNanoClawZeroClawMoltis
LanguageTypeScriptGoTypeScriptRustRust
Agent loop~430K LoCSmall~500 LoC~3.4K LoC~5K LoC
Full codebase1,000+ tests~200K LoC (3,100+ tests)
RuntimeNode.js + npmSingle binaryNode.jsSingle binary (3.4 MB)Single binary (44 MB)
SandboxApp-levelDockerDockerDocker + Apple Container
Memory safetyGCGCGCOwnershipOwnership, zero unsafe*
AuthBasicAPI keysNoneToken + OAuthPassword + Passkey + API keys
Voice I/OPluginBuilt-in (15+ providers)
MCPYesYes (stdio + HTTP/SSE)
HooksYes (limited)15 event types
SkillsYes (store)YesYesYesYes (+ OpenClaw Store)
Memory/RAGPluginPer-groupSQLite + FTSSQLite + FTS + vector

* unsafe is denied workspace-wide in Moltis. The only exceptions are opt-in FFI wrappers behind the local-embeddings feature flag, not part of the core.

Architecture Approach

OpenClaw is the original and most popular project (~211K stars). It ships as a Node.js application with 52+ modules, 45+ npm dependencies, and a large surface area. It has the richest ecosystem of third-party skills and integrations, but the codebase is difficult to audit end-to-end.

PicoClaw — Minimal Go binary

PicoClaw targets extreme resource constraints — $10 SBCs, RISC-V boards, and devices with as little as 10 MB of RAM. It boots in under 1 second on 0.6 GHz hardware. The trade-off is a narrower feature set: no sandbox isolation, no built-in memory/RAG, and limited extensibility.

NanoClaw — Container-first TypeScript

NanoClaw strips away OpenClaw’s complexity to deliver a small, readable TypeScript codebase with first-class container isolation. Agents run in Linux containers with filesystem isolation. It uses Claude Agent SDK for sub-agent delegation and per-group CLAUDE.md memory files. The trade-off is Node.js as a runtime dependency and a smaller feature surface.

ZeroClaw — Lightweight Rust

ZeroClaw compiles to a tiny 3.4 MB binary with <5 MB RAM usage and sub-10ms startup. It uses trait-driven architecture with 22+ provider implementations and 9+ channel integrations. Memory is backed by SQLite with hybrid vector + FTS search. The focus is on minimal footprint and broad platform support.

Moltis — Auditable Rust gateway

Moltis prioritizes auditability and defense in depth. The core agent engine (runner + provider model) is ~5K lines; the core (excluding the optional web UI) is ~196K lines across 46 modular crates, each independently auditable. Key differences from ZeroClaw:

  • Larger binary (44 MB) in exchange for built-in voice I/O, browser automation, web UI, and MCP support
  • Apple Container support in addition to Docker
  • WebAuthn passkey authentication — not just tokens
  • 15 lifecycle hook events with circuit breaker and dry-run mode
  • Built-in web UI with real-time streaming, settings management, and session branching

Security Model

AspectOpenClawPicoClawNanoClawZeroClawMoltis
Code sandboxApp-level permissionsNoneDocker containersDocker containersDocker + Apple Container
Secret handlingEnvironment variablesEnvironment variablesEnvironment variablesEncrypted profilessecrecy::Secret, zeroed on drop
Auth methodBasic passwordAPI keys onlyNone (WhatsApp auth)Token + OAuthPassword + Passkey + API keys
SSRF protectionPluginDNS validationDNS-resolved, blocks loopback/private/link-local/CGNAT
WebSocket originN/ACross-origin rejection
unsafe codeN/A (JS)N/A (Go)N/A (JS)MinimalDenied workspace-wide*
Hook gatingSkills-basedBeforeToolCall inspect/modify/block
Rate limitingPer-IP throttle, strict login limits

Performance

MetricOpenClawPicoClawZeroClawMoltis
Binary / dist size~28 MB (node_modules)<10 MB3.4 MB44 MB
Cold start>30s<1s<10ms~1s
RAM (idle)>100 MB<10 MB<5 MB~30 MB
Min hardwareModern desktop$10 SBC (RISC-V)$10 SBCRaspberry Pi 4+

Moltis is larger because it bundles a web UI, voice engine, browser automation, and MCP runtime. Use --no-default-features --features lightweight for constrained devices.

When to Choose What

Choose OpenClaw if you want the largest ecosystem, maximum third-party skills, and don’t mind Node.js as a dependency.

Choose PicoClaw if you need to run on extremely constrained hardware ($10 boards, RISC-V) and can accept a minimal feature set.

Choose NanoClaw if you want a small, readable TypeScript codebase with container isolation and don’t need voice, MCP, or a web UI.

Choose ZeroClaw if you want the smallest possible Rust binary, sub-10ms startup, and broad channel support without a web UI.

Choose Moltis if you want:

  • A single auditable Rust binary with built-in web UI
  • Voice I/O with 15+ providers (8 TTS + 7 STT)
  • MCP server support (stdio + HTTP/SSE)
  • WebAuthn passkey authentication
  • Apple Container sandbox support (macOS native)
  • 15 lifecycle hook events with circuit breaker
  • Embeddings-powered long-term memory with hybrid search
  • Cron scheduling, browser automation, and Tailscale integration

Configuration

Moltis is configured through moltis.toml, located in ~/.config/moltis/ by default.

On first run, a complete configuration file is generated with sensible defaults. You can edit it to customize behavior.

Configuration File Location

PlatformDefault Path
macOS/Linux~/.config/moltis/moltis.toml
CustomSet via --config-dir or MOLTIS_CONFIG_DIR

Basic Settings

[server]
port = 13131                    # HTTP/WebSocket port
bind = "0.0.0.0"               # Listen address

[identity]
name = "Moltis"                 # Agent display name

[tools]
agent_timeout_secs = 600        # Agent run timeout (seconds, 0 = no timeout)
agent_max_iterations = 25       # Max tool call iterations per run

LLM Providers

Configure providers through the web UI or directly in moltis.toml. API keys can be set via environment variables (e.g. ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY) or in the config file.

[providers]
offered = ["anthropic", "openai", "gemini"]

[providers.anthropic]
enabled = true

[providers.openai]
enabled = true
models = ["gpt-5.3", "gpt-5.2"]
stream_transport = "sse"        # "sse", "websocket", or "auto"

[providers.gemini]
enabled = true
models = ["gemini-2.5-flash-preview-05-20", "gemini-2.0-flash"]

[providers.local-llm]
enabled = true
models = ["qwen2.5-coder-7b-q4_k_m"]

[chat]
priority_models = ["gpt-5.2"]

See Providers for the full list of supported providers and configuration options.

Sandbox Configuration

Commands run inside isolated containers for security:

[tools.exec.sandbox]
mode = "all"                    # "off", "non-main", or "all"
scope = "session"               # "command", "session", or "global"
workspace_mount = "ro"          # "ro", "rw", or "none"
# host_data_dir = "/host/path/data"  # Optional override if auto-detection cannot resolve the host path
home_persistence = "shared"     # "off", "session", or "shared" (default: "shared")
# shared_home_dir = "/path/to/shared-home"  # Optional path for shared mode
backend = "auto"                # "auto", "docker", or "apple-container"
no_network = true

# Packages installed in the sandbox image
packages = [
    "curl",
    "git",
    "jq",
    "tmux",
    "python3",
    "python3-pip",
    "nodejs",
    "npm",
    "golang-go",
]

If Moltis runs inside Docker and also mounts the host container socket (/var/run/docker.sock), Moltis now auto-detects the host path backing /home/moltis/.moltis from the parent container’s mount table. If that inspection cannot resolve the correct path, set host_data_dir explicitly.

Info

When you modify the packages list and restart, Moltis automatically rebuilds the sandbox image with a new tag.

Configure the built-in web_search tool:

[tools.web.search]
enabled = true
provider = "brave"               # "brave" or "perplexity"
max_results = 5                  # 1-10
timeout_seconds = 30
cache_ttl_minutes = 15
duckduckgo_fallback = false      # Default: do not use DuckDuckGo fallback
# api_key = "..."                # Brave key, or use BRAVE_API_KEY

[tools.web.search.perplexity]
# api_key = "..."                # Or use PERPLEXITY_API_KEY / OPENROUTER_API_KEY
# base_url = "..."               # Optional override
# model = "perplexity/sonar-pro" # Optional override

If no search API key is configured:

  • with duckduckgo_fallback = false (default), Moltis returns a clear hint to set BRAVE_API_KEY or PERPLEXITY_API_KEY
  • with duckduckgo_fallback = true, Moltis attempts DuckDuckGo HTML search, which may hit CAPTCHA/rate limits

Skills

Configure skill discovery and agent-managed personal skills:

[skills]
enabled = true
auto_load = ["commit"]
enable_agent_sidecar_files = false  # Opt-in: allow agents to write sidecar text files in personal skills

enable_agent_sidecar_files is disabled by default. When enabled, Moltis registers the write_skill_files tool so agents can write supplementary files such as script.sh, Dockerfile, templates, or _meta.json inside <data_dir>/skills/<name>/. Writes stay confined to that personal skill directory, reject path traversal and symlink escapes, and are recorded in ~/.moltis/logs/security-audit.jsonl.

Chat Message Queue

When a new message arrives while an agent run is already active, Moltis can either replay queued messages one-by-one or merge them into a single follow-up message.

[chat]
message_queue_mode = "followup"  # Default: one-by-one replay

# Options:
#   "followup" - Queue each message and run them sequentially
#   "collect"  - Merge queued text and run once after the active run

Memory System

Long-term memory uses embeddings for semantic search:

[memory]
backend = "builtin"             # Or "qmd"
provider = "openai"             # Or "local", "ollama", "custom"
model = "text-embedding-3-small"
citations = "auto"              # "on", "off", or "auto"
llm_reranking = false
session_export = false

Authentication

Authentication is only required when accessing Moltis from a non-localhost address. When running on localhost or 127.0.0.1, no authentication is needed by default.

When you access Moltis from a network address (e.g., http://192.168.1.100:13131), a one-time setup code is printed to the terminal. Use it to set up a password or passkey.

[auth]
disabled = false                # Set true to disable auth entirely

Warning

Only set disabled = true if Moltis is running on a trusted private network. Never expose an unauthenticated instance to the internet.

Hooks

Configure lifecycle hooks:

[hooks]
[[hooks.hooks]]
name = "my-hook"
command = "./hooks/my-hook.sh"
events = ["BeforeToolCall", "AfterToolCall"]
timeout = 5                     # Timeout in seconds

[hooks.hooks.env]
MY_VAR = "value"               # Environment variables for the hook

See Hooks for the full hook system documentation.

MCP Servers

Connect to Model Context Protocol servers:

[mcp]
request_timeout_secs = 30
                                    # Default timeout for MCP requests (seconds)

[mcp.servers.filesystem]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed"]
request_timeout_secs = 90        # Optional override for this server

[mcp.servers.github]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
env = { GITHUB_TOKEN = "ghp_..." }

[mcp.servers.remote_api]
transport = "sse"
url = "https://mcp.example.com/mcp?api_key=$REMOTE_MCP_KEY"
headers = { Authorization = "Bearer ${REMOTE_MCP_TOKEN}" }

Remote MCP URLs and headers support $NAME or ${NAME} placeholders. For live remote servers, values resolve from Moltis-managed env overrides, either [env] in config or SettingsEnvironment Variables.

Telegram Integration

[channels.telegram.my-bot]
token = "123456:ABC..."
dm_policy = "allowlist"
allowlist = ["123456789"]       # Telegram user IDs or usernames (strings)

See Telegram for full configuration reference and setup instructions.

Discord Integration

[channels]
offered = ["telegram", "discord"]

[channels.discord.my-bot]
token = "MTIzNDU2Nzg5.example.bot-token"
dm_policy = "allowlist"
mention_mode = "mention"
allowlist = ["your_username"]

See Discord for full configuration reference and setup instructions.

Slack Integration

[channels]
offered = ["slack"]

[channels.slack.my-bot]
bot_token = "xoxb-..."
app_token = "xapp-..."
dm_policy = "allowlist"
allowlist = ["U123456789"]

See Slack for full configuration reference and setup instructions.

TLS / HTTPS

[tls]
enabled = true
cert_path = "~/.config/moltis/cert.pem"
key_path = "~/.config/moltis/key.pem"
# If paths don't exist, a self-signed certificate is generated

# Port for the plain-HTTP redirect / CA-download server.
# Defaults to the server port + 1 when not set.
# http_redirect_port = 13132

Override via environment variable: MOLTIS_TLS__HTTP_REDIRECT_PORT=8080.

Tailscale Integration

Expose Moltis over your Tailscale network:

[tailscale]
mode = "serve"                  # "off", "serve", or "funnel"
reset_on_exit = true

Observability

[metrics]
enabled = true
prometheus_endpoint = true

Process Environment Variables ([env])

The [env] section injects variables into the Moltis process at startup. This is useful in Docker deployments where passing individual -e flags is inconvenient, or when you want API keys stored in the config file rather than the host environment.

[env]
BRAVE_API_KEY = "your-brave-key"
OPENROUTER_API_KEY = "sk-or-..."
ELEVENLABS_API_KEY = "..."

Precedence: existing process environment variables are never overwritten. If BRAVE_API_KEY is already set via docker -e or the host shell, the [env] value is skipped. This means docker -e always wins.

Settings UI vs [env]

Environment variables configured through the Settings UI (Settings > Environment) are also injected into the Moltis process at startup. Precedence: host/docker -e > config [env] > Settings UI.

Environment Variables

All settings can be overridden via environment variables:

VariableDescription
MOLTIS_CONFIG_DIRConfiguration directory
MOLTIS_DATA_DIRData directory
MOLTIS_SERVER__PORTServer port override
MOLTIS_SERVER__BINDServer bind address override
MOLTIS_TOOLS__AGENT_TIMEOUT_SECSAgent run timeout override
MOLTIS_TOOLS__AGENT_MAX_ITERATIONSAgent loop iteration cap override

CLI Flags

moltis --config-dir /path/to/config --data-dir /path/to/data

Complete Example

[server]
port = 13131
bind = "0.0.0.0"

[identity]
name = "Atlas"

[tools]
agent_timeout_secs = 600
agent_max_iterations = 25

[providers]
offered = ["anthropic", "openai", "gemini"]

[tools.exec.sandbox]
mode = "all"
scope = "session"
workspace_mount = "ro"
home_persistence = "session"
# shared_home_dir = "/path/to/shared-home"
backend = "auto"
no_network = true
packages = ["curl", "git", "jq", "python3", "nodejs", "golang-go"]

[memory]
backend = "builtin"
provider = "openai"
model = "text-embedding-3-small"

[auth]
disabled = false

[hooks]
[[hooks.hooks]]
name = "audit-log"
command = "./hooks/audit.sh"
events = ["BeforeToolCall"]
timeout = 5

Local Validation

Moltis provides a local validation script that runs the same checks as CI (format, lint, test, e2e), plus a native macOS app build check on macOS hosts.

Why this exists

  • Faster feedback for Rust-heavy branches (no long runner queues for every push)
  • Better parity with a developer’s local environment while iterating
  • Clear visibility in the PR UI (fmt, biome, zizmor, clippy, test, macos-app, e2e)

Run local validation

Run all checks on your current checkout:

./scripts/local-validate.sh

When working on a pull request, pass the PR number to also publish commit statuses to GitHub:

./scripts/local-validate.sh 63

The script runs these checks:

  • local/fmt
  • local/biome
  • local/zizmor
  • local/lockfile — verifies Cargo.lock is in sync (cargo fetch --locked)
  • local/lint
  • local/test
  • local/macos-app — validates the native Swift macOS app build (Darwin only)
  • local/e2e — runs gateway UI Playwright coverage

In PR mode, the PR workflow verifies these contexts and surfaces them as checks in the PR.

Notes

  • The script requires a clean working tree (no uncommitted or untracked changes). Commit or stash local changes before running.
  • On macOS without CUDA (nvcc), the script automatically falls back to non-CUDA test/coverage defaults for local runs.
  • On Linux, local/lint and local/test use --all-features. If you want the opt-in Vulkan path covered locally, install the Vulkan development packages first, for example libvulkan-dev and glslang-tools on Debian/Ubuntu (on Ubuntu 22.04, install glslc from the LunarG Vulkan SDK).
  • local/lint uses the same clippy flags as CI and release: cargo +nightly-2025-11-30 clippy -Z unstable-options --workspace --all-features --all-targets --timings -- -D warnings.
  • zizmor is installed automatically (Homebrew on macOS, apt on Linux) when not already available.
  • zizmor is advisory in local runs and does not block lint/test execution.
  • Test output is suppressed unless tests fail.
  • local/macos-app runs only on macOS; on Linux it is marked skipped.
  • Override or disable macOS app validation with: LOCAL_VALIDATE_MACOS_APP_CMD and LOCAL_VALIDATE_SKIP_MACOS_APP=1.
  • local/e2e auto-runs npm ci only when crates/web/ui/node_modules is missing, then runs npm run e2e:install and npm run e2e. Override with LOCAL_VALIDATE_E2E_CMD.

Merge and release safety

This local-first flow is for pull requests. Full CI still runs on GitHub runners for non-PR events (for example push to main, scheduled runs, and release paths).

End-to-End Testing

This project uses Playwright to run browser-level tests against a real moltis gateway process.

The goal is simple: catch web UI regressions before they ship.

Why This Approach

  • Tests run in a real browser (Chromium), not a DOM mock.
  • Tests hit real gateway routes and WebSocket behavior.
  • Runtime state is isolated so local machine config does not leak into test outcomes.

Current Setup

The e2e harness lives in crates/web/ui:

  • playwright.config.js configures Playwright and web server startup.
  • e2e/start-gateway.sh boots the gateway in deterministic test mode.
  • e2e/specs/smoke.spec.js contains smoke coverage for critical routes.

How Startup Works

e2e/start-gateway.sh:

  1. Creates isolated runtime directories under target/e2e-runtime.
  2. Seeds IDENTITY.md and USER.md so onboarding does not block tests.
  3. Exports MOLTIS_CONFIG_DIR, MOLTIS_DATA_DIR, and test port env.
  4. Starts the gateway with:
cargo run --bin moltis -- --no-tls --bind 127.0.0.1 --port <PORT>

--no-tls is intentional here so Playwright can probe http://.../health during readiness checks.

Running E2E

From repo root (recommended):

just ui-e2e-install
just ui-e2e

Headed mode:

just ui-e2e-headed

Directly from crates/web/ui:

npm install
npm run e2e:install
npm run e2e

Test Artifacts

On failures, Playwright stores artifacts in:

  • crates/web/ui/test-results/ (screenshots, video, traces)
  • crates/web/ui/playwright-report/ (HTML report)

Open a trace with:

cd crates/web/ui
npx playwright show-trace test-results/<test-dir>/trace.zip

Writing Stable Tests

  • Prefer stable IDs/selectors over broad text matching.
  • Assert route + core UI state, avoid over-asserting cosmetic details.
  • Keep smoke tests fast and deterministic.
  • Add focused scenario tests for high-risk features (chat send flow, settings persistence, skills, projects, crons).

CI Integration

The just ui-e2e target is the intended command for CI.

Pull requests use the local-validation flow: the E2E workflow waits for a local/e2e commit status, published by ./scripts/local-validate.sh.

Pushes to main, tags, and manual dispatch still run the hosted E2E job.

LLM Providers

Moltis supports multiple LLM providers through a trait-based architecture. Configure providers through the web UI or directly in configuration files.

Available Providers

API Key Providers

ProviderConfig NameEnv VariableFeatures
AnthropicanthropicANTHROPIC_API_KEYStreaming, tools, vision
OpenAIopenaiOPENAI_API_KEYStreaming, tools, vision, model discovery
Google GeminigeminiGEMINI_API_KEYStreaming, tools, vision, model discovery
DeepSeekdeepseekDEEPSEEK_API_KEYStreaming, tools, model discovery
MistralmistralMISTRAL_API_KEYStreaming, tools, model discovery
GroqgroqGROQ_API_KEYStreaming
xAI (Grok)xaiXAI_API_KEYStreaming
OpenRouteropenrouterOPENROUTER_API_KEYStreaming, tools, model discovery
CerebrascerebrasCEREBRAS_API_KEYStreaming, tools, model discovery
MiniMaxminimaxMINIMAX_API_KEYStreaming, tools
Moonshot (Kimi)moonshotMOONSHOT_API_KEYStreaming, tools, model discovery
VeniceveniceVENICE_API_KEYStreaming, tools, model discovery
Z.AI (Zhipu)zaiZ_API_KEYStreaming, tools, model discovery

OAuth Providers

ProviderConfig NameNotes
OpenAI Codexopenai-codexOAuth flow via web UI
GitHub Copilotgithub-copilotRequires active Copilot subscription

Local

ProviderConfig NameNotes
OllamaollamaLocal or remote Ollama instance
LM StudiolmstudioLocal LM Studio or any OpenAI-compatible server
Local LLMlocal-llmRuns GGUF models directly on your machine

Custom OpenAI-Compatible

Any OpenAI-compatible endpoint can be added with a custom- prefix:

[providers.custom-myservice]
enabled = true
api_key = "..."
base_url = "https://my-service.example.com/v1"
models = ["my-model"]

Configuration

  1. Open Moltis in your browser.
  2. Go to SettingsProviders.
  3. Choose a provider card.
  4. Complete OAuth or enter your API key.
  5. Select your preferred model.

Via Configuration Files

Configure providers in moltis.toml:

[providers]
offered = ["anthropic", "openai", "gemini"]

[providers.anthropic]
enabled = true

[providers.openai]
enabled = true
models = ["gpt-5.3", "gpt-5.2"]
stream_transport = "sse"              # "sse", "websocket", or "auto"

[providers.gemini]
enabled = true
models = ["gemini-2.5-flash-preview-05-20", "gemini-2.0-flash"]
# api_key = "..."                     # Or set GEMINI_API_KEY / GOOGLE_API_KEY env var
# fetch_models = true                 # Discover models from the API
# base_url = "https://generativelanguage.googleapis.com/v1beta/openai"

[chat]
priority_models = ["gpt-5.2"]

Provider Entry Options

Each provider supports these options:

OptionDefaultDescription
enabledtrueEnable or disable the provider
api_keyAPI key (overrides env var)
base_urlOverride API endpoint URL
models[]Preferred models shown first in the picker
fetch_modelstrueDiscover available models from the API
stream_transport"sse""sse", "websocket", or "auto"
aliasCustom label for metrics
tool_mode"auto""auto", "native", "text", or "off"

Provider Setup

Google Gemini

Google Gemini uses an API key from Google AI Studio.

  1. Get an API key from Google AI Studio.
  2. Set GEMINI_API_KEY in your environment (or use GOOGLE_API_KEY).
  3. Gemini models appear automatically in the model picker.
[providers.gemini]
enabled = true
models = ["gemini-2.5-flash-preview-05-20", "gemini-2.0-flash"]

Gemini supports native tool calling, vision/multimodal inputs, streaming, and automatic model discovery.

Anthropic

  1. Get an API key from console.anthropic.com.
  2. Set ANTHROPIC_API_KEY in your environment.

OpenAI

  1. Get an API key from platform.openai.com.
  2. Set OPENAI_API_KEY in your environment.

OpenAI Codex

OpenAI Codex uses OAuth-based access.

  1. Go to SettingsProvidersOpenAI Codex.
  2. Click Connect and complete the auth flow.
  3. Choose a Codex model.

If the browser cannot reach localhost:1455, Moltis now supports a manual fallback in both Settings and Onboarding: paste the callback URL (or code#state) into the OAuth panel and submit it.

Docker and cloud deployments

The OAuth flow redirects your browser to localhost:1455. In Docker, make sure port 1455 is published (-p 1455:1455). On cloud platforms where localhost cannot reach the server, authenticate via the CLI instead:

# Docker
docker exec -it moltis moltis auth login --provider openai-codex

# Fly.io
fly ssh console -C "moltis auth login --provider openai-codex"

The CLI opens a browser on your machine and handles the callback locally. If automatic callback capture fails, the CLI prompts you to paste the callback URL (or code#state) directly in the terminal. Tokens are saved to the config volume and picked up by the gateway automatically.

GitHub Copilot

GitHub Copilot uses OAuth authentication.

  1. Go to SettingsProvidersGitHub Copilot.
  2. Click Connect.
  3. Complete the GitHub OAuth flow.

Docker and cloud deployments

GitHub Copilot uses device-flow authentication (a code you enter on github.com), so it works from the web UI without extra port configuration. If you prefer the CLI:

docker exec -it moltis moltis auth login --provider github-copilot

Info

Requires an active GitHub Copilot subscription.

Ollama

Ollama auto-detects when running at http://127.0.0.1:11434. No API key needed.

[providers.ollama]
enabled = true
# base_url = "http://127.0.0.1:11434/v1"  # Override for remote Ollama

LM Studio

LM Studio auto-detects when running at http://127.0.0.1:1234. No API key needed. Also works with llama.cpp or any OpenAI-compatible local server.

[providers.lmstudio]
enabled = true
# base_url = "http://127.0.0.1:1234/v1"  # Override for different port/host

Local LLM

Local LLM runs GGUF models directly on your machine.

  1. Go to SettingsProvidersLocal LLM.
  2. Choose a model from the local registry or download one.
  3. Save and select it as your active model.

Switching Models

  • Per session: Use the model selector in the chat UI.
  • Per message: Use /model <name> in chat.
  • Global defaults: Use [providers].offered, provider models = [...], and [chat].priority_models in moltis.toml.

Troubleshooting

“Model not available”

  • Check provider auth is still valid.
  • Check model ID spelling.
  • Check account access for that model.

“Rate limited”

  • Retry after a short delay.
  • Switch provider/model.
  • Upgrade provider quota if needed.

“Invalid API key”

  • Verify the key has no extra spaces.
  • Verify it is active and has required permissions.

Why Moltis Doesn’t Support Anthropic OAuth

A common request is browser-based OAuth login for Anthropic, similar to what Moltis offers for OpenAI Codex and GitHub Copilot. This page explains why that isn’t possible and what to do instead.

TL;DR

Anthropic does not offer an OAuth program for third-party tools. Their OAuth flow is locked to Claude Code and Claude.ai only. The only supported way to use Anthropic models in Moltis is with an API key from the Anthropic Console.

Background

Claude Code (Anthropic’s CLI) authenticates via an OAuth 2.0 PKCE flow against console.anthropic.com. The temporary OAuth token is then exchanged for a permanent API key through an internal endpoint (/api/oauth/claude_cli/create_api_key). The path segment /claude_cli/ signals this is a CLI-specific, internal endpoint — not a public API.

The client ID used by Claude Code (9d1c250a-…) is hard-coded for that single application. Anthropic does not provide a way to register new client IDs for third-party projects.

Anthropic’s Policy

In February 2026 Anthropic updated their Legal and Compliance page with an explicit restriction:

OAuth authentication (used with Free, Pro, and Max plans) is intended exclusively for Claude Code and Claude.ai. Using OAuth tokens obtained through Claude Free, Pro, or Max accounts in any other product, tool, or service — including the Agent SDK — is not permitted and constitutes a violation of the Consumer Terms of Service.

This isn’t just a policy statement — Anthropic deployed server-side enforcement in January 2026. OAuth tokens from consumer plans now return errors outside of Claude Code and Claude.ai:

“This credential is only authorized for use with Claude Code and cannot be used for other API requests.”

Several projects that attempted this approach — including Auto-Claude, Goose, and OpenCode — were forced to drop OAuth and switch to standard API keys.

What About Reusing the Claude Code Client ID?

Even if the OAuth flow and key creation technically succeed today, using Claude Code’s client ID from a different application:

  • Violates the Consumer Terms of Service (Section 3.7 — no automated access through bots or scripts except via official API keys).
  • Risks key revocation — Anthropic reserves the right to revoke credentials without prior notice.
  • Could break at any time — the client ID and internal endpoints are not part of a public, stable API surface.

How to Use Anthropic in Moltis

  1. Go to console.anthropic.com and create an account (or sign in).
  2. Navigate to Settings → API Keys and create a new key.
  3. In Moltis, go to Settings → Providers → Anthropic and paste the key.

Alternatively, set the ANTHROPIC_API_KEY environment variable or add it to your moltis.toml:

[providers.anthropic]
enabled = true
api_key = "sk-ant-api03-..."

This is the method Anthropic officially recommends for all third-party integrations.

Will This Change?

If Anthropic introduces a developer OAuth program with client ID registration in the future, Moltis will adopt it. The generic infrastructure for OAuth-to-API-key exchange already exists in the codebase (the api_key_endpoint field on OAuthConfig), so adding support would be straightforward.

References

MCP Servers

Moltis supports the Model Context Protocol (MCP) for connecting to external tool servers. MCP servers extend your agent’s capabilities without modifying Moltis itself.

What is MCP?

MCP is an open protocol that lets AI assistants connect to external tools and data sources. Think of MCP servers as plugins that provide:

  • Tools — Functions the agent can call (e.g., search, file operations, API calls)
  • Resources — Data the agent can read (e.g., files, database records)
  • Prompts — Pre-defined prompt templates

Supported Transports

TransportDescriptionUse Case
stdioLocal process via stdin/stdoutnpm packages, local scripts
Streamable HTTPRemote server via HTTPCloud services, shared servers

Adding an MCP Server

Via Web UI

  1. Go to SettingsMCP Servers
  2. Click Add Server
  3. For remote Streamable HTTP servers, enter the server URL and any optional request headers
  4. Click Save

After saving a remote server, Moltis only shows a sanitized URL plus header names/count in the UI and status views. Stored header values stay hidden.

Via Configuration

Add servers to moltis.toml:

[mcp]
request_timeout_secs = 30

[mcp.servers.filesystem]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/projects"]

[mcp.servers.github]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
env = { GITHUB_TOKEN = "ghp_..." }
request_timeout_secs = 90

[mcp.servers.remote_api]
transport = "sse"
url = "https://mcp.example.com/mcp?api_key=$REMOTE_MCP_KEY"
headers = { Authorization = "Bearer ${REMOTE_MCP_TOKEN}" }

Remote URLs and headers support $NAME and ${NAME} placeholders. For live remote servers, placeholder values resolve from Moltis-managed env overrides, either [env] in config or SettingsEnvironment Variables.

Official Servers

ServerDescriptionInstall
filesystemRead/write local filesnpx @modelcontextprotocol/server-filesystem
githubGitHub API accessnpx @modelcontextprotocol/server-github
postgresPostgreSQL queriesnpx @modelcontextprotocol/server-postgres
sqliteSQLite databasenpx @modelcontextprotocol/server-sqlite
puppeteerBrowser automationnpx @modelcontextprotocol/server-puppeteer
brave-searchWeb searchnpx @modelcontextprotocol/server-brave-search

Community Servers

Explore more at mcp.so and GitHub MCP Servers.

Configuration Options

[mcp]
request_timeout_secs = 30       # Global default timeout for MCP requests

[mcp.servers.my_server]
command = "node"                # Required for stdio transport
args = ["server.js"]            # Optional arguments

# Optional environment variables
env = { API_KEY = "secret", DEBUG = "true" }

# Optional: per-server timeout override
request_timeout_secs = 90

# Optional: remote transport
transport = "sse"               # "stdio" (default) or "sse"
url = "https://mcp.example.com/mcp"  # Required when transport = "sse"
headers = { "x-api-key" = "$REMOTE_MCP_KEY" }  # Optional request headers

Request Timeouts

Moltis applies MCP request timeouts in two layers:

  • mcp.request_timeout_secs sets the global default for every MCP server
  • mcp.servers.<name>.request_timeout_secs optionally overrides that default for a specific server

This is useful when most local MCP servers respond quickly, but one remote SSE server or one expensive tool server needs a longer timeout.

[mcp]
request_timeout_secs = 30

[mcp.servers.remote_api]
transport = "sse"
url = "https://mcp.example.com/mcp"
request_timeout_secs = 120

In the web UI, the MCP settings page lets you edit both the global default timeout and the optional timeout override for each configured server.

Remote SSE Secrets and Placeholders

Remote MCP servers often expect API keys or bearer tokens in the URL query string or request headers. Moltis supports both patterns.

[mcp.servers.linear_remote]
transport = "sse"
url = "https://mcp.example.com/mcp?api_key=$REMOTE_MCP_KEY"
headers = {
  Authorization = "Bearer ${REMOTE_MCP_TOKEN}",
  "x-workspace" = "team-a",
}
  • Use $NAME or ${NAME} placeholders in remote url and headers
  • Placeholder values resolve from Moltis-managed env overrides, either [env] in config or SettingsEnvironment Variables
  • UI and API status payloads only expose sanitized URLs plus header names/count, not raw header values
  • Query-string secrets are redacted when Moltis displays a remote URL after save

Server Lifecycle

┌─────────────────────────────────────────────────────┐
│                   MCP Server                         │
│                                                      │
│  Start → Initialize → Ready → [Tool Calls] → Stop   │
│            │                       │                 │
│            ▼                       ▼                 │
│     Health Check ◄─────────── Heartbeat             │
│            │                       │                 │
│            ▼                       ▼                 │
│    Crash Detected ───────────► Restart              │
│                                    │                 │
│                              Backoff Wait            │
└─────────────────────────────────────────────────────┘

Health Monitoring

Moltis monitors MCP servers and automatically:

  • Detects crashes via process exit
  • Restarts with exponential backoff
  • Disables after max restart attempts
  • Re-enables after cooldown period

Using MCP Tools

Once connected, MCP tools appear alongside built-in tools. The agent can use them naturally:

User: Search GitHub for Rust async runtime projects

Agent: I'll search GitHub for you.
[Calling github.search_repositories with query="rust async runtime"]

Found 15 repositories:
1. tokio-rs/tokio - A runtime for writing reliable async applications
2. async-std/async-std - Async version of the Rust standard library
...

Creating an MCP Server

Simple Node.js Server

// server.js
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

const server = new Server(
  { name: "my-server", version: "1.0.0" },
  { capabilities: { tools: {} } }
);

server.setRequestHandler("tools/list", async () => ({
  tools: [{
    name: "hello",
    description: "Says hello",
    inputSchema: {
      type: "object",
      properties: {
        name: { type: "string", description: "Name to greet" }
      },
      required: ["name"]
    }
  }]
}));

server.setRequestHandler("tools/call", async (request) => {
  if (request.params.name === "hello") {
    const name = request.params.arguments.name;
    return { content: [{ type: "text", text: `Hello, ${name}!` }] };
  }
});

const transport = new StdioServerTransport();
await server.connect(transport);

Configure in Moltis

[mcp.servers.my_server]
command = "node"
args = ["server.js"]

Debugging

Check Server Status

In the web UI, go to SettingsMCP Servers to see:

  • Connection status (connected/disconnected/error)
  • Available tools
  • Sanitized remote URL and configured header names
  • Recent errors

View Logs

MCP server stderr is captured in Moltis logs:

# View gateway logs
tail -f ~/.moltis/logs.jsonl | grep -i mcp

Test Locally

Run the server directly to debug:

echo '{"jsonrpc":"2.0","method":"tools/list","id":1}' | node server.js

OAuth Authentication

Remote MCP servers can require OAuth 2.1 authentication. Moltis handles this automatically — when a server returns 401 Unauthorized, the OAuth flow starts without any manual configuration.

How It Works

  1. Moltis connects to the remote MCP server
  2. The server returns 401 Unauthorized with a WWW-Authenticate header
  3. Moltis discovers the authorization server via RFC 9728 (Protected Resource Metadata)
  4. Moltis performs dynamic client registration (RFC 7591)
  5. A PKCE authorization code flow opens your browser for login
  6. After login, tokens are stored and used for all subsequent requests

Client registrations and tokens are cached locally, so you only need to log in once per server.

Manual OAuth Configuration

If a server doesn’t support standard OAuth discovery, you can configure credentials manually:

[mcp.servers.private_api]
url = "https://mcp.example.com/mcp"
transport = "sse"

[mcp.servers.private_api.oauth]
client_id = "your-client-id"
auth_url = "https://auth.example.com/authorize"
token_url = "https://auth.example.com/token"
scopes = ["mcp:read", "mcp:write"]

Re-authentication

If your session expires or tokens are revoked, Moltis automatically re-authenticates on the next 401 response. You can also trigger re-authentication manually via the mcp.reauth RPC method.

Security Considerations

Warning

MCP servers run with the same permissions as Moltis. Only use servers from trusted sources.

  • Review server code before running
  • Limit file access — use specific paths, not /
  • Use environment variables for secrets
  • Prefer placeholders in remote URLs and headers ($NAME / ${NAME}) instead of hardcoding secrets repeatedly
  • Network isolation — run untrusted servers in containers

Troubleshooting

Server won’t start

  • Check the command exists: which npx
  • Verify the package: npx @modelcontextprotocol/server-filesystem --help
  • Check for port conflicts

Tools not appearing

  • Server may still be initializing (wait a few seconds)
  • Check server logs for errors
  • Verify the server implements tools/list

Server keeps restarting

  • Check stderr for crash messages
  • Increase max_restart_attempts for debugging
  • Verify environment variables are set correctly

Memory System

Moltis provides a powerful memory system that enables the agent to recall past conversations, notes, and context across sessions. This document explains the available backends, features, and configuration options.

Backends

Moltis supports two memory backends:

FeatureBuilt-inQMD
Search TypeHybrid (vector + FTS5 keyword)Hybrid (BM25 + vector + LLM reranking)
Local EmbeddingsGGUF models via llama-cpp-2GGUF models
Remote EmbeddingsOpenAI, Ollama, custom endpointsBuilt-in
Embedding CacheSQLite with LRU evictionBuilt-in
Batch APIOpenAI batch (50% cost saving)No
Circuit BreakerFallback chain with auto-recoveryNo
LLM RerankingOptional (configurable)Built-in with query command
File WatchingReal-time sync via notifyBuilt-in
External DependencyNone (pure Rust)Requires QMD binary (Node.js/Bun)
Offline SupportYes (with local embeddings)Yes

Built-in Backend

The default backend uses SQLite for storage with FTS5 for keyword search and optional vector embeddings for semantic search. Key advantages:

  • Zero external dependencies: Everything is embedded in the moltis binary
  • Fallback chain: Automatically switches between embedding providers if one fails
  • Batch embedding: Reduces OpenAI API costs by 50% for large sync operations
  • Embedding cache: Avoids re-embedding unchanged content

QMD Backend

QMD is an optional external sidecar that provides enhanced search capabilities:

  • BM25 keyword search: Fast, instant results (similar to Elasticsearch)
  • Vector search: Semantic similarity using local GGUF models
  • Hybrid search with LLM reranking: Combines both methods with an LLM pass for optimal relevance

To use QMD:

  1. Install the QMD CLI from github.com/tobi/qmd: npm install -g --ignore-scripts @tobilu/qmd or bun add -g @tobilu/qmd
  2. Verify the binary is on your PATH: qmd --version
  3. Enable it in Settings > Memory > Backend

Moltis invokes the qmd CLI directly for indexing and search, so the memory backend does not require a separate background daemon.

Features

Citations

Citations append source file and line number information to search results:

Some important content from your notes.

Source: memory/notes.md#42

Configuration options:

  • auto (default): Include citations when results come from multiple files
  • on: Always include citations
  • off: Never include citations

Session Export

When enabled, session transcripts are automatically exported to the memory system for cross-run recall. This allows the agent to remember past conversations even after restarts.

Exported sessions are:

  • Stored in memory/sessions/ as markdown files
  • Sanitized to remove sensitive tool results and system messages
  • Automatically cleaned up based on age/count limits

LLM Reranking

LLM reranking uses the configured language model to re-score and reorder search results based on semantic relevance to the query. This provides better results than keyword or vector matching alone, at the cost of additional latency.

How it works:

  1. Initial search returns candidate results
  2. LLM evaluates each result’s relevance (0.0-1.0 score)
  3. Results are reordered by combined score (70% LLM, 30% original)

Configuration

Memory settings can be configured in moltis.toml:

[memory]
# Backend: "builtin" (default) or "qmd"
backend = "builtin"

# Embedding provider: "local", "ollama", "openai", "custom", or auto-detect
provider = "local"

# Disable RAG embeddings and force keyword-only search
disable_rag = false

# Embedding API base URL (host, /v1, or full /embeddings endpoint)
base_url = "http://localhost:11434/v1"

# Citation mode: "on", "off", or "auto"
citations = "auto"

# Enable LLM reranking for hybrid search
llm_reranking = false

# Export sessions to memory for cross-run recall
session_export = true

# QMD-specific settings (only used when backend = "qmd")
[memory.qmd]
command = "qmd"
max_results = 10
timeout_ms = 30000

Or via the web UI: Settings > Memory

Embedding Providers

The built-in backend supports multiple embedding providers:

ProviderModelDimensionsNotes
Local (GGUF)EmbeddingGemma-300M768Offline, ~300MB download
Ollamanomic-embed-text768Requires Ollama running
OpenAItext-embedding-3-small1536Requires API key
CustomConfigurableVariesOpenAI-compatible endpoint

The system auto-detects available providers and creates a fallback chain:

  1. Try configured provider first
  2. Fall back to other available providers if it fails
  3. Use keyword-only search if no embedding provider is available

Memory Directories

By default, moltis indexes markdown files from:

  • ~/.moltis/MEMORY.md - Main long-term memory file
  • ~/.moltis/memory/*.md - Additional memory files
  • ~/.moltis/memory/sessions/*.md - Exported session transcripts

Tools

The memory system exposes three agent tools:

Search memory with a natural language query. Returns relevant chunks ranked by hybrid (vector + keyword) similarity.

{
  "query": "what did we discuss about the API design?",
  "limit": 5
}

memory_get

Retrieve a specific memory chunk by ID. Useful for reading the full text of a result found via memory_search.

{
  "chunk_id": "memory/notes.md:42"
}

memory_save

Save content to long-term memory files. The agent uses this tool when you ask it to remember something (“remember that I prefer dark mode”) or when it decides certain information is worth persisting.

{
  "content": "User prefers dark mode and Vim keybindings.",
  "file": "MEMORY.md",
  "append": true
}

Parameters:

ParameterTypeDefaultDescription
contentstring(required)The content to save
filestringMEMORY.mdTarget file: MEMORY.md, memory.md, or memory/<name>.md
appendbooleantrueAppend to existing file (true) or overwrite (false)

Path validation: The tool enforces a strict allowlist of write targets to prevent path traversal attacks. Only these patterns are accepted:

  • MEMORY.md or memory.md (root memory files)
  • memory/<name>.md (files in the memory subdirectory, one level deep)

Absolute paths, .. traversal, non-.md extensions, spaces in filenames, and nested subdirectories (memory/a/b.md) are all rejected. Content is limited to 50 KB per write.

Auto-reindex: After writing, the memory system automatically re-indexes the affected file so the new content is immediately searchable via memory_search.

Silent Memory Turn (Pre-Compaction Flush)

Before compacting a session (summarizing old messages to free context window space), Moltis runs a silent agentic turn that reviews the conversation and saves important information to memory files. This ensures durable memories survive compaction.

How it works:

  1. When a session approaches the model’s context window limit, the gateway triggers compaction
  2. Before summarizing, a hidden LLM turn runs with a special system prompt asking the agent to save noteworthy information
  3. The agent writes to MEMORY.md and/or memory/YYYY-MM-DD.md using an internal write_file tool backed by the same MemoryWriter as memory_save
  4. The LLM’s response text is discarded (the user sees nothing)
  5. Written files are automatically re-indexed for future search

What gets saved:

  • User preferences and working style
  • Key decisions and their reasoning
  • Project context, architecture choices, and conventions
  • Important facts, names, dates, and relationships
  • Technical setup details (tools, languages, frameworks)

This is the same approach used by OpenClaw. See the comparison page for a detailed analysis of both systems.

Architecture

┌──────────────────────────────────────────────────────────────────┐
│                       Memory Manager                             │
│               (implements MemoryWriter trait)                     │
├──────────────────────────────────────────────────────────────────┤
│                         Read Path                                │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐      │
│  │   Chunker   │  │   Search    │  │  Session Export     │      │
│  │ (markdown)  │  │  (hybrid)   │  │  (transcripts)      │      │
│  └─────────────┘  └─────────────┘  └─────────────────────┘      │
├──────────────────────────────────────────────────────────────────┤
│                        Write Path                                │
│  ┌─────────────────┐  ┌──────────────────┐  ┌────────────────┐  │
│  │  memory_save    │  │  Silent Turn     │  │  Path          │  │
│  │  (agent tool)   │  │  (pre-compact)   │  │  Validation    │  │
│  └─────────────────┘  └──────────────────┘  └────────────────┘  │
├──────────────────────────────────────────────────────────────────┤
│                      Storage Backend                             │
│  ┌────────────────────────┐  ┌────────────────────────┐         │
│  │   Built-in (SQLite)    │  │   QMD (sidecar)        │         │
│  │  - FTS5 keyword        │  │  - BM25 keyword        │         │
│  │  - Vector similarity   │  │  - Vector similarity   │         │
│  │  - Embedding cache     │  │  - LLM reranking       │         │
│  └────────────────────────┘  └────────────────────────┘         │
├──────────────────────────────────────────────────────────────────┤
│                    Embedding Providers                            │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐  ┌───────────────┐      │
│  │  Local  │  │ Ollama  │  │ OpenAI  │  │ Batch/Fallback│      │
│  │  (GGUF) │  │         │  │         │  │               │      │
│  └─────────┘  └─────────┘  └─────────┘  └───────────────┘      │
└──────────────────────────────────────────────────────────────────┘

Troubleshooting

Memory not working

  1. Check status in Settings > Memory
  2. Ensure at least one embedding provider is available:
    • Local: Requires local-embeddings feature enabled at build
    • Ollama: Must be running at localhost:11434
    • OpenAI: Requires OPENAI_API_KEY environment variable

Search returns no results

  1. Check that memory files exist in the expected directories
  2. Trigger a manual sync by restarting moltis
  3. Check logs for sync errors

QMD not available

  1. Install QMD if needed: npm install -g --ignore-scripts @tobilu/qmd or bun add -g @tobilu/qmd
  2. Verify QMD is installed: qmd --version
  3. Check that the path is correct in settings
  4. Ensure QMD has indexed your collections: qmd stats

Memory: Moltis vs OpenClaw

This page provides a detailed comparison of the memory systems in Moltis and OpenClaw. Both projects share the same core architecture for long-term memory, but differ in implementation details, tool surface, and configuration.

Overview

Both systems follow the same fundamental approach: plain Markdown files are the source of truth for agent memory, indexed for semantic search using hybrid vector + keyword retrieval. The agent reads from memory via search tools and writes to memory via file-writing tools (either dedicated or general-purpose).

Feature Comparison

Storage and Indexing

FeatureMoltisOpenClaw
Storage formatMarkdown files on diskMarkdown files on disk
Index storageSQLite (per data dir)SQLite (per agent)
Default backendBuilt-in (SQLite + FTS5 + vector)Built-in (SQLite + BM25 + vector)
Alternative backendQMD (sidecar, BM25 + vector + reranking)QMD (sidecar, BM25 + vector + reranking)
Keyword searchFTS5BM25
Vector searchCosine similarityCosine similarity
Hybrid scoringConfigurable vector/keyword weightsConfigurable vector/text weights
ChunkingMarkdown-aware (~400 tokens, configurable)Markdown-aware (~400 tokens, 80-token overlap)
Embedding cacheSQLite with LRU evictionSQLite, chunk-level
File watchingReal-time sync via notifyFile watcher with 1.5s debounce
Auto-reindex on provider changeNo (manual)Yes (fingerprint-based)

Embedding Providers

ProviderMoltisOpenClaw
Local GGUFEmbeddingGemma-300M via llama-cpp-2Auto-download GGUF (~0.6 GB)
Ollamanomic-embed-textNot listed
OpenAItext-embedding-3-smallVia API key
GeminiNot availableVia API key
VoyageNot availableVia API key
Custom endpointOpenAI-compatibleNot listed
Batch embeddingOpenAI batch API (50% cost saving)OpenAI, Gemini, Voyage batch
Fallback chainAuto-detect + circuit breakerAuto-select in priority order
Offline supportYes (local embeddings)Yes (local embeddings)

Memory Files

AspectMoltisOpenClaw
Data directory~/.moltis/ (configurable)~/.openclaw/workspace/
Long-term memoryMEMORY.mdMEMORY.md
Daily logsmemory/YYYY-MM-DD.mdmemory/YYYY-MM-DD.md
Session transcriptsmemory/sessions/*.mdSession JSONL files (separate)
Extra pathsVia memory_dirs configVia memorySearch.extraPaths
MEMORY.md loadingAlways available in system promptOnly in private sessions (not group chats)

Agent Tools

This is where the two systems differ most significantly in approach.

ToolMoltisOpenClaw
memory_searchDedicated tool, hybrid searchDedicated tool, hybrid search
memory_getDedicated tool, by chunk IDDedicated tool, by path + optional line range
memory_saveDedicated tool with path validationNo dedicated tool
General file writingexec tool (shell commands)Generic write_file tool
Silent memory turnPre-compaction flush via MemoryWriterPre-compaction flush via write_file

How “Remember X” Works

When a user says “remember that I prefer dark mode”, here is how each system handles it:

Moltis: The agent calls the memory_save tool directly:

{
  "content": "User prefers dark mode.",
  "file": "MEMORY.md",
  "append": true
}

The memory_save tool validates the path, writes the file, and re-indexes it so the content is immediately searchable. The agent does not need shell access or a generic file-writing tool.

OpenClaw: The agent calls the generic write_file tool (which is also used for writing code, configs, and any other file):

{
  "path": "MEMORY.md",
  "content": "User prefers dark mode.",
  "append": true
}

The system prompt instructs the agent which paths are for memory. The tool itself has no special memory awareness – it is a general-purpose file writer. The memory indexer’s file watcher detects the change and re-indexes asynchronously (1.5s debounce).

Key difference: Moltis uses a purpose-built memory_save tool with built-in path validation (only MEMORY.md and memory/*.md are writable) and immediate re-indexing. OpenClaw uses a general-purpose write_file tool that can write anywhere, relying on the system prompt to guide the agent to memory paths and the file watcher to re-index.

Session Memory and Compaction

FeatureMoltisOpenClaw
Session storageSQLite databaseJSONL files (append-only, tree structure)
Auto-compactionYes, near context window limitYes, near context window limit
Manual compactionNot yet/compact command with optional instructions
Pre-compaction memory flushSilent turn via MemoryWriter traitSilent turn via write_file tool
Flush visibilityCompletely hidden from userHidden via NO_REPLY convention
Session export to memoryMarkdown files in memory/sessions/Optional (sessionMemory experimental flag)
Session pruningNot yetCache-TTL based, trims old tool results
Session transcript indexingVia session exportExperimental, async delta-based

Pre-Compaction Memory Flush: Detailed Comparison

Both systems run a hidden LLM turn before compaction to persist important context. The implementation differs:

Moltis:

  • The gateway detects that compaction is needed
  • A run_silent_memory_turn() call creates a temporary agent loop with a write_file tool backed by MemoryWriter
  • The MemoryWriter trait is implemented by MemoryManager, which validates paths and re-indexes after writing
  • The LLM’s response text is discarded
  • Written file paths are returned to the caller for logging

OpenClaw:

  • A soft threshold (default 4000 tokens below compaction trigger) activates the flush
  • The flush executes as a regular turn with NO_REPLY prefix to suppress user-facing output
  • The agent writes memory files via the same write_file tool used during normal conversation
  • Flush state is tracked in sessions.json (memoryFlushAt, memoryFlushCompactionCount) to run once per compaction cycle
  • Skipped for read-only workspaces

Write Path Security

AspectMoltisOpenClaw
Path validationStrict allowlist (MEMORY.md, memory.md, memory/*.md)No special memory path restrictions
Traversal preventionRejects .., absolute paths, non-.md extensionsRelies on workspace sandboxing
Size limit50 KB per writeNo documented limit
Write scopeOnly memory filesAny file in workspace
Mechanismvalidate_memory_path() in MemoryWriterWorkspace access mode (rw/ro/none)

Search Features

FeatureMoltisOpenClaw
LLM rerankingOptional (configurable)Built-in with QMD
CitationsConfigurable (auto/on/off)Configurable (auto/on/off)
Result formatChunk ID, path, source, line range, score, textPath, line range, score, snippet (~700 chars)
FallbackKeyword-only if no embeddingsBM25-only if no embeddings

Configuration

SettingMoltis (moltis.toml)OpenClaw (openclaw.json)
Backendmemory.backend = "builtin"memory.backend = "builtin"
Providermemory.provider = "local"Auto-detect from available keys
Citationsmemory.citations = "auto"memory.citations = "auto"
LLM rerankingmemory.llm_reranking = falseVia QMD config
Session exportmemory.session_export = truememorySearch.experimental.sessionMemory
UI configurationSettings > Memory pageConfig file only
QMD settings[memory.qmd] sectionmemory.backend = "qmd"

CLI Commands

CommandMoltisOpenClaw
StatusSettings > Memory (web UI)openclaw memory status [--deep]
Index/reindexAutomatic on startupopenclaw memory index [--verbose]
SearchVia agent tool onlyopenclaw memory search "query"
Per-agent scopingSingle agent--agent <id> flag

Architecture

AspectMoltisOpenClaw
LanguageRustTypeScript/Node.js
Memory crate/modulemoltis-memory cratememory-core plugin
Write abstractionMemoryWriter trait (shared by tools and silent turn)Direct file I/O via write_file tool
Plugin systemMemory is a core crateMemory is a swappable plugin slot
Multi-agentSingle agentPer-agent memory isolation

What Moltis Has That OpenClaw Does Not

  • Dedicated memory_save tool with path validation and immediate re-indexing, reducing reliance on the system prompt for write guidance
  • Ollama embedding support as a provider option
  • Custom OpenAI-compatible embedding endpoints
  • Circuit breaker with automatic fallback chain for embedding providers
  • Web UI for memory configuration (Settings > Memory page)
  • Pure Rust implementation with zero external runtime dependencies

What OpenClaw Has That Moltis Does Not (Yet)

  • Manual /compact command with user-specified instructions
  • CLI memory commands (status, index, search) for debugging
  • Session pruning (cache-TTL based trimming of old tool results)
  • Gemini and Voyage embedding providers
  • Per-agent memory isolation for multi-agent setups
  • Automatic re-indexing on embedding provider/model change (fingerprint detection)
  • Memory plugin slot allowing third-party memory implementations
  • Flush-once-per-compaction tracking to avoid redundant silent turns
  • Configurable flush threshold (soft threshold tokens before compaction)

Summary

The two systems are architecturally equivalent – both use Markdown files, hybrid search, and pre-compaction memory flushes. The main differences are:

  1. Tool approach: Moltis provides a purpose-built memory_save tool with security validation; OpenClaw uses a general-purpose write_file tool guided by the system prompt.

  2. Write safety: Moltis validates write paths at the tool level (allowlist

    • traversal checks); OpenClaw relies on workspace-level access control.
  3. Implementation: Moltis is pure Rust with a MemoryWriter trait abstraction; OpenClaw is TypeScript with direct file I/O through a plugin system.

  4. Maturity: OpenClaw has more CLI tooling and configuration knobs for advanced memory management; Moltis has a simpler, more opinionated setup with a web UI.

Hooks

Hooks let you observe, modify, or block actions at key points in the agent lifecycle. Use them for auditing, policy enforcement, prompt injection filtering, notifications, and custom integrations.

How Hooks Work

┌──────────────────────────────────────────────────────────────┐
│                        Agent Loop                            │
│                                                              │
│  User Message ─→ BeforeLLMCall ─→ LLM Provider              │
│                       │                 │                    │
│                  modify/block      AfterLLMCall              │
│                                         │                    │
│                                    modify/block              │
│                                         │                    │
│                                         ▼                    │
│                                  BeforeToolCall              │
│                                         │                    │
│                                    modify/block              │
│                                         │                    │
│                                    Tool Execution            │
│                                         │                    │
│                                    AfterToolCall             │
│                                         │                    │
│                                         ▼                    │
│                              (loop continues or)             │
│                             Response → MessageSent           │
└──────────────────────────────────────────────────────────────┘

Event Types

Modifying Events (Sequential)

These events run hooks sequentially. Hooks can modify the payload or block the action.

EventDescriptionCan ModifyCan Block
BeforeAgentStartBefore agent loop startsyesyes
BeforeLLMCallBefore prompt is sent to the LLM provideryesyes
AfterLLMCallAfter LLM response, before tool executionyesyes
BeforeToolCallBefore a tool executesyesyes
BeforeCompactionBefore context compactionyesyes
MessageSendingBefore sending a responseyesyes
ToolResultPersistWhen a tool result is persistedyesyes

Read-Only Events (Parallel)

These events run hooks in parallel for performance. They cannot modify or block.

EventDescription
AfterToolCallAfter a tool completes
AfterCompactionAfter context is compacted
AgentEndWhen agent loop completes
MessageReceivedWhen a user message arrives
MessageSentAfter response is delivered
SessionStartWhen a new session begins
SessionEndWhen a session ends
GatewayStartWhen Moltis starts
GatewayStopWhen Moltis shuts down
CommandWhen a slash command is used

Prompt Injection Filtering

The BeforeLLMCall and AfterLLMCall hooks provide filtering points for prompt injection defense.

BeforeLLMCall

Fires before each LLM API call. The payload includes the full message array, provider name, model ID, and iteration count. Use it to:

  • Scan prompts for injection patterns before they reach the LLM
  • Redact PII or sensitive data from the conversation
  • Add safety prefixes to system prompts
  • Block requests that match known attack patterns

Payload fields:

FieldTypeDescription
session_keystringSession identifier
providerstringProvider name (e.g. “openai”, “anthropic”)
modelstringModel ID (e.g. “gpt-5.2-codex”, “qwen2.5-coder-7b-q4_k_m”)
messagesarraySerialized message array (OpenAI format)
tool_countnumberNumber of tool schemas sent to the LLM
iterationnumber1-based loop iteration

AfterLLMCall

Fires after the LLM response is received but before tool calls execute. For streaming responses, this fires after the full response is accumulated (text has already been streamed to the UI) but blocking still prevents tool execution.

Payload fields:

FieldTypeDescription
session_keystringSession identifier
providerstringProvider name
modelstringModel ID
textstring/nullLLM response text
tool_callsarrayTool calls requested by the LLM
input_tokensnumberTokens consumed by the prompt
output_tokensnumberTokens in the response
iterationnumber1-based loop iteration

Example: Block Suspicious Tool Calls

#!/bin/bash
# filter-injection.sh — subscribe to AfterLLMCall
payload=$(cat)
event=$(echo "$payload" | jq -r '.event')

if [ "$event" = "AfterLLMCall" ]; then
    # Check if tool calls contain suspicious patterns
    tool_names=$(echo "$payload" | jq -r '.tool_calls[].name')

    for name in $tool_names; do
        # Block unexpected tool calls that might come from injection
        case "$name" in
            exec|bash|shell)
                text=$(echo "$payload" | jq -r '.text // ""')
                if echo "$text" | grep -qi "ignore previous\|disregard\|new instructions"; then
                    echo "Blocked suspicious tool call after potential injection" >&2
                    exit 1
                fi
                ;;
        esac
    done
fi

exit 0

Example: External Proxy Filter

#!/bin/bash
# proxy-filter.sh — subscribe to BeforeLLMCall
payload=$(cat)

# Send to an external moderation API
result=$(echo "$payload" | curl -s -X POST \
  -H "Content-Type: application/json" \
  -d @- \
  "$MODERATION_API_URL/check")

# Block if the API flags it
if echo "$result" | jq -e '.flagged' > /dev/null 2>&1; then
    reason=$(echo "$result" | jq -r '.reason // "content policy violation"')
    echo "$reason" >&2
    exit 1
fi

exit 0

Creating a Hook

1. Create the Hook Directory

mkdir -p ~/.moltis/hooks/my-hook

2. Create HOOK.md

+++
name = "my-hook"
description = "Logs all tool calls to a file"
events = ["BeforeToolCall", "AfterToolCall"]
command = "./handler.sh"
timeout = 5

[requires]
os = ["darwin", "linux"]
bins = ["jq"]
env = ["LOG_FILE"]
+++

# My Hook

This hook logs all tool calls for auditing purposes.

3. Create the Handler Script

#!/bin/bash
# handler.sh

# Read event payload from stdin
payload=$(cat)

# Extract event type
event=$(echo "$payload" | jq -r '.event')

# Log to file
echo "$(date -Iseconds) $event: $payload" >> "$LOG_FILE"

# Exit 0 to continue (don't block)
exit 0

4. Make it Executable

chmod +x ~/.moltis/hooks/my-hook/handler.sh

Shell Hook Protocol

Hooks communicate via stdin/stdout and exit codes:

Input

The event payload is passed as JSON on stdin:

{
  "event": "BeforeToolCall",
  "data": {
    "tool": "bash",
    "arguments": {
      "command": "ls -la"
    }
  },
  "session_id": "abc123",
  "timestamp": "2024-01-15T10:30:00Z"
}

Output

Exit CodeStdoutResult
0(empty)Continue normally
0{"action":"modify","data":{...}}Replace payload data
1Block (stderr = reason)

Example: Modify Tool Arguments

#!/bin/bash
payload=$(cat)
tool=$(echo "$payload" | jq -r '.data.tool')

if [ "$tool" = "bash" ]; then
    # Add safety flag to all bash commands
    modified=$(echo "$payload" | jq '.data.arguments.command = "set -e; " + .data.arguments.command')
    echo "{\"action\":\"modify\",\"data\":$(echo "$modified" | jq '.data')}"
fi

exit 0

Example: Block Dangerous Commands

#!/bin/bash
payload=$(cat)
command=$(echo "$payload" | jq -r '.data.arguments.command // ""')

# Block rm -rf /
if echo "$command" | grep -qE 'rm\s+-rf\s+/'; then
    echo "Blocked dangerous rm command" >&2
    exit 1
fi

exit 0

Hook Discovery

Hooks are discovered from HOOK.md files in these locations (priority order):

  1. Project-local: <workspace>/.moltis/hooks/<name>/HOOK.md
  2. User-global: ~/.moltis/hooks/<name>/HOOK.md

Project-local hooks take precedence over global hooks with the same name.

Configuration in moltis.toml

You can also define hooks directly in the config file:

[hooks]
[[hooks.hooks]]
name = "audit-log"
command = "./hooks/audit.sh"
events = ["BeforeToolCall", "AfterToolCall"]
timeout = 5

[[hooks.hooks]]
name = "llm-filter"
command = "./hooks/filter-injection.sh"
events = ["BeforeLLMCall", "AfterLLMCall"]
timeout = 10

[[hooks.hooks]]
name = "notify-slack"
command = "./hooks/slack-notify.sh"
events = ["SessionEnd"]
env = { SLACK_WEBHOOK_URL = "https://hooks.slack.com/..." }

Eligibility Requirements

Hooks can declare requirements that must be met:

[requires]
os = ["darwin", "linux"]       # Only run on these OSes
bins = ["jq", "curl"]          # Required binaries in PATH
env = ["SLACK_WEBHOOK_URL"]    # Required environment variables

If requirements aren’t met, the hook is skipped (not an error).

Circuit Breaker

Hooks that fail repeatedly are automatically disabled:

  • Threshold: 5 consecutive failures
  • Cooldown: 60 seconds
  • Recovery: Auto-re-enabled after cooldown

This prevents a broken hook from blocking all operations.

CLI Commands

# List all discovered hooks
moltis hooks list

# List only eligible hooks (requirements met)
moltis hooks list --eligible

# Output as JSON
moltis hooks list --json

# Show details for a specific hook
moltis hooks info my-hook

Bundled Hooks

Moltis includes several built-in hooks:

boot-md

Reads BOOT.md from the workspace on GatewayStart and injects it into the agent context.

BOOT.md is intended for short, explicit startup tasks (health checks, reminders, “send one startup message”, etc.). If the file is missing or empty, nothing is injected.

Workspace Context Files

Moltis supports several workspace markdown files in data_dir.

TOOLS.md

TOOLS.md is loaded as a workspace context file in the system prompt.

Best use is to combine:

  • Local notes: environment-specific facts (hosts, device names, channel aliases)
  • Policy constraints: “prefer read-only tools first”, “never run X on startup”, etc.

If TOOLS.md is empty or missing, it is not injected.

AGENTS.md (workspace)

Moltis also supports a workspace-level AGENTS.md in data_dir.

This is separate from project AGENTS.md/CLAUDE.md discovery. Use workspace AGENTS.md for global instructions that should apply across projects in this workspace.

session-memory

Saves session context when you use the /new command, preserving important information for future sessions.

command-logger

Logs all Command events to a JSONL file for auditing.

Example Hooks

dcg is an external tool that scans shell commands against 49+ destructive pattern categories, including heredoc/inline-script scanning, database, cloud, and infrastructure patterns.

Install:

cargo install dcg

Hook setup:

Copy the bundled hook example to your hooks directory:

cp -r examples/hooks/dcg-guard ~/.moltis/hooks/dcg-guard
chmod +x ~/.moltis/hooks/dcg-guard/handler.sh

The hook subscribes to BeforeToolCall, extracts exec commands, pipes them through dcg, and blocks any command that dcg flags as destructive. See examples/hooks/dcg-guard/HOOK.md for details.

Note: dcg complements but does not replace the built-in dangerous command blocklist, sandbox isolation, or the approval system. Use it as an additional defense layer with broader pattern coverage.

Slack Notification on Session End

#!/bin/bash
# slack-notify.sh
payload=$(cat)
session_id=$(echo "$payload" | jq -r '.session_id')
message_count=$(echo "$payload" | jq -r '.data.message_count')

curl -X POST "$SLACK_WEBHOOK_URL" \
  -H 'Content-Type: application/json' \
  -d "{\"text\":\"Session $session_id ended with $message_count messages\"}"

exit 0

Redact Secrets from Tool Output

#!/bin/bash
# redact-secrets.sh
payload=$(cat)

# Redact common secret patterns
redacted=$(echo "$payload" | sed -E '
  s/sk-[a-zA-Z0-9]{32,}/[REDACTED]/g
  s/ghp_[a-zA-Z0-9]{36}/[REDACTED]/g
  s/password=[^&\s]+/password=[REDACTED]/g
')

echo "{\"action\":\"modify\",\"data\":$(echo "$redacted" | jq '.data')}"
exit 0

Block File Writes Outside Project

#!/bin/bash
# sandbox-writes.sh
payload=$(cat)
tool=$(echo "$payload" | jq -r '.data.tool')

if [ "$tool" = "write_file" ]; then
    path=$(echo "$payload" | jq -r '.data.arguments.path')

    # Only allow writes under current project
    if [[ ! "$path" =~ ^/workspace/ ]]; then
        echo "File writes only allowed in /workspace" >&2
        exit 1
    fi
fi

exit 0

Best Practices

  1. Keep hooks fast — Set appropriate timeouts (default: 5s)
  2. Handle errors gracefully — Use exit 0 unless you want to block
  3. Log for debugging — Write to a log file, not stdout
  4. Test locally first — Pipe sample JSON through your script
  5. Use jq for JSON — It’s reliable and fast for parsing
  6. Layer defenses — Use BeforeLLMCall for input filtering and AfterLLMCall for output filtering

Local LLM Support

Moltis can run LLM inference locally on your machine without requiring an API key or internet connection. This enables fully offline operation and keeps your conversations private.

Backends

Moltis supports two backends for local inference:

BackendFormatPlatformGPU Acceleration
GGUF (llama.cpp).gguf filesmacOS, Linux, WindowsMetal (macOS), CUDA (NVIDIA), Vulkan (opt-in)
MLXMLX model reposmacOS (Apple Silicon only)Apple Silicon neural engine

GGUF (llama.cpp)

GGUF is the primary backend, powered by llama.cpp. It supports quantized models in the GGUF format, which significantly reduces memory requirements while maintaining good quality.

Advantages:

  • Cross-platform (macOS, Linux, Windows)
  • Wide model compatibility (any GGUF model)
  • GPU acceleration on Apple Silicon (Metal), NVIDIA (CUDA), and Vulkan-capable GPUs
  • Mature and well-tested

MLX

MLX is Apple’s machine learning framework optimized for Apple Silicon. Models from the mlx-community on HuggingFace are specifically optimized for M1/M2/M3/M4 chips.

Advantages:

  • Native Apple Silicon performance
  • Efficient unified memory usage
  • Lower latency on Macs

Requirements:

  • macOS with Apple Silicon (M1/M2/M3/M4)

Memory Requirements

Models are organized by memory tiers based on your system RAM:

TierRAMRecommended Models
Tiny4GBQwen 2.5 Coder 1.5B, Llama 3.2 1B
Small8GBQwen 2.5 Coder 3B, Llama 3.2 3B
Medium16GBQwen 2.5 Coder 7B, Llama 3.1 8B
Large32GB+Qwen 2.5 Coder 14B, DeepSeek Coder V2 Lite

Moltis automatically detects your system memory and suggests appropriate models in the UI.

Configuration

  1. Navigate to Providers in the sidebar
  2. Click Add Provider
  3. Select Local LLM
  4. Choose a model from the registry or search HuggingFace
  5. Click Configure — the model will download automatically

Via Configuration File

Add to ~/.config/moltis/moltis.toml:

[providers.local-llm]
models = ["qwen2.5-coder-7b-q4_k_m"]

For custom GGUF files:

{
  "models": [
    {
      "model_id": "my-custom-model",
      "model_path": "/path/to/model.gguf",
      "gpu_layers": 99,
      "backend": "GGUF"
    }
  ]
}

Save this as ~/.config/moltis/local-llm.json (the same file managed by the Settings UI).

Model Storage

Downloaded models are cached in ~/.moltis/models/ by default. This directory can grow large (several GB per model).

HuggingFace Integration

You can search and download models directly from HuggingFace:

  1. In the Add Provider dialog, click “Search HuggingFace”
  2. Enter a search term (e.g., “qwen coder”)
  3. Select GGUF or MLX backend
  4. Choose a model from the results
  5. The model will download immediately after you configure it

Finding GGUF Models

Look for repositories with “GGUF” in the name on HuggingFace:

  • TheBloke — large collection of quantized models
  • bartowski — Llama 3.x GGUF models
  • Qwen — official Qwen GGUF models

Finding MLX Models

MLX models are available from mlx-community:

  • Pre-converted models optimized for Apple Silicon
  • Look for models ending in -4bit or -8bit for quantized versions

GPU Acceleration

Metal (macOS)

Metal acceleration is enabled by default on macOS. The number of GPU layers can be configured:

{
  "models": [
    {
      "model_id": "qwen2.5-coder-7b-q4_k_m",
      "gpu_layers": 99,
      "backend": "GGUF"
    }
  ]
}

CUDA (NVIDIA)

Requires building with the local-llm-cuda feature:

cargo build --release --features local-llm-cuda

Vulkan

Vulkan acceleration is available as an opt-in build. It is not enabled by default in Moltis release builds.

Build with:

cargo build --release --features local-llm-vulkan

Requirements:

  • Linux: install Vulkan development packages, for example on Debian/Ubuntu: sudo apt-get install libvulkan-dev glslang-tools (Ubuntu 24.04+ also has a glslc package; on 22.04 install it from the LunarG Vulkan SDK if the build requires the glslc binary)
  • Windows: install the LunarG Vulkan SDK and set the VULKAN_SDK environment variable before building

If llama.cpp detects a Vulkan device at runtime, Moltis will report GGUF as using Vulkan acceleration in the local model setup flow.

Limitations

Local LLM models have some limitations compared to cloud providers:

  1. No tool calling — Local models don’t support function/tool calling. When using a local model, features like file operations, shell commands, and memory search are disabled.

  2. Slower inference — Depending on your hardware, local inference may be significantly slower than cloud APIs.

  3. Quality varies — Smaller quantized models may produce lower quality responses than larger cloud models.

  4. Context window — Local models typically have smaller context windows (8K-32K tokens vs 128K+ for cloud models).

Chat Templates

Different model families use different chat formatting. Moltis automatically detects the correct template for registered models:

  • ChatML — Qwen, many instruction-tuned models
  • Llama 3 — Meta’s Llama 3.x family
  • DeepSeek — DeepSeek Coder models

For custom models, the template is auto-detected from the model metadata when possible.

Troubleshooting

Model fails to load

  • Check you have enough RAM (see memory tier table above)
  • Verify the GGUF file isn’t corrupted (re-download if needed)
  • Ensure the model file matches the expected architecture

Slow inference

  • Enable GPU acceleration (Metal on macOS, CUDA on Linux)
  • Try a smaller/more quantized model
  • Reduce prompt/context length

Out of memory

  • Choose a model from a lower memory tier
  • Close other applications to free RAM
  • Use a more aggressively quantized model (Q4_K_M vs Q8_0)

Feature Flag

Local LLM support requires the local-llm feature flag at compile time:

cargo build --release --features local-llm

This is enabled by default in release builds.

Sandbox Backends

Moltis runs LLM-generated commands inside containers to protect your host system. The sandbox backend controls which container technology is used.

Backend Selection

Configure in moltis.toml:

[tools.exec.sandbox]
backend = "auto"          # default — picks the best available
# backend = "podman"      # force Podman (daemonless, rootless)
# backend = "docker"      # force Docker
# backend = "apple-container"  # force Apple Container (macOS only)
# backend = "wasm"        # force WASM sandbox (Wasmtime + WASI)
# backend = "restricted-host"  # env clearing + rlimits only

With "auto" (the default), Moltis picks the strongest available backend:

PriorityBackendPlatformIsolation
1Apple ContainermacOSVM (Virtualization.framework)
2PodmananyLinux namespaces / cgroups (daemonless)
3DockeranyLinux namespaces / cgroups
4Restricted Hostanyenv clearing, rlimits (no filesystem isolation)
5none (host)anyno isolation

The WASM backend (backend = "wasm") is not in the auto-detect chain because it cannot execute arbitrary shell commands — use it explicitly when you want WASI-isolated execution.

Apple Container runs each sandbox in a lightweight virtual machine using Apple’s Virtualization.framework. Every container gets its own kernel, so a kernel exploit inside the sandbox cannot reach the host — unlike Docker, which shares the host kernel.

Install

Download the signed installer from GitHub:

# Download the installer package
gh release download --repo apple/container --pattern "container-installer-signed.pkg" --dir /tmp

# Install (requires admin)
sudo installer -pkg /tmp/container-installer-signed.pkg -target /

# First-time setup — downloads a default Linux kernel
container system start

Alternatively, build from source with brew install container (requires Xcode 26+).

Verify

container --version
# Run a quick test
container run --rm ubuntu echo "hello from VM"

Once installed, restart moltis gateway — the startup banner will show sandbox: apple-container backend.

Podman

Podman is a daemonless, rootless container engine that is CLI-compatible with Docker. It is preferred over Docker in auto-detection because it doesn’t require a background daemon process and runs rootless by default for better security.

Install

# macOS
brew install podman
podman machine init && podman machine start

# Debian/Ubuntu
sudo apt-get install -y podman

# Fedora/RHEL
sudo dnf install -y podman

Verify

podman --version
podman run --rm docker.io/library/ubuntu echo "hello from podman"

Once installed, restart moltis gateway — the startup banner will show sandbox: podman backend. All Docker hardening flags (see below) apply identically to Podman containers.

Docker

Docker is supported on macOS, Linux, and Windows. On macOS it runs inside a Linux VM managed by Docker Desktop, so it is reasonably isolated but adds more overhead than Apple Container.

Install from https://docs.docker.com/get-docker/

Docker/Podman Hardening

Docker and Podman containers launched by Moltis include the following security hardening flags by default:

FlagEffect
--cap-drop ALLDrops all Linux capabilities
--security-opt no-new-privilegesPrevents privilege escalation via setuid/setgid binaries
--tmpfs /tmp:rw,nosuid,size=256mWritable tmpfs for temp files (noexec on real root)
--tmpfs /run:rw,nosuid,size=64mWritable tmpfs for runtime files
--read-onlyRead-only root filesystem (prebuilt images only)

The --read-only flag is applied only to prebuilt sandbox images (where packages are already baked in). Non-prebuilt images need a writable root filesystem for apt-get provisioning on first start.

WASM Sandbox (Wasmtime + WASI)

The WASM sandbox provides real sandboxed execution using Wasmtime with WASI. Commands execute in an isolated filesystem tree with fuel metering and epoch-based timeout enforcement.

How It Works

The WASM sandbox has two execution tiers:

Tier 1 — Built-in commands (~20 common coreutils implemented in Rust): echo, cat, ls, mkdir, rm, cp, mv, pwd, env, head, tail, wc, sort, touch, which, true, false, test/[, basename, dirname.

These operate on a sandboxed directory tree, translating guest paths (e.g. /home/sandbox/file.txt) to host paths under ~/.moltis/sandbox/wasm/<id>/. Paths outside the sandbox root are rejected.

Basic shell features are supported: &&, ||, ; sequences, $VAR expansion, quoting via shell-words, and > / >> output redirects.

Tier 2 — Real WASM module execution: When the command references a .wasm file, it is loaded and run via Wasmtime + WASI preview1 with full isolation: preopened directories, fuel metering, epoch interruption, and captured I/O.

Unknown commands return exit code 127: “command not found in WASM sandbox”.

Filesystem Isolation

~/.moltis/sandbox/wasm/<session-key>/
  home/        preopened as /home/sandbox (rw)
  tmp/         preopened as /tmp (rw)

Home persistence is respected:

  • shared: uses data_dir()/sandbox/home/shared/wasm/
  • session: uses data_dir()/sandbox/wasm/<session-id>/
  • off: per-session, cleaned up on cleanup()

Resource Limits

  • Fuel metering: store.set_fuel(fuel_limit) — limits WASM instruction count (Tier 2 only)
  • Epoch interruption: background thread ticks epochs, store traps on deadline (Tier 2 only)
  • Memory: wasm_config.memory_reservation(bytes) — Wasmtime memory limits (Tier 2 only)

Configuration

[tools.exec.sandbox]
backend = "wasm"

# WASM-specific settings
wasm_fuel_limit = 1000000000       # instruction fuel (default: 1 billion)
wasm_epoch_interval_ms = 100       # epoch interruption interval (default: 100ms)

[tools.exec.sandbox.resource_limits]
memory_limit = "512M"    # Wasmtime memory reservation

Limitations

  • Built-in commands cover common coreutils but not a full shell
  • No pipe support yet (planned via busybox.wasm in future)
  • No network access from WASM modules
  • .wasm modules must target WASI preview1

When to Use

The WASM sandbox is a good fit when:

  • You want filesystem-isolated execution without container overhead
  • You need a sandboxed environment on platforms without Docker or Apple Container
  • You are running .wasm modules and want fuel-metered, time-bounded execution

Compile-Time Feature

The WASM sandbox is gated behind the wasm cargo feature, which is enabled by default. To build without Wasmtime (saves ~30 MB binary size):

cargo build --release --no-default-features --features lightweight

When the feature is disabled and the config requests backend = "wasm", Moltis falls back to restricted-host with a warning.

Restricted Host Sandbox

The restricted-host sandbox provides lightweight isolation by running commands on the host via sh -c with environment clearing and ulimit resource wrappers. This is the fallback when no container runtime is available.

How It Works

When the restricted-host sandbox runs a command, it:

  1. Clears the environment — all inherited environment variables are removed
  2. Sets a restricted PATH — only /usr/local/bin:/usr/bin:/bin
  3. Sets HOME to /tmp — prevents access to the user’s home directory
  4. Applies resource limits via shell ulimit:
    • ulimit -u (max processes) from pids_max config (default: 256)
    • ulimit -n 1024 (max open files)
    • ulimit -t (CPU seconds) from cpu_quota config (default: 300s)
    • ulimit -v (virtual memory) from memory_limit config (default: 512M)
  5. Enforces a timeout via tokio::time::timeout

User-specified environment variables from opts.env are re-applied after the environment is cleared, so the LLM tool can still pass required variables.

Limitations

  • No filesystem isolation — commands run on the host filesystem
  • No network isolation — commands can make network requests
  • ulimit enforcement is best-effort
  • No image building — moltis sandbox build returns immediately

For production use with untrusted workloads, prefer Apple Container or Docker.

No sandbox

If no runtime is found (and the wasm feature is disabled), commands execute directly on the host. The startup banner will show a warning. This is not recommended for untrusted workloads.

Failover Chain

Moltis wraps the primary sandbox backend with automatic failover:

  • Apple Container → Docker → Restricted Host: if Apple Container enters a corrupted state (stale metadata, missing config, VM boot failure), Moltis fails over to Docker. If Docker is unavailable, it uses restricted-host.
  • Docker → Restricted Host: if Docker loses its daemon connection during a session, Moltis fails over to the restricted-host sandbox.

Failover is sticky for the lifetime of the gateway process — once triggered, all subsequent commands use the fallback backend. Restart the gateway to retry the primary backend.

Failover triggers:

PrimaryTriggers
Apple Containerconfig.json missing, VM never booted, NSPOSIXErrorDomain Code=22, service errors
Dockercannot connect to the docker daemon, connection refused, is the docker daemon running

Per-session overrides

The web UI allows toggling sandboxing per session and selecting a custom container image. These overrides persist across gateway restarts.

Home persistence

By default, /home/sandbox is persisted in a shared host folder so that CLI auth/config files survive container recreation. You can change this with home_persistence:

[tools.exec.sandbox]
home_persistence = "session"   # "off", "session", or "shared" (default)
# shared_home_dir = "/path/to/shared-home"  # optional, used when mode is "shared"
  • off: no home mount, container home is ephemeral
  • session: mount a per-session host folder to /home/sandbox
  • shared: mount one shared host folder to /home/sandbox for all sessions (defaults to data_dir()/sandbox/home/shared, or shared_home_dir if set)

Moltis stores persisted homes under data_dir()/sandbox/home/.

Docker-in-Docker workspace mounts

When Moltis runs inside a container and launches Docker-backed sandboxes via a mounted container socket, the sandbox bind mount source must be a host-visible path. Moltis auto-detects this by inspecting the parent container’s mounts. If that lookup fails or you want to pin the value explicitly, set host_data_dir:

[tools.exec.sandbox]
host_data_dir = "/srv/moltis/data"

This remaps sandbox workspace mounts and default sandbox persistence paths from the guest data_dir() to the host path you provide. It is mainly an override for Docker-in-Docker deployments where mount auto-detection is unavailable or ambiguous.

Network policy

By default, sandbox containers have no network access (no_network = true). For tasks that need filtered internet access, use trusted network mode — a proxy-based allowlist that lets containers reach approved domains while blocking everything else.

[tools.exec.sandbox]
network = "trusted"
trusted_domains = ["registry.npmjs.org", "github.com"]

See Trusted Network for full configuration and the network audit log.

Note: Home persistence applies to Docker, Apple Container, and WASM backends. The restricted-host backend uses HOME=/tmp and does not mount persistent storage.

Resource limits

[tools.exec.sandbox.resource_limits]
memory_limit = "512M"
cpu_quota = 1.0
pids_max = 256

How resource limits are applied depends on the backend:

LimitDockerApple ContainerWASMRestricted Hostcgroup (Linux)
memory_limit--memory--memoryWasmtime reservationulimit -vMemoryMax=
cpu_quota--cpus--cpusepoch timeoutulimit -t (seconds)CPUQuota=
pids_max--pids-limit--pids-limitn/aulimit -uTasksMax=

Comparison

FeatureApple ContainerDockerWASMRestricted Hostnone
Filesystem isolation✅ VM boundary✅ namespaces✅ sandboxed tree❌ host FS
Network isolation✅ (no network)
Kernel isolation✅ separate kernel❌ shared kernel✅ WASM VM
Environment isolation✅ cleared + restricted
Resource limits✅ fuel + epoch✅ ulimit
Image building✅ (via Docker)
Shell commands✅ full shell✅ full shell~20 built-ins✅ full shell✅ full shell
PlatformmacOS 26+anyanyanyany
Overheadlowmediumminimalminimalnone

Trusted Network Mode

Trusted network mode gives sandbox containers filtered internet access through a local HTTP proxy, so LLM-generated code can reach approved domains (package registries, APIs) while everything else is blocked.

Why

By default, sandbox containers run with no_network = true — full network isolation. This is the safest option but breaks any task that needs to install packages, fetch data, or call an API.

Setting no_network = false opens the network entirely, which defeats much of the sandbox’s purpose: a malicious command could exfiltrate data or download additional payloads.

Trusted network mode sits between these extremes. It routes all outbound traffic through a filtering proxy that only allows connections to domains you explicitly trust.

┌──────────────┐      CONNECT        ┌────────────┐       ┌──────────────┐
│   Sandbox    │ ──────────────────▶  │   Proxy    │ ────▶ │  github.com  │ ✓
│  Container   │                      │ :18791     │       └──────────────┘
│              │      CONNECT         │            │       ┌──────────────┐
│  HTTP_PROXY  │ ──────────────────▶  │  Domain    │ ──✗─▶ │  evil.com    │ ✗
│  = proxy:18791                      │  Filter    │       └──────────────┘
└──────────────┘                      └────────────┘

Configuration

Enable trusted network mode in moltis.toml:

[tools.exec.sandbox]
network = "trusted"
trusted_domains = [
  # Package registries
  "registry.npmjs.org",
  "pypi.org",
  "files.pythonhosted.org",
  "crates.io",
  "static.crates.io",

  # Git hosting
  "github.com",
  "gitlab.com",

  # Common APIs
  "api.openai.com",
  "httpbin.org",
]

Network policies

PolicyBehaviorUse case
(empty / default)Uses legacy no_network flagBackward compatible
blockedNo network at allMaximum isolation
trustedProxy-filtered by domain allowlistDevelopment tasks
openUnrestricted networkFully trusted workloads

open mode

network = "open" disables all network filtering. Only use this when you fully trust the LLM output or are running on a throw-away machine.

Domain patterns

The trusted_domains list supports three pattern types:

PatternExampleMatches
Exactgithub.comOnly github.com
Wildcard subdomain*.npmjs.orgregistry.npmjs.org, www.npmjs.org, etc.
Full wildcard*Everything (equivalent to open mode)

How the proxy works

When network = "trusted", the gateway starts an HTTP CONNECT proxy on 127.0.0.1:18791 at startup. Sandbox containers are configured to route traffic through this proxy via HTTP_PROXY / HTTPS_PROXY environment variables.

For each connection the proxy:

  1. Extracts the target domain from the CONNECT request (or Host header for plain HTTP).
  2. Checks the domain against the allowlist in DomainApprovalManager.
  3. If allowed — opens a TCP tunnel to the target and relays data bidirectionally.
  4. If denied — returns 403 Forbidden and logs the attempt.

Both allowed and denied requests are recorded in the network audit log.

Network Audit

Every proxied request is logged to an in-memory ring buffer (2048 entries) and persisted to ~/.moltis/network-audit.jsonl. The audit log is accessible from:

  • Settings > Network Audit in the web UI
  • RPC methods: network.audit.list, network.audit.tail, network.audit.stats
  • WebSocket events: network.audit.entry streams entries in real time

Audit entry fields

FieldDescription
timestampISO 8601 timestamp (RFC 3339)
domainTarget domain name
portTarget port number
protocolhttps, http, or connect
actionallowed or denied
sourceWhy the decision was made (config_allowlist, session, etc.)

Filtering the audit log

The web UI provides real-time filtering by:

  • Domain — free-text search across domain names
  • Protocol — filter by https, http, or connect
  • Action — show only allowed or denied entries

The RPC methods accept the same filter parameters:

{
  "method": "network.audit.list",
  "params": {
    "domain": "github",
    "protocol": "https",
    "action": "denied",
    "limit": 100
  }
}

Audit stats

network.audit.stats returns aggregate counts:

{
  "total": 847,
  "allowed": 812,
  "denied": 35,
  "by_domain": [
    { "domain": "registry.npmjs.org", "count": 423 },
    { "domain": "github.com", "count": 312 }
  ]
}

Node.js / npm

trusted_domains = [
  "registry.npmjs.org",
  "*.npmjs.org",
]

Python / pip

trusted_domains = [
  "pypi.org",
  "files.pythonhosted.org",
]

Rust / cargo

trusted_domains = [
  "crates.io",
  "static.crates.io",
  "index.crates.io",
]

Git operations

trusted_domains = [
  "github.com",
  "gitlab.com",
  "bitbucket.org",
]

Start narrow, widen as needed

Begin with only the registries your project uses. The audit log will show denied domains — add them to the allowlist only if they are legitimate.

Relationship to other settings

  • no_network = true (legacy) is equivalent to network = "blocked". When network is set, it takes precedence over no_network.
  • mode = "off" disables sandboxing entirely — network policy has no effect because commands run directly on the host.
  • Resource limits (memory_limit, cpu_quota, pids_max) apply independently of the network policy.

Troubleshooting

Proxy not starting: Check the gateway startup log for trusted-network proxy started on port 18791. If missing, verify network = "trusted" is set in moltis.toml.

Connections timing out: Some tools don’t respect HTTP_PROXY. Verify the tool uses the proxy by checking the audit log — if no entries appear for the domain, the tool is bypassing the proxy.

Too many denied entries: Review the audit log to identify legitimate domains being blocked, then add them to trusted_domains.

Voice Services

Moltis provides text-to-speech (TTS) and speech-to-text (STT) capabilities through the moltis-voice crate and gateway integration.

Feature Flag

Voice services are behind the voice cargo feature, enabled by default:

# Cargo.toml (gateway crate)
[features]
default = ["voice", ...]
voice = ["dep:moltis-voice"]

To disable voice features at compile time:

cargo build --no-default-features --features "file-watcher,tailscale,tls,web-ui"

When disabled:

  • TTS/STT RPC methods are not registered
  • Voice settings section is hidden in the UI
  • Microphone button is hidden in the chat interface
  • voice_enabled: false is set in the gon data

Architecture

┌─────────────────────────────────────────────────────────────┐
│                      Voice Crate                            │
│                   (crates/voice/)                           │
├─────────────────────────────────────────────────────────────┤
│  TtsProvider trait         │  SttProvider trait             │
│  ├─ ElevenLabsTts          │  ├─ WhisperStt (OpenAI)        │
│  ├─ OpenAiTts              │  ├─ GroqStt (Groq)             │
│  ├─ GoogleTts              │  ├─ DeepgramStt                │
│  ├─ PiperTts (local)       │  ├─ GoogleStt                  │
│  └─ CoquiTts (local)       │  ├─ MistralStt                 │
│                            │  ├─ VoxtralLocalStt (local)    │
│                            │  ├─ WhisperCliStt (local)      │
│                            │  └─ SherpaOnnxStt (local)      │
└─────────────────────────────────────────────────────────────┘
                    │
                    ▼
┌─────────────────────────────────────────────────────────────┐
│                    Gateway Services                         │
│                (crates/gateway/src/voice.rs)                │
├─────────────────────────────────────────────────────────────┤
│  LiveTtsService            │  LiveSttService                │
│  (wraps TTS providers)     │  (wraps STT providers)         │
└─────────────────────────────────────────────────────────────┘
                    │
                    ▼
┌─────────────────────────────────────────────────────────────┐
│                     RPC Methods                             │
├─────────────────────────────────────────────────────────────┤
│  tts.status, tts.providers, tts.enable, tts.disable,        │
│  tts.convert, tts.setProvider                               │
│  stt.status, stt.providers, stt.transcribe, stt.setProvider │
└─────────────────────────────────────────────────────────────┘

Text-to-Speech (TTS)

Supported Providers

Moltis supports multiple TTS providers across cloud and local backends.

CategoryNotes
Cloud TTS providersHosted neural voices with low-latency streaming
Local TTS providersOffline/on-device synthesis for privacy-sensitive workflows

More voice providers are coming soon.

Configuration

Set API keys via environment variables:

export ELEVENLABS_API_KEY=your-key-here
export OPENAI_API_KEY=your-key-here

Or configure in moltis.toml:

[voice.tts]
enabled = true
# provider = "openai"    # Omit to auto-select the first configured provider
providers = []           # Optional UI allowlist, empty = show all TTS providers
auto = "off"             # "always", "off", "inbound", "tagged"
max_text_length = 2000

[voice.tts.elevenlabs]
api_key = "sk-..."
voice_id = "21m00Tcm4TlvDq8ikWAM"  # Rachel (default)
model = "eleven_flash_v2_5"
stability = 0.5
similarity_boost = 0.75

[voice.tts.openai]
# No api_key needed if OpenAI is configured as an LLM provider or OPENAI_API_KEY is set.
# api_key = "sk-..."
voice = "alloy"  # alloy, echo, fable, onyx, nova, shimmer
model = "tts-1"
speed = 1.0

[voice.tts.google]
api_key = "..."  # Google Cloud API key
voice = "en-US-Neural2-D"  # See Google Cloud TTS voices
language_code = "en-US"
speaking_rate = 1.0

# Local providers - no API key required

[voice.tts.piper]
# binary_path = "/usr/local/bin/piper"  # optional, searches PATH
model_path = "~/.moltis/models/en_US-lessac-medium.onnx"  # required
# config_path = "~/.moltis/models/en_US-lessac-medium.onnx.json"  # optional
# speaker_id = 0  # for multi-speaker models
# length_scale = 1.0  # speaking rate (lower = faster)

[voice.tts.coqui]
endpoint = "http://localhost:5002"  # Coqui TTS server
# model = "tts_models/en/ljspeech/tacotron2-DDC"  # optional

Local TTS Provider Setup

Piper TTS

Piper is a fast, local neural text-to-speech system that runs entirely offline.

  1. Install Piper:

    # Via pip
    pip install piper-tts
    
    # Or download pre-built binaries from:
    # https://github.com/rhasspy/piper/releases
    
  2. Download a voice model from Piper Voices:

    mkdir -p ~/.moltis/models
    curl -L -o ~/.moltis/models/en_US-lessac-medium.onnx \
      https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx
    
  3. Configure in moltis.toml:

    [voice.tts]
    provider = "piper"
    
    [voice.tts.piper]
    model_path = "~/.moltis/models/en_US-lessac-medium.onnx"
    

Coqui TTS

Coqui TTS is a high-quality neural TTS with voice cloning capabilities.

  1. Install and start the server:

    # Via pip
    pip install TTS
    tts-server --model_name tts_models/en/ljspeech/tacotron2-DDC
    
    # Or via Docker
    docker run -p 5002:5002 ghcr.io/coqui-ai/tts
    
  2. Configure in moltis.toml:

    [voice.tts]
    provider = "coqui"
    
    [voice.tts.coqui]
    endpoint = "http://localhost:5002"
    

Browse available models at Coqui TTS Models.

RPC Methods

tts.status

Get current TTS status.

Response:

{
  "enabled": true,
  "provider": "elevenlabs",
  "auto": "off",
  "maxTextLength": 2000,
  "configured": true
}

tts.providers

List available TTS providers.

Response:

[
  { "id": "elevenlabs", "name": "ElevenLabs", "configured": true },
  { "id": "openai", "name": "OpenAI", "configured": false }
]

tts.enable

Enable TTS with optional provider selection.

Request:

{ "provider": "elevenlabs" }

Response:

{ "enabled": true, "provider": "elevenlabs" }

tts.disable

Disable TTS.

Response:

{ "enabled": false }

tts.convert

Convert text to speech.

Request:

{
  "text": "Hello, how can I help you today?",
  "provider": "elevenlabs",
  "voiceId": "21m00Tcm4TlvDq8ikWAM",
  "model": "eleven_flash_v2_5",
  "format": "mp3",
  "speed": 1.0,
  "stability": 0.5,
  "similarityBoost": 0.75
}

Response:

{
  "audio": "base64-encoded-audio-data",
  "format": "mp3",
  "mimeType": "audio/mpeg",
  "durationMs": 2500,
  "size": 45000
}

Audio Formats:

  • mp3 (default) - Widely compatible
  • opus / ogg - Good for Telegram voice notes
  • aac - Apple devices
  • pcm - Raw audio

tts.setProvider

Change the active TTS provider.

Request:

{ "provider": "openai" }

Auto-Speak Modes

ModeDescription
alwaysSpeak all AI responses
offNever auto-speak (default)
inboundOnly when user sent voice input
taggedOnly with explicit [[tts]] markup

Speech-to-Text (STT)

Supported Providers

Moltis supports multiple STT providers across cloud and local backends.

CategoryNotes
Cloud STT providersManaged transcription APIs with language/model options
Local STT providersOffline transcription through local binaries or services

More voice providers are coming soon.

Configuration

[voice.stt]
enabled = true
# provider = "whisper"  # Omit to auto-select the first configured provider
providers = []          # Optional UI allowlist, empty = show all STT providers

# Cloud providers - API key required
[voice.stt.whisper]
# No api_key needed if OpenAI is configured as an LLM provider or OPENAI_API_KEY is set.
# api_key = "sk-..."
model = "whisper-1"
language = "en"     # Optional ISO 639-1 hint

[voice.stt.groq]
api_key = "gsk_..."
model = "whisper-large-v3-turbo"  # default
language = "en"

[voice.stt.deepgram]
api_key = "..."
model = "nova-3"  # default
language = "en"
smart_format = true

[voice.stt.google]
api_key = "..."
language = "en-US"
# model = "latest_long"  # optional

[voice.stt.mistral]
api_key = "..."
model = "voxtral-mini-latest"  # default
language = "en"

# Local providers - no API key, requires server or binary

# Voxtral local via vLLM server
[voice.stt.voxtral_local]
# endpoint = "http://localhost:8000"  # default vLLM endpoint
# model = "mistralai/Voxtral-Mini-3B-2507"  # optional, server default
# language = "en"  # optional

[voice.stt.whisper_cli]
# binary_path = "/usr/local/bin/whisper-cli"  # optional, searches PATH
model_path = "~/.moltis/models/ggml-base.en.bin"  # required
language = "en"

[voice.stt.sherpa_onnx]
# binary_path = "/usr/local/bin/sherpa-onnx-offline"  # optional
model_dir = "~/.moltis/models/sherpa-onnx-whisper-tiny.en"  # required
language = "en"

Local Provider Setup

Voxtral via vLLM

Voxtral is an open-weights model from Mistral AI that can run locally using vLLM. It supports 13 languages with fast transcription.

Requirements:

  • Python 3.10+
  • CUDA-capable GPU with ~9.5GB VRAM (or CPU with more memory)
  • vLLM with audio support

Setup:

  1. Install vLLM with audio support:

    pip install "vllm[audio]"
    
  2. Start the vLLM server:

    vllm serve mistralai/Voxtral-Mini-3B-2507 \
      --tokenizer_mode mistral \
      --config_format mistral \
      --load_format mistral
    

    The server exposes an OpenAI-compatible endpoint at http://localhost:8000.

  3. Configure in moltis.toml:

    [voice.stt]
    provider = "voxtral-local"
    
    [voice.stt.voxtral_local]
    # Default endpoint works if vLLM is running locally
    # endpoint = "http://localhost:8000"
    

Supported Languages: English, French, German, Spanish, Portuguese, Italian, Dutch, Polish, Swedish, Norwegian, Danish, Finnish, Arabic

Note: Unlike the embedded local providers (whisper.cpp, sherpa-onnx), this requires running vLLM as a separate server process. The model is downloaded automatically on first vLLM startup.

whisper.cpp

  1. Install the binary:

    # macOS
    brew install whisper-cpp
    
    # From source: https://github.com/ggerganov/whisper.cpp
    
  2. Download a model from Hugging Face:

    mkdir -p ~/.moltis/models
    curl -L -o ~/.moltis/models/ggml-base.en.bin \
      https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin
    
  3. Configure in moltis.toml:

    [voice.stt]
    provider = "whisper-cli"
    
    [voice.stt.whisper_cli]
    model_path = "~/.moltis/models/ggml-base.en.bin"
    

sherpa-onnx

  1. Install following the official docs

  2. Download a model from the model list

  3. Configure in moltis.toml:

    [voice.stt]
    provider = "sherpa-onnx"
    
    [voice.stt.sherpa_onnx]
    model_dir = "~/.moltis/models/sherpa-onnx-whisper-tiny.en"
    

RPC Methods

stt.status

Get current STT status.

Response:

{
  "enabled": true,
  "provider": "whisper",
  "configured": true
}

stt.providers

List available STT providers.

Response:

[
  { "id": "whisper", "name": "OpenAI Whisper", "configured": true },
  { "id": "groq", "name": "Groq", "configured": false },
  { "id": "deepgram", "name": "Deepgram", "configured": false },
  { "id": "google", "name": "Google Cloud", "configured": false },
  { "id": "mistral", "name": "Mistral AI", "configured": false },
  { "id": "voxtral-local", "name": "Voxtral (Local)", "configured": false },
  { "id": "whisper-cli", "name": "whisper.cpp", "configured": false },
  { "id": "sherpa-onnx", "name": "sherpa-onnx", "configured": false }
]

stt.transcribe

Transcribe audio to text.

Request:

{
  "audio": "base64-encoded-audio-data",
  "format": "mp3",
  "language": "en",
  "prompt": "Technical discussion about Rust programming"
}

Response:

{
  "text": "Hello, how are you today?",
  "language": "en",
  "confidence": null,
  "durationSeconds": 2.5,
  "words": [
    { "word": "Hello", "start": 0.0, "end": 0.5 },
    { "word": "how", "start": 0.6, "end": 0.8 },
    { "word": "are", "start": 0.9, "end": 1.0 },
    { "word": "you", "start": 1.1, "end": 1.3 },
    { "word": "today", "start": 1.4, "end": 1.8 }
  ]
}

Parameters:

  • audio (required): Base64-encoded audio data
  • format: Audio format (mp3, opus, ogg, aac, pcm)
  • language: ISO 639-1 code to improve accuracy
  • prompt: Context hint (terminology, topic)

stt.setProvider

Change the active STT provider.

Request:

{ "provider": "groq" }

Valid provider IDs: whisper, groq, deepgram, google, mistral, voxtral-local, whisper-cli, sherpa-onnx

Code Structure

Voice Crate (crates/voice/)

src/
├── lib.rs           # Crate entry, re-exports
├── config.rs        # VoiceConfig, TtsConfig, SttConfig
├── tts/
│   ├── mod.rs       # TtsProvider trait, AudioFormat, types
│   ├── elevenlabs.rs # ElevenLabs implementation
│   ├── openai.rs    # OpenAI TTS implementation
│   ├── google.rs    # Google Cloud TTS implementation
│   ├── piper.rs     # Piper local TTS implementation
│   └── coqui.rs     # Coqui TTS server implementation
└── stt/
    ├── mod.rs          # SttProvider trait, Transcript types
    ├── whisper.rs      # OpenAI Whisper implementation
    ├── groq.rs         # Groq Whisper implementation
    ├── deepgram.rs     # Deepgram implementation
    ├── google.rs       # Google Cloud Speech-to-Text
    ├── mistral.rs      # Mistral AI Voxtral cloud implementation
    ├── voxtral_local.rs # Voxtral via local vLLM server
    ├── cli_utils.rs    # Shared utilities for CLI providers
    ├── whisper_cli.rs  # whisper.cpp CLI wrapper
    └── sherpa_onnx.rs  # sherpa-onnx CLI wrapper

Key Traits

#![allow(unused)]
fn main() {
/// Text-to-Speech provider trait
#[async_trait]
pub trait TtsProvider: Send + Sync {
    fn id(&self) -> &'static str;
    fn name(&self) -> &'static str;
    fn is_configured(&self) -> bool;
    async fn voices(&self) -> Result<Vec<Voice>>;
    async fn synthesize(&self, request: SynthesizeRequest) -> Result<AudioOutput>;
}

/// Speech-to-Text provider trait
#[async_trait]
pub trait SttProvider: Send + Sync {
    fn id(&self) -> &'static str;
    fn name(&self) -> &'static str;
    fn is_configured(&self) -> bool;
    async fn transcribe(&self, request: TranscribeRequest) -> Result<Transcript>;
}
}

Gateway Integration (crates/gateway/src/voice.rs)

  • LiveTtsService: Wraps TTS providers, implements TtsService trait
  • LiveSttService: Wraps STT providers, implements SttService trait
  • NoopSttService: No-op for when STT is not configured

Security

  • API keys are stored using secrecy::Secret<String> to prevent accidental logging
  • Debug output redacts all secret values
  • Keys can be set via environment variables or config file

Adding New Providers

TTS Provider

  1. Create crates/voice/src/tts/newprovider.rs
  2. Implement TtsProvider trait
  3. Re-export from crates/voice/src/tts/mod.rs
  4. Add to LiveTtsService in gateway

STT Provider

  1. Create crates/voice/src/stt/newprovider.rs
  2. Implement SttProvider trait
  3. Re-export from crates/voice/src/stt/mod.rs
  4. Add to LiveSttService in gateway

Web UI Integration

The voice feature integrates with the web UI:

  • Microphone button: Record voice input in the chat interface
  • Settings page: Configure and enable/disable voice providers
  • Auto-detection: API keys are detected from environment variables and LLM provider configs
  • Toggle switches: Enable/disable providers without removing configuration
  • Setup instructions: Step-by-step guides for local provider installation

Future Enhancements

  • Streaming TTS: Chunked audio delivery for lower latency
  • VoiceWake: Wake word detection and continuous listening
  • Audio playback: Play TTS responses directly in the chat
  • Channel Integration: Auto-transcribe Telegram voice messages
  • Per-Agent Voices: Different voices for different agents

Channels

Moltis connects to messaging platforms through channels. Each channel type has a distinct inbound mode, determining how it receives messages, and a set of capabilities that control what features are available.

Supported Channels

ChannelInbound ModePublic URL RequiredKey Capabilities
TelegramPollingNoStreaming, voice ingest, reactions, OTP, location
DiscordGateway (WebSocket)NoStreaming, interactive messages, threads, reactions
Microsoft TeamsWebhookYesStreaming, interactive messages, threads
WhatsAppGateway (WebSocket)NoStreaming, voice ingest, OTP, pairing, location
SlackSocket ModeNoStreaming, interactive messages, threads, reactions

Inbound Modes

Polling

The bot periodically fetches new messages from the platform API. No public URL or open port is needed. Used by Telegram.

Gateway / WebSocket

The bot opens a persistent outbound WebSocket connection to the platform and receives events in real time. No public URL needed. Used by Discord and WhatsApp.

Socket Mode

Similar to a gateway connection, but uses the platform’s Socket Mode protocol. No public URL needed. Used by Slack.

Webhook

The platform sends HTTP POST requests to a publicly reachable endpoint on your server. You must configure the messaging endpoint URL in the platform’s settings. Used by Microsoft Teams.

None (Send-Only)

For channels that only send outbound messages and do not receive inbound traffic. No channels currently use this mode, but it is available for future integrations (e.g. email, SMS).

Capabilities Reference

CapabilityDescription
supports_outboundCan send messages to users
supports_streamingCan stream partial responses (typing/editing)
supports_interactiveCan send interactive components (buttons, menus)
supports_threadsCan reply in threads
supports_voice_ingestCan receive and transcribe voice messages
supports_pairingRequires device pairing (QR code)
supports_otpSupports OTP-based sender approval
supports_reactionsCan add/remove emoji reactions
supports_locationCan receive and process location data

Setup

Each channel is configured in moltis.toml under [channels]:

[channels.telegram.my_bot]
token = "123456:ABC-DEF..."
dm_policy = "allowlist"
allowlist = ["alice", "bob"]

[channels.msteams.my_teams_bot]
app_id = "..."
app_password = "..."

[channels.discord.my_discord_bot]
token = "..."

[channels.slack.my_slack_bot]
bot_token = "xoxb-..."
app_token = "xapp-..."

[channels.whatsapp.my_wa]
dm_policy = "open"

For detailed configuration, see the per-channel pages: Telegram, Discord, Slack, WhatsApp.

You can also use the web UI’s Channels tab for guided setup with each platform.

Proactive Outbound Messaging

Agents are not limited to replying in the current chat. Moltis supports three main outbound patterns:

  • send_message tool for direct proactive messages to any configured channel account/chat
  • Cron job delivery for background jobs that should post their final output to a channel
  • Heartbeat delivery for periodic heartbeat acknowledgements sent to a chosen chat

Example send_message tool call:

{
  "account_id": "my-telegram-bot",
  "to": "123456789",
  "text": "Deployment finished successfully."
}

account_id is the configured channel account name from moltis.toml, and to is the destination chat, peer, or room identifier for that platform.

Access Control

All channels share the same access control model with three settings:

DM Policy

Controls who can send direct messages to the bot.

ValueBehavior
"allowlist"Only users listed in allowlist can DM (default for all channels except WhatsApp)
"open"Anyone can DM the bot
"disabled"DMs are silently ignored

Empty allowlist blocks everyone

When dm_policy = "allowlist" with an empty allowlist, all DMs are blocked. This is a security feature — removing all entries from an allowlist never silently switches to open access. Add user IDs/usernames to allowlist or set dm_policy = "open".

Group Policy

Controls who can interact with the bot in group chats / channels / guilds.

ValueBehavior
"open"Bot responds in all groups (default)
"allowlist"Only groups on the allowlist are allowed
"disabled"Group messages are silently ignored

The group allowlist field name varies by channel: group_allowlist (Telegram, WhatsApp, MS Teams), guild_allowlist (Discord), channel_allowlist (Slack).

Mention Mode

Controls when the bot responds in groups (does not apply to DMs).

ValueBehavior
"mention"Bot only responds when @mentioned (default)
"always"Bot responds to every message
"none"Bot never responds in groups (DM-only)

Allowlist Matching

All allowlist fields across all channels share the same matching behavior:

  • Values are strings — even for numeric IDs, use "123456789" not 123456789
  • Case-insensitive"Alice" matches "alice"
  • Glob wildcards"admin_*", "*@example.com", "user_*_vip"
  • Multiple identifiers — both the user’s numeric ID and username are checked (where applicable)

OTP Self-Approval

Channels that support OTP (Telegram, Discord, WhatsApp) allow non-allowlisted users to self-approve by entering a 6-digit code. The code appears in the web UI under Channels > Senders. See each channel’s page for details.

FieldDefaultDescription
otp_self_approvaltrueEnable OTP challenges for non-allowlisted DM users
otp_cooldown_secs300Lockout duration after 3 failed attempts

Telegram

Moltis can connect to Telegram as a bot, letting you chat with your agent from any Telegram conversation. The integration uses Telegram’s Bot API with long polling — no public URL or webhook endpoint is required.

How It Works

┌──────────────────────────────────────────────────────┐
│                 Telegram Bot API                      │
│            (api.telegram.org/bot...)                  │
└──────────────────┬───────────────────────────────────┘
                   │  long polling (getUpdates)
                   ▼
┌──────────────────────────────────────────────────────┐
│              moltis-telegram crate                    │
│  ┌────────────┐  ┌────────────┐  ┌────────────────┐  │
│  │  Handler   │  │  Outbound  │  │     Plugin     │  │
│  │ (inbound)  │  │ (replies)  │  │  (lifecycle)   │  │
│  └────────────┘  └────────────┘  └────────────────┘  │
└──────────────────┬───────────────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────────────┐
│                 Moltis Gateway                        │
│         (chat dispatch, tools, memory)                │
└──────────────────────────────────────────────────────┘

The bot connects outward to Telegram’s servers via polling. No port forwarding, public domain, or TLS certificate is needed. This makes it easy to run on a home machine or behind a NAT.

Prerequisites

Before configuring Moltis, create a Telegram bot:

  1. Open Telegram and message @BotFather
  2. Send /newbot and follow the prompts to choose a name and username
  3. Copy the bot token (e.g. 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11)

Warning

The bot token is a secret — treat it like a password. Never commit it to version control. Moltis stores it with secrecy::Secret and redacts it from logs, but your moltis.toml file is plain text on disk. Consider using Vault for encryption at rest.

Configuration

Add a [channels.telegram.<account-id>] section to your moltis.toml:

[channels.telegram.my-bot]
token = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"

Make sure "telegram" is included in channels.offered so the Web UI shows the Telegram option:

[channels]
offered = ["telegram"]

Configuration Fields

FieldRequiredDefaultDescription
tokenyesBot token from @BotFather
dm_policyno"allowlist"Who can DM the bot: "open", "allowlist", or "disabled"
group_policyno"open"Who can talk to the bot in groups: "open", "allowlist", or "disabled"
mention_modeno"mention"When the bot responds in groups: "always", "mention" (only when @mentioned), or "none"
allowlistno[]User IDs or usernames allowed to DM the bot (when dm_policy = "allowlist")
group_allowlistno[]Group/chat IDs allowed to interact with the bot
modelnoOverride the default model for this channel
model_providernoProvider for the overridden model
reply_to_messagenofalseSend bot responses as Telegram replies to the user’s message
otp_self_approvalnotrueEnable OTP self-approval for non-allowlisted DM users
otp_cooldown_secsno300Cooldown in seconds after 3 failed OTP attempts
stream_modeno"edit_in_place"Streaming mode: "edit_in_place" or "off"
edit_throttle_msno300Minimum milliseconds between streaming edit updates
stream_notify_on_completenofalseSend a completion notification after streaming finishes
stream_min_initial_charsno30Minimum characters before sending the first streamed message

Allowlist values are strings

All allowlist entries must be strings, even for numeric Telegram user IDs. Write allowlist = ["123456789"], not allowlist = [123456789]. Both numeric user IDs and usernames are supported — the bot checks both when evaluating access.

Full Example

[channels]
offered = ["telegram"]

[channels.telegram.my-bot]
token = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
dm_policy = "allowlist"
group_policy = "open"
mention_mode = "mention"
allowlist = ["123456789", "alice_username"]
group_allowlist = ["-1001234567890"]
reply_to_message = true
model = "claude-sonnet-4-20250514"
model_provider = "anthropic"
otp_self_approval = true
stream_mode = "edit_in_place"
edit_throttle_ms = 300

Per-User and Per-Channel Model Overrides

You can override the model for specific users or group chats:

[channels.telegram.my-bot]
token = "..."
model = "claude-sonnet-4-20250514"
model_provider = "anthropic"

[channels.telegram.my-bot.channel_overrides."-1001234567890"]
model = "gpt-4o"
model_provider = "openai"

[channels.telegram.my-bot.user_overrides."123456789"]
model = "claude-opus-4-20250514"
model_provider = "anthropic"

User overrides take priority over channel overrides, which take priority over the account default.

Access Control

Telegram uses the same gating system as Discord and other channels.

DM Policy

Controls who can send direct messages to the bot.

ValueBehavior
"allowlist"Only users listed in allowlist can DM (default)
"open"Anyone who can find the bot can DM it
"disabled"DMs are silently ignored

Default denies unlisted users

The default dm_policy is "allowlist". With an empty allowlist, all DMs are blocked. You must either add entries to allowlist or set dm_policy = "open".

Group Policy

Controls who can interact with the bot in group chats.

ValueBehavior
"open"Bot responds in any group (default)
"allowlist"Only groups listed in group_allowlist are allowed
"disabled"Group messages are silently ignored

Mention Mode

Controls when the bot responds in groups (does not apply to DMs).

ValueBehavior
"mention"Bot only responds when @mentioned (default)
"always"Bot responds to every message in allowed groups
"none"Bot never responds in groups (useful for DM-only bots)

Allowlist Matching

Allowlist entries support:

  • Exact match (case-insensitive): "alice", "123456789"
  • Glob wildcards: "admin_*", "*_bot", "user_*_vip"

Both the user’s numeric Telegram ID and their username are checked against the allowlist. For example, if a user has ID 123456789 and username alice, either "123456789" or "alice" in the allowlist grants access.

OTP Self-Approval

When dm_policy = "allowlist" and otp_self_approval = true (the default), unknown users who DM the bot receive a verification challenge:

  1. User sends a DM to the bot
  2. Bot responds with a challenge prompt (the 6-digit code is not shown to the user)
  3. The code appears in the Moltis web UI under Channels > Senders
  4. The bot owner shares the code with the user out-of-band
  5. User replies with the 6-digit code
  6. On success, the user is automatically added to the allowlist

After 3 failed attempts, the user is locked out for otp_cooldown_secs seconds (default: 300). Codes expire after 5 minutes.

Set otp_self_approval = false if you want to manually approve every user from the web UI.

Streaming

By default (stream_mode = "edit_in_place"), the bot sends an initial message after stream_min_initial_chars characters (default: 30) and then edits it in place as tokens arrive, throttled to at most one edit every edit_throttle_ms milliseconds (default: 300).

Set stream_mode = "off" to disable streaming and send the full response as a single message.

When stream_notify_on_complete = true, the bot sends a short non-silent message after streaming finishes. This can trigger a Telegram push notification on the user’s device (since silent edits don’t always trigger notifications).

Finding Your Telegram User ID

To find your numeric Telegram user ID (for the allowlist):

  1. Message @userinfobot on Telegram
  2. It replies with your user ID, first name, and username
  3. Use the numeric ID as a string in your config: allowlist = ["123456789"]

Alternatively, use your Telegram username (without the @): allowlist = ["your_username"].

Web UI Setup

You can also configure Telegram through the web interface:

  1. Open Settings > Channels
  2. Click Connect Telegram
  3. Enter an account ID (any alias) and your bot token
  4. Adjust DM policy, mention mode, and allowlist as needed
  5. Click Connect

The same form is available during onboarding when Telegram is in channels.offered.

Troubleshooting

Bot doesn’t respond

  • Verify the bot token is correct (ask @BotFather for /token if unsure)
  • Check dm_policy — if set to "allowlist", make sure your user ID or username is listed in allowlist
  • An empty allowlist with dm_policy = "allowlist" blocks all DMs
  • Check group_policy — if "disabled", group messages are ignored
  • Look at logs: RUST_LOG=moltis_telegram=debug moltis

“allowed_users” doesn’t work

The field name is allowlist, not allowed_users. If you’re migrating from OpenClaw, note that the field was renamed. Values must also be strings (e.g. ["123456789"]), not integers.

Bot responds to everyone

  • Check that dm_policy = "allowlist" is set (it’s the default)
  • Verify you have entries in allowlist — an empty allowlist with dm_policy = "allowlist" blocks everyone, but dm_policy = "open" allows everyone

Bot doesn’t respond in groups

  • Check mention_mode — if set to "mention", you must @mention the bot
  • Check group_policy — if "disabled", group messages are ignored
  • Check group_allowlist — if non-empty, the group must be listed

Discord

Moltis can connect to Discord as a bot, letting you chat with your agent from any Discord server or DM. The integration uses Discord’s Gateway API via a persistent WebSocket connection — no public URL or webhook endpoint is required.

How It Works

┌──────────────────────────────────────────────────────┐
│                   Discord Gateway                     │
│              (wss://gateway.discord.gg)               │
└──────────────────┬───────────────────────────────────┘
                   │  persistent WebSocket
                   ▼
┌──────────────────────────────────────────────────────┐
│               moltis-discord crate                    │
│  ┌────────────┐  ┌────────────┐  ┌────────────────┐  │
│  │  Handler   │  │  Outbound  │  │     Plugin     │  │
│  │ (inbound)  │  │ (replies)  │  │  (lifecycle)   │  │
│  └────────────┘  └────────────┘  └────────────────┘  │
└──────────────────┬───────────────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────────────┐
│                 Moltis Gateway                        │
│         (chat dispatch, tools, memory)                │
└──────────────────────────────────────────────────────┘

The bot connects outward to Discord’s servers. Unlike Microsoft Teams (which requires an inbound webhook), Discord needs no port forwarding, no public domain, and no TLS certificate. This makes it especially easy to run on a home machine or behind a NAT.

Prerequisites

Before configuring Moltis, create a Discord bot:

  1. Go to the Discord Developer Portal
  2. Click New Application and give it a name
  3. Navigate to Bot in the left sidebar
  4. Click Reset Token and copy the bot token
  5. Under Privileged Gateway Intents, enable Message Content Intent
  6. Navigate to OAuth2 → URL Generator
    • Scopes: bot
    • Bot Permissions: Send Messages, Attach Files, Read Message History, Add Reactions
  7. Copy the generated URL and open it to invite the bot to your server

Warning

The bot token is a secret — treat it like a password. Never commit it to version control. Moltis stores it with secrecy::Secret and redacts it from logs, but your moltis.toml file is plain text on disk. Consider using Vault for encryption at rest.

Configuration

Add a [channels.discord.<account-id>] section to your moltis.toml:

[channels.discord.my-bot]
token = "MTIzNDU2Nzg5.example.bot-token"

Make sure "discord" is included in channels.offered so the Web UI shows the Discord option:

[channels]
offered = ["telegram", "discord"]

Configuration Fields

FieldRequiredDefaultDescription
tokenyesDiscord bot token from the Developer Portal
dm_policyno"allowlist"Who can DM the bot: "open", "allowlist", or "disabled"
group_policyno"open"Who can talk to the bot in guild channels: "open", "allowlist", or "disabled"
mention_modeno"mention"When the bot responds in guilds: "always", "mention" (only when @mentioned), or "none"
allowlistno[]Discord usernames allowed to DM the bot (when dm_policy = "allowlist")
guild_allowlistno[]Guild (server) IDs allowed to interact with the bot
modelnoOverride the default model for this channel
model_providernoProvider for the overridden model
reply_to_messagenofalseSend bot responses as Discord replies to the user’s message
ack_reactionnoEmoji reaction added while processing (e.g. "👀"); omit to disable
activitynoBot activity status text (e.g. "with AI")
activity_typeno"custom"Activity type: "playing", "listening", "watching", "competing", or "custom"
statusno"online"Bot online status: "online", "idle", "dnd", or "invisible"
otp_self_approvalnotrueEnable OTP self-approval for non-allowlisted DM users
otp_cooldown_secsno300Cooldown in seconds after 3 failed OTP attempts

Full Example

[channels]
offered = ["telegram", "discord"]

[channels.discord.my-bot]
token = "MTIzNDU2Nzg5.example.bot-token"
dm_policy = "allowlist"
group_policy = "open"
mention_mode = "mention"
allowlist = ["alice", "bob"]
guild_allowlist = ["123456789012345678"]
reply_to_message = true
ack_reaction = "👀"
model = "gpt-4o"
model_provider = "openai"
activity = "with AI"
activity_type = "custom"
status = "online"
otp_self_approval = true

Access Control

Discord uses the same gating system as Telegram and Microsoft Teams:

DM Policy

Controls who can send direct messages to the bot.

ValueBehavior
"allowlist"Only users listed in allowlist can DM (default)
"open"Anyone who can reach the bot can DM it
"disabled"DMs are silently ignored

Group Policy

Controls who can interact with the bot in guild (server) channels.

ValueBehavior
"open"Bot responds in any guild channel (default)
"allowlist"Only guilds listed in guild_allowlist are allowed
"disabled"Guild messages are silently ignored

Mention Mode

Controls when the bot responds in guild channels (does not apply to DMs).

ValueBehavior
"mention"Bot only responds when @mentioned (default)
"always"Bot responds to every message in allowed channels
"none"Bot never responds in guilds (useful for DM-only bots)

Guild Allowlist

If guild_allowlist is non-empty, messages from guilds not in the list are silently dropped — regardless of group_policy. This provides a server-level filter on top of the channel-level policy.

OTP Self-Approval

When dm_policy = "allowlist" and otp_self_approval = true (the default), unknown users who DM the bot receive a verification challenge. The flow:

  1. User sends a DM to the bot
  2. Bot responds with a challenge prompt (the 6-digit code is not shown to the user)
  3. The code appears in the Moltis web UI under Channels → Senders
  4. The bot owner shares the code with the user out-of-band
  5. User replies with the 6-digit code
  6. On success, the user is automatically added to the allowlist

After 3 failed attempts, the user is locked out for otp_cooldown_secs seconds (default: 300). Codes expire after 5 minutes.

Tip

This is the same OTP mechanism used by the Telegram integration. It provides a simple access control flow without requiring manual allowlist management.

Bot Presence

Configure the bot’s Discord presence (the “Playing…” / “Listening to…” status) using the activity, activity_type, and status fields:

[channels.discord.my-bot]
token = "..."
activity = "with AI"
activity_type = "custom"  # or "playing", "listening", "watching", "competing"
status = "online"         # or "idle", "dnd", "invisible"

The presence is set when the bot connects to the Discord gateway. If no activity or status is configured, the bot uses Discord’s default (online, no activity).

Slash Commands

The bot automatically registers native Discord slash commands when it connects:

CommandDescription
/newStart a new chat session
/clearClear the current session history
/compactSummarize the current session
/contextShow session info (model, tokens, plugins)
/modelList or switch the AI model
/sessionsList or switch chat sessions
/agentList or switch agents
/helpShow available commands

Slash commands appear in Discord’s command palette (type / in any channel where the bot is present). Responses are ephemeral — only visible to the user who invoked the command.

Note

Text-based / commands (e.g. typing /model as a regular message) continue to work alongside native slash commands. The native commands provide autocomplete and a better Discord-native experience.

Web UI Setup

You can also configure Discord through the web interface:

  1. Open Settings → Channels
  2. Click Connect Discord
  3. Enter an account ID (any alias) and your bot token
  4. Adjust DM policy, mention mode, and allowlist as needed
  5. Click Connect

The same form is available during onboarding when Discord is in channels.offered.

Talking to Your Bot

Once the bot is connected there are several ways to interact with it.

In a Server

To use the bot in a Discord server you need to invite it first:

  1. Go to the Discord Developer Portal
  2. Select your application → OAuth2 → URL Generator
  3. Scopes: check bot
  4. Bot Permissions: check Send Messages, Read Message History, and Add Reactions
  5. Copy the generated URL and open it in your browser
  6. Select the server you want to add the bot to and confirm

Tip

The Moltis web UI generates this invite link automatically when you paste your bot token. Look for the “Invite bot to a server” card in the Connect Discord dialog.

Once the bot is in your server, @mention it in any channel to get a response (assuming mention_mode = "mention", the default). If you set mention_mode = "always" the bot responds to every message in allowed channels.

Via Direct Message

You can DM the bot directly from Discord — no shared server required:

  1. Open Discord and go to Direct Messages
  2. Click the New Message icon (or Find or start a conversation)
  3. Search for the bot’s username and select it
  4. Send a message

Note

If dm_policy is set to "allowlist" (the default), make sure your Discord username is listed in the allowlist array — otherwise the bot will ignore your DMs. Set dm_policy = "open" to allow anyone to DM the bot.

Without a Shared Server

DMs work even if you and the bot don’t share a server. Discord bots are reachable by username from any account. This makes DMs the simplest way to start chatting — just connect the bot in Moltis and message it directly.

Message Handling

Inbound Messages

When a message arrives from Discord:

  1. Bot’s own messages are ignored
  2. Guild allowlist is checked (if configured)
  3. DM/group policy is evaluated
  4. Mention mode is checked (guild messages only)
  5. Bot mention prefix (@BotName) is stripped from the message text
  6. The message is logged and dispatched to the chat engine
  7. Commands (messages starting with /) are dispatched to the command handler

Outbound Messages

Discord enforces a 2,000-character limit per message. Moltis automatically splits long responses into multiple messages, preferring to break at newline boundaries and avoiding splits inside fenced code blocks.

Streaming uses edit-in-place — an initial message is sent after 30 characters and then updated every 500ms as tokens arrive. If the final text exceeds 2,000 characters, the first message is edited to the limit and overflow is sent as follow-up messages.

Reply-to-Message

Set reply_to_message = true to have the bot send responses as Discord replies (threaded to the user’s original message). The first chunk of a multi-chunk or streamed response carries the reply reference; follow-up chunks are sent as regular messages.

Ack Reactions

Set ack_reaction = "👀" (or any Unicode emoji) to have the bot react to the user’s message when processing starts. The reaction is removed once the response is complete. This provides a visual indicator that the bot has seen the message and is working on a reply.

Crate Structure

crates/discord/
├── Cargo.toml
└── src/
    ├── lib.rs         # Public exports
    ├── commands.rs    # Native Discord slash command registration
    ├── config.rs      # DiscordAccountConfig (token, policies, presence, OTP)
    ├── error.rs       # Error enum (Config, Gateway, Send, Channel)
    ├── handler.rs     # serenity EventHandler (inbound + OTP + interactions)
    ├── outbound.rs    # ChannelOutbound + ChannelStreamOutbound impls
    ├── plugin.rs      # ChannelPlugin + ChannelStatus impls
    └── state.rs       # AccountState + AccountStateMap (includes OtpState)

The crate implements the same trait set as moltis-telegram and moltis-msteams:

TraitPurpose
ChannelPluginStart/stop accounts, lifecycle management
ChannelOutboundSend text, media, typing indicators
ChannelStreamOutboundHandle streaming responses
ChannelStatusHealth probes (connected / disconnected)

Troubleshooting

Bot doesn’t respond

  • Verify Message Content Intent is enabled in the Developer Portal
  • Check that the bot token is correct (reset it if unsure)
  • Ensure the bot has been invited to the server with the right permissions
  • Check dm_policy / group_policy — if set to "allowlist", make sure your username or guild ID is listed
  • Look at logs: RUST_LOG=moltis_discord=debug moltis

“Gateway connection failed”

  • Check your network connection — the bot connects outward to wss://gateway.discord.gg
  • Firewalls or proxies that block outbound WebSocket connections will prevent the bot from connecting
  • The token may have been revoked — regenerate it in the Developer Portal

Bot responds in DMs but not in guilds

  • Check mention_mode — if set to "mention", you must @mention the bot
  • Check group_policy — if "disabled", guild messages are ignored
  • Check guild_allowlist — if non-empty, the guild must be listed

Slack

Moltis can connect to Slack as a bot, letting you chat with your agent from any Slack workspace. The integration supports both Socket Mode (default, no public URL needed) and Events API (webhook-based).

How It Works

┌──────────────────────────────────────────────────────┐
│                    Slack API                          │
│            (Socket Mode / Events API)                │
└──────────────────┬───────────────────────────────────┘
                   │  WebSocket (Socket Mode)
                   │  or HTTP POST (Events API)
                   ▼
┌──────────────────────────────────────────────────────┐
│                moltis-slack crate                     │
│  ┌────────────┐  ┌────────────┐  ┌────────────────┐  │
│  │  Handler   │  │  Outbound  │  │     Plugin     │  │
│  │ (inbound)  │  │ (replies)  │  │  (lifecycle)   │  │
│  └────────────┘  └────────────┘  └────────────────┘  │
└──────────────────┬───────────────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────────────┐
│                 Moltis Gateway                        │
│         (chat dispatch, tools, memory)                │
└──────────────────────────────────────────────────────┘

With Socket Mode (the default), the bot opens an outbound WebSocket connection to Slack — no public URL, port forwarding, or TLS certificate is needed. With Events API mode, Slack sends HTTP POST requests to your server, requiring a publicly reachable endpoint.

Prerequisites

Before configuring Moltis, create a Slack app:

  1. Go to api.slack.com/apps and click Create New App
  2. Choose From scratch, name the app, and select your workspace
  3. Navigate to OAuth & Permissions and add these Bot Token Scopes:
    • app_mentions:read — read @mentions
    • chat:write — send messages
    • im:history — read DM history
    • im:read — view DM metadata
    • channels:history — read channel messages (for mention_mode = "always")
  4. Click Install to Workspace and copy the Bot User OAuth Token (xoxb-...)
  5. For Socket Mode (recommended):
    • Go to Socket Mode and enable it
    • Generate an App-Level Token (xapp-...) with the connections:write scope
  6. For Events API mode:
    • Go to Event Subscriptions and enable it
    • Set the Request URL to your Moltis instance endpoint
    • Copy the Signing Secret from Basic Information
  7. Under Event Subscriptions > Subscribe to bot events, add:
    • app_mention — when someone @mentions the bot
    • message.im — direct messages to the bot

Warning

The bot token and app token are secrets — treat them like passwords. Never commit them to version control. Moltis stores them with secrecy::Secret and redacts them from logs.

Configuration

Add a [channels.slack.<account-id>] section to your moltis.toml:

[channels.slack.my-bot]
bot_token = "xoxb-your-bot-token"
app_token = "xapp-your-app-token"

Make sure "slack" is included in channels.offered:

[channels]
offered = ["slack"]

Configuration Fields

FieldRequiredDefaultDescription
bot_tokenyesBot user OAuth token (xoxb-...)
app_tokenyes*App-level token for Socket Mode (xapp-...). *Required for socket_mode.
connection_modeno"socket_mode"Connection method: "socket_mode" or "events_api"
signing_secretno*Signing secret for Events API request verification. *Required for events_api.
dm_policyno"allowlist"Who can DM the bot: "open", "allowlist", or "disabled"
group_policyno"open"Who can talk to the bot in channels: "open", "allowlist", or "disabled"
mention_modeno"mention"When the bot responds in channels: "always", "mention", or "none"
allowlistno[]Slack user IDs allowed to DM the bot (when dm_policy = "allowlist")
channel_allowlistno[]Slack channel IDs allowed to interact with the bot
modelnoOverride the default model for this channel
model_providernoProvider for the overridden model
stream_modeno"edit_in_place"Streaming mode: "edit_in_place", "native", or "off"
edit_throttle_msno500Minimum milliseconds between streaming edit updates
thread_repliesnotrueReply in threads

Allowlist values are strings

All allowlist entries must be strings. Use Slack user IDs like ["U0123456789"]. Matching is case-insensitive and supports glob wildcards.

Full Example

[channels]
offered = ["slack"]

[channels.slack.my-bot]
bot_token = "xoxb-..."
app_token = "xapp-..."
connection_mode = "socket_mode"
dm_policy = "allowlist"
group_policy = "open"
mention_mode = "mention"
allowlist = ["U0123456789", "U9876543210"]
channel_allowlist = ["C0123456789"]
model = "claude-sonnet-4-20250514"
model_provider = "anthropic"
stream_mode = "edit_in_place"
edit_throttle_ms = 500
thread_replies = true

Events API Mode

If you prefer webhook-based delivery instead of Socket Mode:

[channels.slack.my-bot]
bot_token = "xoxb-..."
connection_mode = "events_api"
signing_secret = "abc123..."

This requires your Moltis instance to be reachable from the internet (or use Tailscale Funnel).

Access Control

Slack uses the same gating system as Telegram, Discord, and other channels.

DM Policy

ValueBehavior
"allowlist"Only users listed in allowlist can DM (default)
"open"Anyone in the workspace can DM the bot
"disabled"DMs are silently ignored

Group Policy

ValueBehavior
"open"Bot responds in any channel it’s invited to (default)
"allowlist"Only channels listed in channel_allowlist are allowed
"disabled"Channel messages are silently ignored

Mention Mode

ValueBehavior
"mention"Bot only responds when @mentioned (default)
"always"Bot responds to every message in allowed channels
"none"Bot never responds in channels (useful for DM-only bots)

Allowlist Matching

Allowlist entries support:

  • Exact match (case-insensitive): "U0123456789"
  • Glob wildcards: "U012*", "*admin*"

Streaming

Slack supports three streaming modes:

ModeBehavior
"edit_in_place"Sends a placeholder message and edits it as tokens arrive (default)
"native"Uses Slack’s streaming API (chat.startStream/chat.appendStream/chat.stopStream)
"off"No streaming — sends the full response as a single message

The edit_in_place mode throttles updates to edit_throttle_ms milliseconds (default: 500) to avoid Slack API rate limits.

Thread Replies

By default (thread_replies = true), the bot replies in a thread attached to the user’s message. Set thread_replies = false to have the bot reply directly in the channel.

Troubleshooting

Bot doesn’t respond

  • Verify the bot and app tokens are correct
  • Check that Socket Mode is enabled in the Slack app settings
  • Check dm_policy — if set to "allowlist", make sure your Slack user ID is in allowlist
  • Ensure the bot has been invited to channels you want it to respond in
  • Look at logs: RUST_LOG=moltis_slack=debug moltis

Bot doesn’t respond in channels

  • Check mention_mode — if "mention", you must @mention the bot
  • Check group_policy — if "disabled", channel messages are ignored
  • Check channel_allowlist — if non-empty, the channel must be listed
  • Ensure the bot is a member of the channel (invite it with /invite @botname)

WhatsApp Channel

Moltis supports WhatsApp as a messaging channel using the WhatsApp Linked Devices protocol. Your WhatsApp account connects as a linked device (like WhatsApp Web), so no separate phone number or WhatsApp Business API is needed — you pair your existing personal or business WhatsApp by scanning a QR code.

How It Works

┌────────────────┐   QR pair   ┌─────────────────┐   Signal   ┌──────────────┐
│  Your Phone    │ ──────────► │  Moltis Gateway  │ ◄────────► │  WhatsApp    │
│  (WhatsApp)    │             │  (linked device)  │            │  Servers     │
└────────────────┘             └─────────────────┘            └──────────────┘
                                       │
                                       ▼
                               ┌─────────────────┐
                               │  LLM Provider    │
                               │  (Claude, GPT…)  │
                               └─────────────────┘
  1. Moltis registers as a linked device on your WhatsApp account
  2. Messages sent to your WhatsApp number arrive at both your phone and Moltis
  3. Moltis processes inbound messages through the configured LLM
  4. The LLM reply is sent back through your WhatsApp account

Dedicated vs Personal Number

Dedicated number (recommended): Use a separate phone with its own WhatsApp account. All messages to that number go to the bot. Clean separation, no accidental replies to personal contacts.

Personal number (self-chat): Use your own WhatsApp account and message yourself via WhatsApp’s “Message Yourself” feature. Moltis automatically detects self-chat and prevents reply loops. Convenient for personal use. Note that Moltis (as a linked device) sees all your incoming messages — whether it responds is governed by access control (see below).

Feature Flag

WhatsApp is behind the whatsapp cargo feature, enabled by default:

# crates/cli/Cargo.toml
[features]
default = ["whatsapp", ...]
whatsapp = ["moltis-gateway/whatsapp"]

When disabled, all WhatsApp code is compiled out — no QR code library, no Signal Protocol store, no WhatsApp event handlers.

Enable in Channel List

WhatsApp is not shown in the web UI by default. Add it to the offered channels list in moltis.toml:

```toml [channels] offered = [“telegram”, “discord”, “slack”, “whatsapp”] ```

Restart Moltis after changing this setting. The + Add Channel menu will then include the WhatsApp option.

Quick Start (Web UI)

The fastest way to connect WhatsApp:

  1. Start Moltis: moltis serve
  2. Open the web UI and navigate to Settings > Channels
  3. Click + Add Channel > WhatsApp
  4. Enter an Account ID (any name you like, e.g. my-whatsapp)
  5. Choose a DM Policy (Open, Allowlist, or Disabled)
  6. Optionally select a default Model
  7. Click Start Pairing — a QR code appears
  8. On your phone: WhatsApp > Settings > Linked Devices > Link a Device
  9. Scan the QR code
  10. The modal shows “Connected” with your phone’s display name

That’s it — messages to your WhatsApp account are now processed by Moltis.

Tip

The QR code refreshes automatically every ~20 seconds. If it expires before you scan it, a new one appears without any action needed.

Quick Start (Config File)

You can also configure WhatsApp accounts in moltis.toml. This is useful for automated deployments or when you want to pre-configure settings before pairing.

# ~/.moltis/moltis.toml

[channels.whatsapp."my-whatsapp"]
dm_policy = "open"
model = "anthropic/claude-sonnet-4-20250514"
model_provider = "anthropic"

Start Moltis and the account will begin the pairing process. The QR code is printed to the terminal and also available via the web UI. Once paired, the config file is updated with:

[channels.whatsapp."my-whatsapp"]
paired = true
display_name = "John's iPhone"
phone_number = "+15551234567"
dm_policy = "open"
model = "anthropic/claude-sonnet-4-20250514"
model_provider = "anthropic"

Configuration Reference

Each WhatsApp account is a named entry under [channels.whatsapp]:

[channels.whatsapp."<account-id>"]
FieldTypeDefaultDescription
pairedboolfalseWhether QR code pairing is complete (auto-set)
display_namestringPhone name after pairing (auto-populated)
phone_numberstringPhone number after pairing (auto-populated)
store_pathstringCustom path to sled store; defaults to ~/.moltis/whatsapp/<account_id>/
modelstringDefault LLM model ID for this account
model_providerstringProvider name for the model
dm_policystring"open"DM access policy: "open", "allowlist", or "disabled"
group_policystring"open"Group access policy: "open", "allowlist", or "disabled"
allowlistarray[]Users allowed to DM (usernames or phone numbers)
group_allowlistarray[]Group JIDs allowed for bot responses
otp_self_approvalbooltrueAllow non-allowlisted users to self-approve via OTP
otp_cooldown_secsint300Cooldown seconds after 3 failed OTP attempts

Full Example

[channels.whatsapp."personal"]
paired = true
display_name = "John's iPhone"
phone_number = "+15551234567"
model = "anthropic/claude-sonnet-4-20250514"
model_provider = "anthropic"
dm_policy = "allowlist"
allowlist = ["alice", "bob", "+15559876543"]
group_policy = "disabled"
otp_self_approval = true
otp_cooldown_secs = 300

[channels.whatsapp."work-bot"]
paired = true
dm_policy = "open"
group_policy = "allowlist"
group_allowlist = ["120363456789@g.us"]
model = "openai/gpt-4.1"
model_provider = "openai"

Access Control

WhatsApp uses the same access control model as Telegram channels.

DM Policies

PolicyBehavior
openAnyone who messages your WhatsApp can chat with the bot
allowlistOnly users on the allowlist get responses; others get an OTP challenge
disabledAll DMs are silently ignored

Group Policies

PolicyBehavior
openBot responds in all groups it’s part of
allowlistBot only responds in groups on the group_allowlist
disabledBot ignores all group messages

OTP Self-Approval

When dm_policy = "allowlist" and otp_self_approval = true (the default), users not on the allowlist can request access:

  1. User sends any message to the bot
  2. Bot replies: “Please reply with the 6-digit code to verify access”
  3. The OTP code appears in the Senders tab of the web UI
  4. User replies with the code
  5. If correct, user is permanently added to the allowlist

After 3 wrong attempts, a cooldown period kicks in (default 5 minutes). You can also approve or deny users directly from the Senders tab without waiting for OTP verification.

Tip

Set otp_self_approval = false if you want to manually approve every new user from the web UI instead of letting them self-approve.

Using Your Personal Number Safely

When Moltis is linked to your personal WhatsApp, it sees every incoming message — from friends, family, groups, everyone. The key question is: who does the bot respond to?

Self-chat always works. Messages you send to yourself (via “Message Yourself”) bypass access control entirely. You are the account owner, so you’re always authorized regardless of dm_policy settings.

Other people’s messages follow dm_policy. If you want Moltis to only respond to your self-chat and ignore everyone else:

[channels.whatsapp."personal"]
dm_policy = "disabled"    # Ignore all DMs from other people
group_policy = "disabled" # Ignore all group messages

This is the safest configuration for personal use — the bot only responds when you message yourself.

If you want to selectively allow certain contacts:

[channels.whatsapp."personal"]
dm_policy = "allowlist"
allowlist = ["alice", "bob"]  # Only these people get bot responses
group_policy = "disabled"

Default is Open

The default dm_policy is "open", which means everyone who messages your WhatsApp will get a bot response. If you’re using your personal number, change this to "disabled" or "allowlist" before pairing.

Session Persistence

WhatsApp uses the Signal Protocol for end-to-end encryption. The encryption keys and session state are stored in a sled database at:

~/.moltis/whatsapp/<account_id>/

This means:

  • No re-pairing after restart — the linked device session survives process restarts, server reboots, and upgrades
  • One store per account — multiple WhatsApp accounts each get their own isolated database
  • Custom path — set store_path in config to use a different location (useful for Docker volumes or shared storage)

Warning

Do not delete the sled store directory while Moltis is running. If you need to re-pair, stop Moltis first, then delete the directory and restart.

Self-Chat

Moltis automatically supports WhatsApp’s “Message Yourself” feature. When you send a message to yourself, the bot processes it as a regular inbound message and replies in the same chat.

This is useful for:

  • Personal assistant — chat with your AI without a dedicated phone number
  • Testing — verify the bot works before sharing with others
  • Quick notes — send yourself reminders that the AI processes

Loop Prevention

When the bot replies to your self-chat, WhatsApp delivers that reply back as an incoming message (since it’s your own chat). Moltis uses two mechanisms to prevent infinite reply loops:

  1. Message ID tracking: Every message the bot sends is recorded in a bounded ring buffer (256 entries). Incoming is_from_me messages whose ID matches a tracked send are recognized as bot echoes and skipped.

  2. Invisible watermark: An invisible Unicode sequence (zero-width joiners) is appended to every bot-sent text message. If an incoming message contains this watermark, it’s recognized as a bot echo even if the message ID wasn’t tracked (e.g. after a restart).

Both checks are automatic — no configuration needed.

Media Handling

WhatsApp supports rich media messages. Moltis handles each type:

Message TypeHandling
TextDispatched directly to the LLM
ImageDownloaded, optimized for LLM consumption (resized if needed), sent as attachment
VoiceDownloaded and transcribed via STT (if configured); falls back to text guidance
AudioSame as voice, but classified separately (non-PTT audio files)
VideoThumbnail extracted and sent as image attachment with caption
DocumentCaption and filename/MIME metadata dispatched as text
LocationResolves pending location tool requests, or dispatches coordinates to LLM

Voice Transcription

Voice message transcription requires an STT provider to be configured. See Voice Services for setup instructions. Without STT, the bot replies asking the user to send a text message instead.

Managing Channels in the Web UI

Channels Tab

The Channels page shows all connected accounts across all channel types (Telegram, WhatsApp). Each card displays:

  • Status badge: connected, pairing, or disconnected
  • Display name: Phone name from WhatsApp (after pairing)
  • Sender summary: List of recent senders with message counts
  • Edit / Remove buttons

Adding a WhatsApp Channel

  1. Click + Add Channel > WhatsApp
  2. Fill in the account ID, DM policy, and optional model
  3. Click Start Pairing
  4. Scan the QR code on your phone
  5. Wait for the “Connected” confirmation

Editing a Channel

Click Edit on a channel card to modify:

  • DM and group policies
  • Allowlist entries
  • Default model

Changes take effect immediately — no restart needed.

Senders Tab

Switch to the Senders tab to see everyone who has messaged the bot:

  • Filter by account using the dropdown
  • See message counts, last activity, and access status
  • Approve or Deny users directly
  • View pending OTP challenges with the code displayed

Troubleshooting

WhatsApp Not in Add Channel Menu

  • WhatsApp is not offered by default. Add "whatsapp" to the offered list in moltis.toml:
    [channels]
    offered = ["telegram", "discord", "slack", "whatsapp"]
    
  • Restart Moltis after changing this setting

“Can’t Understand That Message Type”

  • This means the bot received a message type it doesn’t handle (e.g. stickers, reactions, polls)
  • Check the server logs for an info entry that lists which message fields were present
  • Supported types: text, images, audio, voice notes, video, documents, and locations

QR Code Not Appearing

  • Ensure the whatsapp feature is enabled (it is by default)
  • Check terminal output for errors — the QR code is also printed to stdout
  • Verify the sled store directory is writable: ~/.moltis/whatsapp/

“Logged Out” After Restart

  • This usually means the sled store was corrupted or deleted
  • Check that ~/.moltis/whatsapp/<account_id>/ exists and has data files
  • Re-pair by removing the directory and restarting: the pairing flow starts again

Bot Not Responding to Messages

  • Check dm_policy — if set to allowlist, only listed users get responses
  • Check group_policy — if set to disabled, group messages are ignored
  • Look at the Senders tab to see if the user is denied or pending OTP
  • Check terminal logs for access control decisions

Self-Chat Not Working

  • WhatsApp’s “Message Yourself” chat must be used (not a group with only yourself)
  • The bot needs to be connected and the account paired
  • If you just restarted, the watermark-based detection handles messages that arrive before the message ID buffer is rebuilt

Code Structure

crates/whatsapp/
├── src/
│   ├── lib.rs           # Crate entry, WhatsAppPlugin
│   ├── config.rs        # WhatsAppAccountConfig
│   ├── connection.rs    # Bot startup, sled store, event loop
│   ├── handlers.rs      # Event routing, message handling, media
│   ├── outbound.rs      # WhatsAppOutbound (ChannelOutbound impl)
│   ├── state.rs         # AccountState, loop detection, watermark
│   ├── access.rs        # DM/group access control
│   ├── otp.rs           # OTP challenge/verification
│   ├── plugin.rs        # ChannelPlugin trait impl
│   ├── sled_store.rs    # Persistent Signal Protocol store
│   └── memory_store.rs  # In-memory store (tests/fallback)

Browser Automation

Moltis provides full browser automation via Chrome DevTools Protocol (CDP), enabling agents to interact with JavaScript-heavy websites, fill forms, click buttons, and capture screenshots.

Overview

Browser automation is useful when you need to:

  • Interact with SPAs (Single Page Applications)
  • Fill forms and click buttons
  • Navigate sites that require JavaScript rendering
  • Take screenshots of pages
  • Execute JavaScript in page context
  • Maintain session state across multiple interactions

For simple page content retrieval (static HTML), prefer web_fetch as it’s faster and more lightweight.

Architecture

┌─────────────────┐     ┌─────────────────┐     ┌──────────────────┐
│   BrowserTool   │────▶│  BrowserManager │────▶│   BrowserPool    │
│   (AgentTool)   │     │   (actions)     │     │   (instances)    │
└─────────────────┘     └─────────────────┘     └──────────────────┘
                                                         │
                                                         ▼
                                                ┌──────────────────┐
                                                │  Chrome/Chromium │
                                                │     via CDP      │
                                                └──────────────────┘

Components

  • BrowserTool (crates/tools/src/browser.rs) - AgentTool wrapper for LLM
  • BrowserManager (crates/browser/src/manager.rs) - High-level action API
  • BrowserPool (crates/browser/src/pool.rs) - Chrome instance management
  • Snapshot (crates/browser/src/snapshot.rs) - DOM element extraction

Configuration

Browser automation is enabled by default. To customize, add to your moltis.toml:

[tools.browser]
enabled = true              # Enable browser support
headless = true             # Run without visible window (default)
viewport_width = 2560       # Default viewport width
viewport_height = 1440      # Default viewport height
device_scale_factor = 2.0   # HiDPI/Retina scaling (1.0 = standard, 2.0 = Retina)

# Pool management
max_instances = 0           # 0 = unlimited (limited by memory), >0 = hard limit
memory_limit_percent = 90   # Block new instances when memory exceeds this %
idle_timeout_secs = 300     # Close idle browsers after 5 min
navigation_timeout_ms = 30000  # Page load timeout

# Optional customization
# chrome_path = "/path/to/chrome"  # Custom Chrome path
# user_agent = "Custom UA"         # Custom user agent
# chrome_args = ["--disable-extensions"]  # Extra args

# Sandbox image (browser sandbox mode follows session sandbox mode)
sandbox_image = "browserless/chrome"  # Container image for sandboxed sessions
# allowed_domains = ["example.com", "*.trusted.org"]  # Restrict navigation

# Container connectivity (for Moltis-in-Docker setups)
# container_host = "127.0.0.1"  # Default; change when Moltis runs inside Docker

Memory-Based Pool Limits

By default, browser instances are limited by system memory rather than a fixed count:

  • max_instances = 0 (default): Unlimited instances, blocked only when memory exceeds memory_limit_percent
  • memory_limit_percent = 90: New browsers blocked when system memory > 90%
  • Set max_instances > 0 for a hard limit if you prefer fixed constraints

This allows multiple chat sessions to each have their own browser without artificial limits, while protecting system stability when memory is constrained.

Domain Restrictions

For improved security, you can restrict which domains the browser can navigate to:

[tools.browser]
allowed_domains = [
    "docs.example.com",    # Exact match
    "*.github.com",        # Wildcard: matches any subdomain
    "localhost",           # Allow localhost
]

When allowed_domains is set, any navigation to a domain not in the list will be blocked with an error. Wildcards (*.domain.com) match any subdomain and also the base domain itself.

Tool Usage

Actions

ActionDescriptionRequired Params
navigateGo to a URLurl
snapshotGet DOM with element refs-
screenshotCapture page imagefull_page (optional)
clickClick element by refref_
typeType into elementref_, text
scrollScroll page/elementx, y, ref_ (optional)
evaluateRun JavaScriptcode
waitWait for elementselector or ref_
get_urlGet current URL-
get_titleGet page title-
backGo back in history-
forwardGo forward in history-
refreshReload the page-
closeClose browser session-

Automatic Session Tracking

The browser tool automatically tracks and reuses session IDs. After a navigate action creates a session, subsequent actions will reuse it without needing to pass session_id explicitly:

// 1. Navigate (creates session)
{ "action": "navigate", "url": "https://example.com" }

// 2. Snapshot (session_id auto-injected)
{ "action": "snapshot" }

// 3. Click (session_id auto-injected)
{ "action": "click", "ref_": 1 }

// 4. Screenshot (session_id auto-injected)
{ "action": "screenshot" }

// 5. Close (clears tracked session)
{ "action": "close" }

This prevents pool exhaustion from LLMs that forget to pass the session_id.

Browser selection

You can ask for a specific browser at runtime (host mode):

{ "action": "navigate", "url": "https://example.com", "browser": "brave" }

Supported values: auto, chrome, chromium, edge, brave, opera, vivaldi, arc.

auto (default) picks the first detected installed browser. If none are installed, Moltis will attempt a best-effort auto-install, then retry detection.

Workflow Example

// 1. Navigate to a page
{
  "action": "navigate",
  "url": "https://example.com/login"
}
// Returns: { "session_id": "browser-abc123", "url": "https://..." }

// 2. Get interactive elements (session_id optional - auto-tracked)
{
  "action": "snapshot"
}
// Returns element refs like:
// { "elements": [
//   { "ref_": 1, "tag": "input", "role": "textbox", "placeholder": "Email" },
//   { "ref_": 2, "tag": "input", "role": "textbox", "placeholder": "Password" },
//   { "ref_": 3, "tag": "button", "role": "button", "text": "Sign In" }
// ]}

// 3. Fill in the form
{
  "action": "type",
  "ref_": 1,
  "text": "user@example.com"
}

{
  "action": "type",
  "ref_": 2,
  "text": "password123"
}

// 4. Click the submit button
{
  "action": "click",
  "ref_": 3
}

// 5. Take a screenshot of the result
{
  "action": "screenshot"
}
// Returns: { "screenshot": "data:image/png;base64,..." }

Element Reference System

The snapshot action extracts interactive elements and assigns them numeric references. This approach (inspired by OpenClaw) provides:

  • Stability: References don’t break with minor page updates
  • Security: No CSS selectors exposed to the model
  • Reliability: Elements identified by role/content, not fragile paths

Extracted Element Info

{
  "ref_": 1,
  "tag": "button",
  "role": "button",
  "text": "Submit",
  "href": null,
  "placeholder": null,
  "value": null,
  "aria_label": "Submit form",
  "visible": true,
  "interactive": true,
  "bounds": { "x": 100, "y": 200, "width": 80, "height": 40 }
}

Comparison: Browser vs Web Fetch

Featureweb_fetchbrowser
SpeedFastSlower
ResourcesMinimalChrome instance
JavaScriptNoYes
Forms/clicksNoYes
ScreenshotsNoYes
SessionsNoYes
Use caseStatic contentInteractive sites

When to use web_fetch:

  • Reading documentation
  • Fetching API responses
  • Scraping static HTML

When to use browser:

  • Logging into websites
  • Filling forms
  • Interacting with SPAs
  • Sites that require JavaScript
  • Taking screenshots

Metrics

When the metrics feature is enabled, the browser module records:

MetricDescription
moltis_browser_instances_activeCurrently running browsers
moltis_browser_instances_created_totalTotal browsers launched
moltis_browser_instances_destroyed_totalTotal browsers closed
moltis_browser_screenshots_totalScreenshots taken
moltis_browser_navigation_duration_secondsPage load time histogram
moltis_browser_errors_totalErrors by type

Sandbox Mode

Browser sandbox mode automatically follows the session’s sandbox mode. When a chat session uses sandbox mode (controlled by [tools.exec.sandbox]), the browser tool will also run in a sandboxed container. When the session is not sandboxed, the browser runs directly on the host.

Host Mode

When the session is not sandboxed (or sandbox mode is “off”), Chrome runs directly on the host machine. This is faster but the browser has full access to the host network and filesystem.

Sandbox Mode

When the session is sandboxed, Chrome runs inside a Docker container with:

  • Network isolation: Browser can access the internet but not local services
  • Filesystem isolation: No access to host filesystem
  • Automatic lifecycle: Container started/stopped with browser session
  • Readiness detection: Waits for Chrome to be fully ready before connecting
[tools.browser]
sandbox_image = "browserless/chrome"  # Container image for sandboxed sessions

Requirements:

  • Docker or Apple Container must be installed and running
  • The container image is pulled automatically on first use
  • Session sandbox mode must be enabled ([tools.exec.sandbox] mode = "all")

Moltis Inside Docker (Sibling Containers)

When Moltis itself runs inside a Docker container, the browser container is launched as a sibling via the host’s Docker socket. By default Moltis connects to the browser at 127.0.0.1, which points to the Moltis container’s own loopback — not the host where the browser port is mapped.

Set container_host so Moltis can reach the browser container through the host’s port mapping:

[tools.browser]
container_host = "host.docker.internal"   # macOS / Windows Docker Desktop
# container_host = "172.17.0.1"           # Linux Docker bridge gateway IP

On Linux, host.docker.internal is not available by default. Use the Docker bridge gateway IP (typically 172.17.0.1) or add --add-host=host.docker.internal:host-gateway to the Moltis container’s docker run command.

Exec Tool Scripts

If agents need to run browser automation scripts (Puppeteer, Playwright, Selenium) inside the command sandbox, Chromium is included in the default sandbox packages:

# Inside sandbox (via exec tool)
chromium --headless --no-sandbox --dump-dom https://example.com

Or use Puppeteer/Playwright in a Node.js script executed via the exec tool.

Security Considerations

Prompt Injection Risk

Important: Web pages can contain content designed to manipulate LLM behavior (prompt injection). When the browser tool returns page content to the LLM, malicious sites could attempt to inject instructions.

Mitigations:

  1. Domain restrictions: Use allowed_domains to limit navigation to trusted sites only. This is the most effective mitigation.

  2. Review returned content: The snapshot action returns element text which could contain injected prompts. Be cautious with untrusted sites.

  3. Sandbox mode: Use sandboxed sessions to run the browser in an isolated Docker container for additional security. Browser sandbox follows session sandbox mode automatically.

Other Security Considerations

  1. Host vs Sandbox mode: Browser sandbox mode follows the session’s sandbox mode. For sandboxed sessions, the browser runs in a Docker container with network/filesystem isolation. For non-sandboxed sessions, the browser runs on the host with --no-sandbox for container compatibility.

  2. Resource limits: Browser instances are limited by memory usage (default: block when > 90% used). Set max_instances > 0 for a hard limit.

  3. Idle cleanup: Browsers are automatically closed after idle_timeout_secs of inactivity.

  4. Network access: In host mode, the browser has full network access. In sandbox mode, the browser can reach the internet but not local services. Use firewall rules for additional restrictions.

  5. Sandbox scripts: Browser scripts running in the exec sandbox (Puppeteer, Playwright) inherit sandbox network restrictions (no_network: true by default).

Browser Detection

Moltis automatically detects installed Chromium-based browsers in the following order:

  1. Custom path from chrome_path config
  2. CHROME environment variable
  3. Platform-specific app bundles (macOS/Windows)
    • macOS: /Applications/Google Chrome.app, /Applications/Chromium.app, etc.
    • Windows: C:\Program Files\Google\Chrome\Application\chrome.exe, etc.
  4. PATH executables (fallback): chrome, chromium, msedge, brave, etc.

If no browser is found, Moltis displays platform-specific installation instructions.

Supported Browsers

Any Chromium-based browser works:

  • Google Chrome
  • Chromium
  • Microsoft Edge
  • Brave
  • Opera
  • Vivaldi
  • Arc (macOS)

Screenshot Display

When the browser tool takes a screenshot, it’s displayed in the chat UI:

  • Thumbnail view: Screenshots appear as clickable thumbnails (200×150px max)
  • Fullscreen lightbox: Click to view full-size with dark overlay
  • Scrollable view: Long screenshots can be scrolled within the lightbox
  • Download button: Save screenshot to disk (top of lightbox)
  • Close button: Click ✕ button, click outside, or press Escape to close
  • HiDPI scaling: Screenshots display at correct size on Retina displays

Screenshots are base64-encoded PNGs returned in the tool result. The device_scale_factor config (default: 2.0) controls the rendering resolution for high-DPI displays.

Telegram Integration

When using the Telegram channel, screenshots are automatically sent to the chat:

  • Images sent as photos when dimensions are within Telegram limits
  • Automatically retried as documents for oversized images (aspect ratio > 20)
  • Error messages sent to channel if delivery fails

Handling Model Errors

Some models (particularly Claude via GitHub Copilot) occasionally send malformed tool calls with missing required fields. Moltis handles this gracefully:

  • Default action: If url is provided but action is missing, defaults to navigate
  • Automatic retry: The agent loop retries with corrected arguments
  • Muted error display: Validation errors show as muted/informational cards in the UI (60% opacity, gray text) to indicate they’re expected, not alarming

Troubleshooting

Browser not launching

  • Ensure Chrome/Chromium is installed
  • Check chrome_path in config if using custom location
  • Set CHROME environment variable to specify browser path
  • On Linux, install dependencies: apt-get install chromium-browser
  • On macOS, if using Homebrew Chromium, prefer installing Google Chrome or Brave (the Homebrew chromium wrapper can be unreliable)

Elements not found

  • Use snapshot to see available elements
  • Elements must be visible in the viewport
  • Some elements may need scrolling first

Timeouts

  • Increase navigation_timeout_ms for slow pages
  • Use wait action to wait for dynamic content
  • Check network connectivity

High memory usage

  • Browser instances are now limited by memory (blocks at 90% by default)
  • Set max_instances > 0 for a hard limit if preferred
  • Lower idle_timeout_secs to clean up faster
  • Consider enabling headless mode if not already

Pool exhaustion

  • Browser tool now auto-tracks session IDs, preventing pool exhaustion from LLMs that forget to pass session_id
  • If you still hit limits, check memory_limit_percent threshold
  • Use close action when done to free up sessions

CalDAV (Calendars)

Moltis can read and manage remote calendars through the CalDAV protocol. Once configured, the agent gains a caldav tool that can list calendars, query events, and create, update, or delete entries on your behalf.

The feature is compiled in by default (the caldav cargo feature) and can be disabled at build time with --no-default-features.

Configuration

Add a [caldav] section to your moltis.toml (usually ~/.moltis/moltis.toml):

[caldav]
enabled = true
default_account = "fastmail"

[caldav.accounts.fastmail]
provider = "fastmail"
username = "you@fastmail.com"
password = "app-specific-password"

Multiple accounts

You can define as many accounts as you like. When only one account exists it is used implicitly; otherwise specify default_account or pass account in each tool call.

[caldav]
enabled = true
default_account = "work"

[caldav.accounts.work]
provider = "fastmail"
username = "work@fastmail.com"
password = "app-specific-password"

[caldav.accounts.personal]
provider = "icloud"
username = "you@icloud.com"
password = "app-specific-password"

Supported providers

Providerprovider valueNotes
Fastmail"fastmail"URL auto-discovered (caldav.fastmail.com). Use an app password.
iCloud"icloud"URL auto-discovered (caldav.icloud.com). Requires an app-specific password.
Generic"generic"Any CalDAV server. You must set url.

For generic servers, provide the CalDAV base URL:

[caldav.accounts.nextcloud]
provider = "generic"
url      = "https://cloud.example.com/remote.php/dav"
username = "admin"
password = "secret"

Account fields

FieldRequiredDefaultDescription
providerno"generic"Provider hint ("fastmail", "icloud", "generic")
urldependsCalDAV base URL. Required for generic; optional for Fastmail/iCloud (well-known URL used).
usernameyesAuthentication username
passwordyesPassword or app-specific password
timeout_secondsno30HTTP request timeout

Warning

Store passwords as app-specific passwords, never your main account password. Passwords are stored in moltis.toml and redacted in logs, but the file itself is plain text on disk. Consider using Vault for encryption at rest.

How it works

When Moltis starts and CalDAV is enabled with at least one account, a caldav tool is registered in the agent tool registry. The agent can then call it during conversations to interact with your calendars.

Connections are established lazily on first use and cached for the lifetime of the process. All communication uses HTTPS with system-native TLS roots.

Operations

The agent calls the caldav tool with an operation parameter. Five operations are available:

list_calendars

Lists all calendars available on the account.

Returns: href, display_name, color, description for each calendar.

list_events

Lists events in a specific calendar, optionally filtered by date range.

ParameterRequiredDescription
calendaryesCalendar href (from list_calendars)
startnoISO 8601 start date/time
endnoISO 8601 end date/time

Returns: href, etag, uid, summary, start, end, all_day, location for each event.

create_event

Creates a new calendar event.

ParameterRequiredDescription
calendaryesCalendar href
summaryyesEvent title
startyesISO 8601 start (e.g. 2025-06-15T10:00:00 or 2025-06-15 for all-day)
endnoISO 8601 end date/time
all_daynoBoolean, default false
locationnoEvent location
descriptionnoEvent notes

Returns: href, etag, uid of the created event.

update_event

Updates an existing event. Uses ETag-based optimistic concurrency control to prevent overwriting concurrent changes.

ParameterRequiredDescription
event_hrefyesEvent href (from list_events)
etagyesCurrent ETag (from list_events)
summarynoNew title
startnoNew start
endnoNew end
all_daynoNew all-day flag
locationnoNew location
descriptionnoNew description

Returns: updated href and etag.

delete_event

Deletes an event. Also requires the current ETag.

ParameterRequiredDescription
event_hrefyesEvent href
etagyesCurrent ETag

Concurrency control

Updates and deletes require an etag obtained from list_events. If the event was modified on the server since the ETag was fetched (e.g. edited from a phone), the server rejects the request with a conflict error. This prevents accidental overwrites. The agent should re-fetch the event and retry.

Example conversation

You: What’s on my calendar this week?

The agent calls list_calendars, picks the primary calendar, then calls list_events with start / end spanning the current week.

Agent: You have 3 events this week: …

You: Move the dentist appointment to Friday at 2pm.

The agent calls update_event with the event’s href and etag, setting the new start time.

Disabling CalDAV

Set enabled = false or remove the [caldav] section entirely:

[caldav]
enabled = false

To disable at compile time, build without the feature:

cargo build --release --no-default-features --features lightweight

Validation

moltis config check validates CalDAV configuration and warns about unknown providers. Valid provider values are: fastmail, icloud, generic.

GraphQL API

Moltis exposes a GraphQL API that mirrors gateway RPC methods with typed query and mutation responses where the data shape is known.

Availability

GraphQL is compile-time feature gated:

  • Gateway feature: graphql
  • CLI feature: graphql (enabled in default feature set)

If Moltis is built without this feature, /graphql is not registered.

When built with the feature, GraphQL is runtime-toggleable:

  • Config: [graphql] enabled = true|false
  • UI: Settings > GraphQL toggle

Changes apply immediately, without restart.

Endpoints

MethodPathPurpose
GET/graphqlGraphiQL playground and WebSocket subscriptions
POST/graphqlQueries and mutations

WebSocket subprotocols accepted:

  • graphql-transport-ws
  • graphql-ws

Authentication and Security

GraphQL is protected by the same auth decisions used elsewhere in the gateway. It is not on the public path allowlist.

  • With web-ui builds, GraphQL is behind the global auth_gate middleware.
  • Without web-ui, GraphQL is explicitly guarded by graphql_auth_gate.

When auth is required and the request is unauthenticated, GraphQL returns 401 ({"error":"not authenticated"} or {"error":"setup required"}).

When GraphQL is runtime-disabled, /graphql returns 503 ({"error":"graphql server is disabled"}).

Supported auth methods:

  • Valid session cookie (moltis_session)
  • Authorization: Bearer <api_key>

Warning

Do not expose Moltis to untrusted networks with authentication disabled.

Schema Layout

The schema is organized by namespaces that map to gateway method groups.

Top-level query fields include:

  • health
  • status
  • system, node, chat, sessions, channels
  • config, cron, heartbeat, logs
  • tts, stt, voice
  • skills, models, providers, mcp
  • usage, execApprovals, projects, memory, hooks, agents
  • voicewake, device

Top-level mutation fields follow the same namespace pattern (for example: chat.send, config.set, cron.add, providers.oauthStart, mcp.reauth).

Subscriptions include:

  • chatEvent
  • sessionChanged
  • cronNotification
  • channelEvent
  • nodeEvent
  • tick
  • logEntry
  • mcpStatusChanged
  • approvalEvent
  • configChanged
  • presenceChanged
  • metricsUpdate
  • updateAvailable
  • voiceConfigChanged
  • skillsInstallProgress
  • allEvents

Typed Data and Json Scalar

Most GraphQL return types are concrete structs. The custom Json scalar is still used where runtime shape is intentionally dynamic (for example: arbitrary config values, context payloads, or pass-through node payloads).

Examples

Query (health)

curl -sS http://localhost:13131/graphql \
  -H 'content-type: application/json' \
  -H 'authorization: Bearer mk_your_api_key' \
  -d '{"query":"{ health { ok connections } }"}'

Query with namespace fields

query {
  status {
    hostname
    version
    connections
  }
  cron {
    list {
      id
      name
      enabled
    }
  }
}

Mutation

mutation {
  chat {
    send(message: "Hello from GraphQL") {
      ok
      sessionKey
    }
  }
}

Subscription (graphql-transport-ws)

  1. Connect to ws://localhost:13131/graphql with subprotocol graphql-transport-ws.
  2. Send:
{ "type": "connection_init" }
  1. Start a subscription:
{
  "id": "1",
  "type": "subscribe",
  "payload": {
    "query": "subscription { tick { ts connections } }"
  }
}

GraphiQL in the Web UI

When the binary includes GraphQL, the Settings page includes a GraphQL tab. At the top of that page you can enable/disable GraphQL immediately; when enabled it embeds GraphiQL at /graphql.

Session State

Moltis provides a per-session key-value store that allows skills, extensions, and the agent itself to persist context across messages within a session.

Overview

Session state is scoped to a (session_key, namespace, key) triple, backed by SQLite. Each entry stores a string value and is automatically timestamped.

The agent accesses state through the session_state tool, which supports three operations: get, set, and list.

Agent Tool

The session_state tool is registered as a built-in tool and available in every session.

Get a value

{
  "op": "get",
  "namespace": "my-skill",
  "key": "last_query"
}

Set a value

{
  "op": "set",
  "namespace": "my-skill",
  "key": "last_query",
  "value": "SELECT * FROM users"
}

List all keys in a namespace

{
  "op": "list",
  "namespace": "my-skill"
}

Namespacing

Every state entry belongs to a namespace. This prevents collisions between different skills or extensions using state in the same session. Use your skill name as the namespace.

Storage

State is stored in the session_state table in the main SQLite database (moltis.db). The migration is in crates/sessions/migrations/20260205120000_session_state.sql.

Tip

State values are strings. To store structured data, serialize to JSON before writing and parse after reading.

Session Branching

Session branching (forking) lets you create an independent copy of a conversation at any point. The new session diverges without affecting the original — useful for exploring alternative approaches, running “what if” scenarios, or preserving a checkpoint before a risky prompt.

Forking from the UI

There are two ways to fork a session in the web UI:

  • Chat header — click the Fork button in the header bar (next to Delete). This is visible for every session except cron sessions.
  • Sidebar — hover over a session in the sidebar and click the fork icon that appears in the action buttons.

Both create a new session that copies all messages from the current one and immediately switch you to it.

Forked sessions appear indented under their parent in the sidebar, with a branch icon to distinguish them from top-level sessions. The metadata line shows fork@N where N is the message index at which the fork occurred.

Agent Tool

The agent can also fork programmatically using the branch_session tool:

{
  "at_message": 5,
  "label": "explore-alternative"
}
  • at_message — the message index to fork at (messages 0..N are copied). If omitted, all messages are copied.
  • label — optional human-readable label for the new session.

The tool returns the new session key.

RPC Method

The sessions.fork RPC method is the underlying mechanism:

{ "key": "main", "at_message": 5, "label": "my-fork" }

On success the response payload contains { "sessionKey": "session:<uuid>" }.

What Gets Inherited

When forking, the new session inherits:

InheritedNot inherited
Messages (up to fork point)Worktree branch
Model selectionSandbox settings
Project assignmentChannel binding
MCP disabled flag

Parent-Child Relationships

Fork relationships are stored directly on the sessions table:

  • parent_session_key — the key of the session this was forked from.
  • fork_point — the message index where the fork occurred.

These fields drive the tree rendering in the sidebar. Sessions with a parent appear indented under it; deeply nested forks indent further.

Deleting a parent

Deleting a parent session does not cascade to its children. Child sessions become top-level sessions — they keep their messages and history but lose their visual nesting in the sidebar.

When you delete a forked session, the UI navigates back to its parent session. If the deleted session had no parent (or the parent no longer exists), it falls back to the next sibling or main.

Independence

A forked session is fully independent after creation. Changes to the parent do not propagate to the fork, and vice versa.

Scheduling (Cron Jobs)

Moltis includes a built-in cron system that lets the agent schedule and manage recurring tasks. Jobs can fire agent turns, send system events, or trigger other actions on a flexible schedule.

Heartbeat

The heartbeat is a special system cron job (__heartbeat__) that runs periodically (default: every 30 minutes). It gives the agent an opportunity to check reminders, run deferred tasks, and react to events that occurred while the user was away.

The heartbeat prompt is assembled from HEARTBEAT.md in the workspace data directory. If the file is empty and no pending events exist, the heartbeat turn is skipped to save tokens.

Heartbeat replies can also be delivered to a configured channel destination via [heartbeat] deliver, channel, and to in moltis.toml, or from the web UI under Settings -> Heartbeat.

Event-Driven Heartbeat Wake

Normally the heartbeat fires on its regular schedule. The wake system lets other parts of Moltis trigger an immediate heartbeat when something noteworthy happens, so the agent can react in near real-time.

Wake Mode

Every cron job has a wakeMode field that controls whether it triggers an immediate heartbeat after execution:

ValueBehaviour
"nextHeartbeat" (default)No extra wake — events are picked up on the next scheduled heartbeat
"now"Immediately reschedules the heartbeat to run as soon as possible

Set wakeMode when creating or updating a job through the cron tool:

{
  "action": "add",
  "job": {
    "name": "check-deploy",
    "schedule": { "kind": "every", "every_ms": 300000 },
    "payload": { "kind": "agentTurn", "message": "Check deploy status" },
    "wakeMode": "now"
  }
}

Aliases are accepted: "immediate", "immediately" map to "now"; "next", "default", "next_heartbeat", "next-heartbeat" map to "nextHeartbeat".

System Events Queue

Moltis maintains an in-memory bounded queue of system events — short text summaries of things that happened (command completions, cron triggers, etc.). When the heartbeat fires, any pending events are drained from the queue and prepended to the heartbeat prompt so the agent sees what occurred.

The queue holds up to 20 events. Consecutive duplicate events are deduplicated. Events that arrive after the buffer is full are silently dropped (oldest events are preserved).

Exec Completion Events

When a background command finishes (via the exec tool), Moltis automatically enqueues a summary event with the command name, exit code, and a short preview of stdout/stderr. If the heartbeat is idle, it is woken immediately so the agent can react to the result.

This means the agent learns about completed background tasks without polling — a long-running build or deployment that finishes while the user is away will surface in the next heartbeat turn.

Schedule Types

Jobs support several schedule kinds:

KindFieldsDescription
everyevery_msRepeat at a fixed interval (milliseconds)
cronexpr, optional tzStandard cron expression (e.g. "0 */6 * * *")
atat_msRun once at a specific Unix timestamp (ms)

Cron Tool

The agent manages jobs through the built-in cron tool. Available actions:

  • add — Create a new job
  • list — List all jobs
  • run — Trigger a job immediately
  • update — Patch an existing job (name, schedule, enabled, wakeMode, etc.)
  • remove — Delete a job
  • runs — View recent execution history for a job

One-Shot Jobs

Set deleteAfterRun: true to automatically remove a job after its first execution. Combined with the at schedule, this is useful for deferred one-time tasks (reminders, follow-ups).

Channel Delivery

Background agent turns can deliver their final output to a configured channel account/chat after the run completes.

Use all of the following together:

  • sessionTarget: "isolated"
  • payload.kind: "agentTurn"
  • payload.deliver: true
  • payload.channel: "<account_id>"
  • payload.to: "<chat_or_peer_id>"

Example:

{
  "action": "add",
  "job": {
    "name": "daily summary",
    "schedule": { "kind": "cron", "expr": "0 9 * * *", "tz": "Europe/Paris" },
    "sessionTarget": "isolated",
    "payload": {
      "kind": "agentTurn",
      "message": "Summarize yesterday's activity and send it to Telegram.",
      "deliver": true,
      "channel": "my-telegram-bot",
      "to": "123456789"
    }
  }
}

Channel delivery is separate from session targeting. The cron job still runs in an isolated cron session, then Moltis forwards the finished output to the requested channel destination.

Session Targeting

Each job specifies where its agent turn runs:

TargetDescription
"main"Inject a systemEvent into the main session. Use this for reminders that should continue the main conversation.
"isolated"Run an agentTurn in a throwaway cron session. Use this for background jobs and channel delivery.
named("<name>")Internal/persistent cron session used by system jobs such as heartbeat. Not user-configurable through the cron tool.

Security

See Cron Job Security for rate limiting, sandbox isolation, and job notification details.

Metrics

MetricTypeDescription
moltis_cron_jobs_scheduledGaugeNumber of scheduled jobs
moltis_cron_executions_totalCounterJob executions
moltis_cron_execution_duration_secondsHistogramJob duration
moltis_cron_errors_totalCounterFailed jobs
moltis_cron_stuck_jobs_cleared_totalCounterJobs exceeding 2h timeout
moltis_cron_input_tokens_totalCounterInput tokens from cron runs
moltis_cron_output_tokens_totalCounterOutput tokens from cron runs

Skill Self-Extension

Moltis can create, update, and delete personal skills at runtime through agent tools, enabling the system to extend its own capabilities during a conversation.

Overview

Three agent tools manage personal skills by default:

ToolDescription
create_skillWrite a new SKILL.md to <data_dir>/skills/<name>/
update_skillOverwrite an existing skill’s SKILL.md
delete_skillRemove a skill directory

When skills.enable_agent_sidecar_files = true, a fourth tool becomes available:

ToolDescription
write_skill_filesWrite supplementary UTF-8 text files inside an existing personal skill directory

Skills created this way are personal and stored in the configured data directory’s skills/ folder. They become available on the next message automatically thanks to the skill watcher.

Skill Watcher

The skill watcher (crates/skills/src/watcher.rs) monitors skill directories for filesystem changes using debounced notifications. When a SKILL.md file is created, modified, or deleted, the watcher emits a skills.changed event via the WebSocket event bus so the UI can refresh. Supplementary file writes do not change discovery on their own, so the watcher intentionally stays focused on SKILL.md.

Tip

The watcher uses debouncing to avoid firing multiple events for rapid successive edits (e.g. an editor writing a temp file then renaming).

Creating a Skill

The agent can create a skill by calling the create_skill tool:

{
  "name": "summarize-pr",
  "content": "# summarize-pr\n\nSummarize a GitHub pull request...",
  "description": "Summarize GitHub PRs with key changes and review notes"
}

This writes <data_dir>/skills/summarize-pr/SKILL.md with the provided content. The skill discoverer picks it up on the next message.

Writing Supplementary Files

When skills.enable_agent_sidecar_files = true, the agent can add sidecar files such as shell scripts, templates, _meta.json, or Dockerfile:

{
  "name": "summarize-pr",
  "files": [
    {
      "path": "script.sh",
      "content": "#!/usr/bin/env bash\necho summarize\n"
    },
    {
      "path": "templates/prompt.txt",
      "content": "Summarize the pull request with risks first.\n"
    }
  ]
}

Safety rules:

  • only writes inside <data_dir>/skills/<name>/
  • only relative UTF-8 text files
  • rejects .., absolute paths, hidden path components, and SKILL.md
  • rejects symlink escapes and oversized batches
  • appends an audit entry to ~/.moltis/logs/security-audit.jsonl

Updating a Skill

{
  "name": "summarize-pr",
  "content": "# summarize-pr\n\nUpdated instructions..."
}

Deleting a Skill

{
  "name": "summarize-pr"
}

This removes the entire <data_dir>/skills/summarize-pr/ directory, including any supplementary files written alongside SKILL.md.

Warning

Deleted skills cannot be recovered. The agent should confirm with the user before deleting a skill.

Mobile PWA and Push Notifications

Moltis can be installed as a Progressive Web App (PWA) on mobile devices, providing a native app-like experience with push notifications.

Installing on Mobile

iOS (Safari)

  1. Open moltis in Safari
  2. Tap the Share button (box with arrow)
  3. Scroll down and tap “Add to Home Screen”
  4. Tap “Add” to confirm

The app will appear on your home screen with the moltis icon.

Android (Chrome)

  1. Open moltis in Chrome
  2. You should see an install banner at the bottom - tap “Install”
  3. Or tap the three-dot menu and select “Install app” or “Add to Home Screen”
  4. Tap “Install” to confirm

The app will appear in your app drawer and home screen.

PWA Features

When installed as a PWA, moltis provides:

  • Standalone mode: Full-screen experience without browser UI
  • Offline support: Previously loaded content remains accessible
  • Fast loading: Assets are cached locally
  • Home screen icon: Quick access from your device’s home screen
  • Safe area support: Proper spacing for notched devices (iPhone X+)

Push Notifications

Push notifications allow you to receive alerts when the LLM responds, even when you’re not actively viewing the app.

Enabling Push Notifications

  1. Open the moltis app (must be installed as PWA on Safari/iOS)
  2. Go to Settings > Notifications
  3. Click Enable to subscribe to push notifications
  4. When prompted, allow notification permissions

Safari/iOS Note: Push notifications only work when the app is installed as a PWA. If you see “Installation required”, add moltis to your Dock first:

  • macOS: File → Add to Dock
  • iOS: Share → Add to Home Screen

Managing Subscriptions

The Settings > Notifications page shows all subscribed devices:

  • Device name: Parsed from user agent (e.g., “Safari on macOS”, “iPhone”)
  • IP address: Client IP at subscription time (supports proxies via X-Forwarded-For)
  • Subscription date: When the device subscribed

You can remove any subscription by clicking the Remove button. This works from any device - useful for revoking access to old devices.

Subscription changes are broadcast in real-time via WebSocket, so all connected clients see updates immediately.

How It Works

Moltis uses the Web Push API with VAPID (Voluntary Application Server Identification) keys:

  1. VAPID Keys: On first run, the server generates a P-256 ECDSA key pair
  2. Subscription: The browser creates a push subscription using the server’s public key
  3. Registration: The subscription details are sent to the server and stored
  4. Notification: When you need to be notified, the server encrypts and sends a push message

Push API Routes

The gateway exposes these API endpoints for push notifications:

EndpointMethodDescription
/api/push/vapid-keyGETGet the VAPID public key for subscription
/api/push/subscribePOSTRegister a push subscription
/api/push/unsubscribePOSTRemove a push subscription
/api/push/statusGETGet push service status and subscription list

Subscribe Request

{
  "endpoint": "https://fcm.googleapis.com/fcm/send/...",
  "keys": {
    "p256dh": "base64url-encoded-key",
    "auth": "base64url-encoded-auth"
  }
}

Status Response

{
  "enabled": true,
  "subscription_count": 2,
  "subscriptions": [
    {
      "endpoint": "https://fcm.googleapis.com/...",
      "device": "Safari on macOS",
      "ip": "192.168.1.100",
      "created_at": "2025-02-05T23:30:00Z"
    }
  ]
}

Notification Payload

Push notifications include:

{
  "title": "moltis",
  "body": "New response available",
  "url": "/chats",
  "sessionKey": "session-id"
}

Clicking a notification will open or focus the app and navigate to the relevant chat.

Configuration

Feature Flag

Push notifications are controlled by the push-notifications feature flag, which is enabled by default. To disable:

# In your Cargo.toml or when building
[dependencies]
moltis-gateway = { default-features = false, features = ["web-ui", "tls"] }

Or build without the feature:

cargo build --no-default-features --features web-ui,tls,tailscale,file-watcher

Data Storage

Push notification data is stored in push.json in the data directory:

  • VAPID keys: Generated once and reused
  • Subscriptions: List of all registered browser subscriptions

The VAPID keys are persisted so subscriptions remain valid across restarts.

Mobile UI Considerations

The mobile interface adapts for smaller screens:

  • Navigation drawer: The sidebar becomes a slide-out drawer on mobile
  • Sessions panel: Displayed as a bottom sheet that can be swiped
  • Touch targets: Minimum 44px touch targets for accessibility
  • Safe areas: Proper insets for devices with notches or home indicators

Responsive Breakpoints

  • Mobile: < 768px width (drawer navigation)
  • Desktop: ≥ 768px width (sidebar navigation)

Browser Support

FeatureChromeSafariFirefoxEdge
PWA Install✅ (iOS)
Push Notifications✅ (iOS 16.4+)
Service Worker
Offline Support

Note: iOS push notifications require iOS 16.4 or later and the app must be installed as a PWA.

Troubleshooting

Notifications Not Working

  1. Check permissions: Ensure notifications are allowed in browser/OS settings
  2. Check subscription: Go to Settings > Notifications to see if your device is listed
  3. Check server logs: Look for push: prefixed log messages for delivery status
  4. Safari/iOS specific:
    • Must be installed as PWA (Add to Dock/Home Screen)
    • iOS requires version 16.4 or later
    • The Enable button is disabled until installed as PWA
  5. Behind a proxy: Ensure your proxy forwards X-Forwarded-For or X-Real-IP headers

PWA Not Installing

  1. HTTPS required: PWAs require a secure connection (or localhost)
  2. Valid manifest: Ensure /manifest.json loads correctly
  3. Service worker: Check that /sw.js registers without errors
  4. Clear cache: Try clearing browser cache and reloading

Service Worker Issues

Clear the service worker registration:

  1. Open browser DevTools
  2. Go to Application > Service Workers
  3. Click “Unregister” on the moltis service worker
  4. Reload the page

Native Swift App with Embedded Moltis Rust Core (POC)

This guide shows a proof-of-concept path to build a native Swift app where Swift is the UI layer and Moltis Rust code is embedded as a local library.

Goal:

  • Keep business/runtime logic in Rust.
  • Build native iOS/macOS UI in Swift/SwiftUI.
  • Ship as one app bundle from the Swift side (no separate Rust service process).

Feasibility

Yes — this architecture is feasible with an FFI boundary.

The most practical POC shape is:

  1. Add a small Rust crate that compiles as staticlib.
  2. Expose a narrow C ABI (extern "C") surface.
  3. Call that ABI from Swift via a bridging header/module map.
  4. Keep Swift responsible for presentation and user interaction.
SwiftUI / UIKit / AppKit
        |
        v
Swift wrapper types (safe Swift API)
        |
        v
C ABI bridge (headers + extern "C")
        |
        v
Rust core facade (thin FFI-safe layer)
        |
        v
Existing Moltis crates (chat/providers/config/etc.)

Boundary Rules

For the POC, keep the ABI intentionally small:

  • moltis_version()
  • moltis_chat_json(request_json)
  • moltis_free_string(ptr)
  • moltis_shutdown()

Pass JSON strings across FFI to avoid unstable struct layouts early on.

Rust-side Implementation Notes

Create a dedicated bridge crate (example name: crates/swift-bridge):

  • crate-type = ["staticlib"] for Apple targets.
  • Keep all extern "C" functions in one module.
  • Never expose internal Rust structs directly.
  • Return *mut c_char and provide explicit free functions.
  • Convert internal errors into structured JSON error payloads.

Safety checklist:

  • Validate all incoming pointers and UTF-8.
  • Do not panic across FFI boundaries (catch_unwind at boundary).
  • Keep ownership explicit (allocator symmetry for returned memory).
  • Do not leak secrets into logs or debug output.

Swift-side Integration Notes

Use YAML-generated Xcode projects for the POC (no hand-maintained .xcodeproj):

  1. Define app targets in apps/macos/project.yml.
  2. Generate project with XcodeGen.
  3. Link Generated/libmoltis_bridge.a and include Generated/moltis_bridge.h.
  4. Use a Swift facade (MoltisClient) to own pointer and lifetime rules.
  5. Keep Swift linted via apps/macos/.swiftlint.yml.

From repo root:

just swift-build-rust
just swift-generate
just swift-lint
just swift-build

The UI remains purely SwiftUI while core requests/responses flow through the Rust bridge.

Intel + Apple Silicon (Universal libmoltis)

Yes — you can build libmoltis for both Intel and Apple Silicon and merge them into one universal macOS static library.

Build both architectures

rustup target add x86_64-apple-darwin aarch64-apple-darwin

# Intel
cargo build -p moltis-swift-bridge --release --target x86_64-apple-darwin

# Apple Silicon
cargo build -p moltis-swift-bridge --release --target aarch64-apple-darwin

Merge into one universal archive

mkdir -p target/universal-macos/release
lipo -create \
  target/x86_64-apple-darwin/release/libmoltis_bridge.a \
  target/aarch64-apple-darwin/release/libmoltis_bridge.a \
  -output target/universal-macos/release/libmoltis_bridge.a

lipo -info target/universal-macos/release/libmoltis_bridge.a

This universal libmoltis_bridge.a can then be linked by your Swift macOS app, so one app build supports both Intel and M-series Macs.

For production, prefer an XCFramework (device/simulator/platform-safe packaging) rather than manually juggling multiple .a files.

Async/Streaming Strategy

Moltis is async-first. For a POC:

  • Start with request/response calls over FFI.
  • Add streaming in phase 2 using callback registration or poll handles.

Simple incremental plan:

  1. Blocking/synchronous POC call (prove bridge correctness).
  2. Background Task wrapping on Swift side.
  3. Token streaming callback API when stable.

Single-Binary Expectation Clarification

On Apple platforms, you typically ship a single app artifact that includes Swift executable + statically linked Rust library in one app bundle.

So for your POC requirement (Swift UI app that embeds Rust core without a separate Rust daemon), this is achievable.

POC Milestones

  1. Add swift-bridge crate exposing one health function (moltis_version).
  2. Add one end-to-end chat method (moltis_chat_json).
  3. Build and link from minimal SwiftUI app.
  4. Validate memory lifecycle with repeated calls.
  5. Expand API surface only after the boundary is stable.

Risks to Watch Early

  • ABI drift (solve with one owned header and narrow API).
  • Threading assumptions across Swift and Rust runtimes.
  • Logging and secret handling at the boundary.
  • Cross-target build complexity (simulator vs device architectures).

Why This Fits Moltis

Moltis already has clear crate boundaries and async services. A thin FFI facade lets Swift own the native UX while reusing provider orchestration, config, and session logic from Rust.

macOS App FFI Bridge (Work in Progress)

Warning

This macOS app integration is not finished yet. It is currently being built.

This page documents how apps/macos currently bridges Swift to Rust through FFI.

Runtime Architecture

┌──────────────────────────────────────────────────────────────────────────┐
│ Moltis.app (single macOS process)                                        │
│                                                                          │
│  SwiftUI Views                                                           │
│  (ContentView, OnboardingView, SettingsView, ...)                        │
│                    │                                                     │
│                    ▼                                                     │
│  State stores                                                            │
│  (ChatStore, ProviderStore, LogStore)                                    │
│                    │                                                     │
│                    ▼                                                     │
│  Swift FFI facade: MoltisClient.swift                                    │
│  - encodes requests to JSON                                              │
│  - calls C symbols from `moltis_bridge.h`                                │
│  - decodes JSON responses / bridge errors                                │
└────────────────────┬─────────────────────────────────────────────────────┘
                     │
                     │ C ABI (`moltis_*`)
                     ▼
┌──────────────────────────────────────────────────────────────────────────┐
│ Rust bridge static library: `libmoltis_bridge.a`                         │
│ crate: `crates/swift-bridge`                                             │
│                                                                          │
│  `extern "C"` exports                                                    │
│  (chat, streaming, providers, sessions, httpd, version, shutdown, ...)   │
│                    │                                                     │
│                    ▼                                                     │
│  Rust bridge internals                                                   │
│  - pointer/UTF-8 + JSON validation                                       │
│  - panic boundary (`catch_unwind`)                                       │
│  - tokio runtime + provider registry + session storage                   │
└────────────────────┬─────────────────────────────────────────────────────┘
                     │
                     ▼
┌──────────────────────────────────────────────────────────────────────────┐
│ Reused Moltis crates                                                     │
│ (`moltis-providers`, `moltis-sessions`, `moltis-gateway`, etc.)          │
└──────────────────────────────────────────────────────────────────────────┘

Reverse direction callbacks:
- Rust logs: `moltis_set_log_callback(...)` -> Swift `LogStore`
- Rust streaming events: `moltis_*_chat_stream(...)` callback -> Swift closures
- Rust session events: `moltis_set_session_event_callback(...)` -> Swift `ChatStore`
`just swift-build-rust`
        │
        ▼
scripts/build-swift-bridge.sh
  1) cargo build -p moltis-swift-bridge --target x86_64-apple-darwin
  2) cargo build -p moltis-swift-bridge --target aarch64-apple-darwin
  3) lipo -create -> universal `libmoltis_bridge.a`
  4) cbindgen -> `moltis_bridge.h`
  5) copy both artifacts into `apps/macos/Generated/`
        │
        ▼
`just swift-generate` (xcodegen from `apps/macos/project.yml`)
        │
        ▼
Xcode build
  - header search path: `apps/macos/Generated`
  - library search path: `apps/macos/Generated`
  - links `-lmoltis_bridge`
  - uses `Sources/Bridging-Header.h` -> includes `moltis_bridge.h`

Main FFI Touchpoints

  • Swift header import: apps/macos/Sources/Bridging-Header.h
  • Swift facade: apps/macos/Sources/MoltisClient.swift
  • Rust exports: crates/swift-bridge/src/lib.rs
  • Artifact builder: scripts/build-swift-bridge.sh
  • Xcode linking config: apps/macos/project.yml

Real-time Session Sync

Sessions created in the macOS app appear in the web UI (and vice versa) in real time thanks to a shared tokio::sync::broadcast channel — the SessionEventBus.

┌──────────────┐  publish   ┌─────────────────┐  subscribe  ┌────────────────┐
│ Bridge FFI   │ ────────→ │ SessionEventBus  │ ────────→  │ FFI callback   │→ macOS app
│ (macOS app)  │           │ (broadcast chan)  │            │ (bridge lib.rs)│
└──────────────┘           └─────────────────┘            └────────────────┘
                                  ↑
┌──────────────┐  publish         │
│ Gateway RPCs │ ─────────────────┘
│ (sessions.*) │        (also broadcasts to WS clients directly)
└──────────────┘

When HTTPD is enabled, the bridge passes its bus instance to prepare_gateway() so both share the same channel. Events:

KindTrigger
createdsessions.resolve (new), sessions.fork, bridge moltis_create_session
patchedsessions.patch
deletedsessions.delete

Swift receives events via moltis_set_session_event_callback — each event is a JSON object {"kind":"created","sessionKey":"..."} dispatched to ChatStore.handleSessionEvent() on the main thread.

Current Status

The bridge is already functional for core flows (version, chat, streaming, providers, sessions, embedded httpd), but this is still a POC-stage macOS app and the integration surface is still evolving.

OpenClaw Import

Moltis can automatically detect and import data from an existing OpenClaw installation. This lets you migrate to Moltis without losing your provider keys, memory files, skills, sessions, personality, or channel configuration.

Your OpenClaw installation is never modified. The import is strictly read-only — Moltis copies data into its own directory and does not write to, move, or delete anything under ~/.openclaw/. You can safely keep using OpenClaw alongside Moltis, and re-import at any time to pick up new data.

How Detection Works

Moltis checks for an OpenClaw installation in two locations:

  1. The path set in the OPENCLAW_HOME environment variable
  2. ~/.openclaw/ (default)

If the directory exists and contains recognizable OpenClaw files (openclaw.json, agent directories, etc.), Moltis considers it detected. The workspace directory respects the OPENCLAW_PROFILE environment variable for multi-profile setups.

What Gets Imported

CategorySourceDestinationNotes
Identityopenclaw.json agent name, theme, and timezonemoltis.toml identity sectionPreserves existing Moltis identity if already configured
ProvidersAgent auth-profiles (API keys)~/.moltis/provider_keys.jsonMaps OpenClaw provider names to Moltis equivalents (e.g., google becomes gemini)
Skillsskills/ directories with SKILL.md~/.moltis/skills/Copies entire skill directories; skips duplicates
MemoryMEMORY.md and all memory/*.md files~/.moltis/MEMORY.md and ~/.moltis/memory/Imports daily logs, project notes, and all other markdown memory files. Appends with <!-- Imported from OpenClaw --> separator for idempotency
ChannelsTelegram and Discord bot configuration in openclaw.jsonmoltis.toml channels sectionSupports both flat and multi-account Telegram configs
SessionsJSONL conversation files under agents/*/sessions/~/.moltis/sessions/ and ~/.moltis/memory/sessions/Converts OpenClaw message format to Moltis format; prefixes keys with oc:. Also generates markdown transcripts for memory search indexing
MCP Serversmcp-servers.json~/.moltis/mcp-servers.jsonMerges with existing servers; skips duplicates by name
Workspace FilesSOUL.md, IDENTITY.md, USER.md, TOOLS.md, AGENTS.md, HEARTBEAT.md, BOOT.md~/.moltis/ (root) or ~/.moltis/agents/<id>/Copies raw workspace files; skips if destination already has user content. Replaces auto-seeded defaults

Workspace files explained

These markdown files shape your agent’s personality and behavior. Moltis uses them in the same way OpenClaw does:

  • SOUL.md — personality directives (tone, style, boundaries)
  • IDENTITY.md — agent name, emoji, creature/vibe theme
  • USER.md — user profile (name, preferences, context the agent should know about you)
  • TOOLS.md — tool usage guidelines and constraints
  • AGENTS.md — global workspace rules injected into every conversation
  • HEARTBEAT.md — periodic heartbeat prompt (what to check on each scheduled tick)
  • BOOT.md — startup context injected when the gateway starts

If you customized any of these files in OpenClaw, they will carry over. If the destination already has user content, the import skips the file to avoid overwriting your work. Auto-seeded defaults (like the template SOUL.md) are replaced with your imported content.

Multi-agent support

If your OpenClaw installation has multiple agents (defined in openclaw.json’s agents.list or detected from agents/ directories), all of them are imported:

  • The default agent becomes Moltis’s main agent
  • Non-default agents are created as separate agent personas with their name, theme, and emoji
  • Per-agent workspace files (SOUL.md, IDENTITY.md, etc.) are copied to ~/.moltis/agents/<id>/, giving each agent its own personality
  • Per-agent sessions are prefixed with oc:<agent_id>: so they appear under the correct agent
  • Agents without per-agent workspace files inherit from the root files automatically

Importing via Web UI

During Onboarding

If Moltis detects an OpenClaw installation at first launch, an Import step appears in the onboarding wizard before the identity and provider steps. You can select which categories to import using checkboxes, then proceed with the rest of setup.

From Settings

  1. Go to Settings (gear icon)
  2. Select OpenClaw Import from the sidebar
  3. Click Scan to see what data is available
  4. Check the categories you want to import
  5. Click Import Selected

The import section only appears when an OpenClaw installation is detected.

Importing via CLI

The moltis import command provides three subcommands:

Detect

Check whether an OpenClaw installation exists and preview what can be imported:

moltis import detect

Example output:

OpenClaw installation detected at /Users/you/.openclaw

  Identity:        available (agent: "friday")
  Providers:       available (2 auth profiles)
  Skills:          3 skills found
  Memory:          available (MEMORY.md + 12 memory files)
  Channels:        available (1 Telegram account)
  Sessions:        47 session files across 2 agents
  MCP Servers:     4 servers configured
  Workspace Files: SOUL.md, IDENTITY.md, USER.md, TOOLS.md, HEARTBEAT.md

Use --json for machine-readable output:

moltis import detect --json

Import All

Import everything at once:

moltis import all

Preview what would happen without writing anything:

moltis import all --dry-run

Import Selected Categories

Import only specific categories:

moltis import select -c providers,skills,memory

Valid category names: identity, providers, skills, memory, channels, sessions, mcp_servers, workspace-files.

Combine with --dry-run to preview:

moltis import select -c sessions --dry-run

Importing via RPC

Three RPC methods are available for programmatic access:

MethodDescription
openclaw.detectReturns detection and scan results (what data is available)
openclaw.scanAlias for openclaw.detect
openclaw.importPerforms the import with a selection object

Example openclaw.import params:

{
  "identity": true,
  "providers": true,
  "skills": true,
  "memory": true,
  "channels": false,
  "sessions": false,
  "mcp_servers": true,
  "workspace_files": true
}

The response includes a report with per-category status (imported, skipped, error) and counts.

Incremental Session Import

If you continue using OpenClaw after the initial import, Moltis will detect new messages when you re-import. Sessions are compared by source file line count — if the source JSONL has grown since the last import, Moltis re-converts the full session and updates the metadata.

On incremental update:

  • The session’s original id and created_at are preserved
  • The version field is bumped
  • The markdown transcript is regenerated with all messages
  • The CLI report shows updated sessions separately: 2 imported, 1 updated, 3 skipped

Legacy metadata (from imports before incremental support) will trigger a one-time catch-up re-import to establish the baseline line count.

Automatic Background Syncing

When the file-watcher feature is enabled (default), Moltis automatically watches the OpenClaw sessions directory for changes. Any new or appended session files are synced incrementally within seconds, without requiring a manual re-import.

How it works:

  • Moltis uses OS-level file notifications (FSEvents on macOS, inotify on Linux) to detect .jsonl file changes in the OpenClaw sessions directory
  • Events are debounced with a 5-second window to batch rapid writes during active conversations
  • A 60-second periodic fallback ensures changes are caught even if file notifications are missed
  • Only sessions are synced automatically — provider keys, memory, skills, and other categories are handled by the manual import or their own dedicated watchers

What gets synced:

  • New session files are imported with oc: prefixed keys
  • Existing sessions that have grown (new messages appended) are re-converted and updated
  • Markdown transcripts are regenerated for updated sessions so they remain searchable
  • Session metadata (id, created_at) is preserved across updates

The watcher starts automatically at gateway startup when an OpenClaw installation is detected. You can see the status in the startup logs:

openclaw: session watcher started

To disable automatic syncing, compile without the file-watcher feature.

Idempotency

Running the import multiple times is safe:

  • Memory uses an <!-- Imported from OpenClaw --> marker to avoid duplicating MEMORY.md content. Individual memory files skip if they already exist at the destination
  • Skills skip directories that already exist in the Moltis skills folder
  • MCP servers skip entries with matching names
  • Sessions use oc: prefixed keys that won’t collide with native Moltis sessions. Unchanged sessions (same line count) are skipped; grown sessions are re-converted
  • Provider keys merge with existing keys without overwriting
  • Workspace files skip if the destination already has user content; replace only auto-seeded defaults

Provider Name Mapping

OpenClaw and Moltis use different names for some providers:

OpenClaw NameMoltis Name
googlegemini
anthropicanthropic
openaiopenai
openrouteropenrouter

Unmapped provider names are passed through as-is.

Unsupported Channels

Currently only Telegram and Discord channels are imported. If your OpenClaw configuration includes other channel types (Slack, WhatsApp, etc.), they will appear as warnings in the scan output but will not be imported.

Troubleshooting

Import not detected

  • Verify the OpenClaw directory exists: ls ~/.openclaw/
  • If using a custom path, set OPENCLAW_HOME=/path/to/openclaw
  • If using profiles, set OPENCLAW_PROFILE=your-profile

Provider keys not working after import

OpenClaw stores API keys in agent auth-profiles. If the key was rotated or expired in OpenClaw, the imported key will also be invalid. Re-enter the key in Settings > Providers.

Memory import appears incomplete

The import brings over MEMORY.md and all .md files from the memory/ directory (daily logs, project notes, custom files). Non-markdown files are skipped. OpenClaw’s SQLite vector database is not imported because embeddings are not portable across models — Moltis will re-index the imported files automatically.

Session transcripts

When sessions are imported, Moltis also generates markdown transcripts in ~/.moltis/memory/sessions/. These contain the user/assistant conversation text and are indexed by the memory system, making your imported OpenClaw conversations searchable.

Workspace files not appearing

If a workspace file wasn’t imported, it may already exist at the destination with custom content. The import never overwrites user-customized files. Check ~/.moltis/SOUL.md (or ~/.moltis/agents/<id>/SOUL.md for non-default agents) to see what’s there. You can delete it and re-import to get the OpenClaw version.

Multi-Node

Moltis can distribute work across multiple machines. A node is a remote device that connects to your gateway and executes commands on your behalf. This lets the AI agent run shell commands on a Linux server, query a Raspberry Pi, or leverage a GPU machine — all from a single chat session.

How It Works

┌──────────────┐    WebSocket     ┌─────────────────┐
│  Your laptop │◄────────────────►│  Moltis gateway  │
│  (browser)   │                  │  (moltis)        │
└──────────────┘                  └────────┬─────────┘
                                           │ WebSocket
                                  ┌────────▼─────────┐
                                  │  Remote machine   │
                                  │  (moltis node)    │
                                  └──────────────────┘
  1. The gateway runs on your primary machine (or a server).
  2. On the remote machine, run moltis node add to register it with the gateway.
  3. The gateway authenticates the node using a device token from the pairing flow.
  4. Once connected, the agent can execute commands on the node, query its telemetry, and discover its LLM providers.

Nodes are stateless from the gateway’s perspective — they connect and disconnect freely. There is no start/stop lifecycle managed by the gateway; a node is available when its process is running and connected.

Pairing a Node

Before a node can connect, it must be paired with the gateway.

  1. Open the Nodes page in the web UI (Settings → Nodes).
  2. Click Generate Token to create a device token.
  3. Copy the connection command shown in the UI.
  4. Run it on the remote machine.

The pairing flow produces a device token that authenticates the node on every connection. Tokens can be revoked from the Nodes page at any time.

Adding a Node

On the remote machine, register it as a node:

moltis node add --host ws://your-gateway:9090/ws --token <device-token> --name "Build Server"

This saves the connection parameters to ~/.moltis/node.json and installs an OS service that starts on boot and reconnects on failure:

PlatformService file
macOS~/Library/LaunchAgents/org.moltis.node.plist
Linux~/.config/systemd/user/moltis-node.service

Options:

FlagDescriptionDefault
--hostGateway WebSocket URL(required)
--tokenDevice token from pairing(required)
--nameDisplay name shown in the UInone
--node-idCustom node identifierrandom UUID
--working-dirWorking directory for commands$HOME
--timeoutMax command timeout in seconds300
--foregroundRun in the terminal instead of installing a serviceoff

You can also set MOLTIS_GATEWAY_URL and MOLTIS_DEVICE_TOKEN as environment variables instead of passing --host and --token.

Foreground mode

For debugging or one-off use, pass --foreground to run the node in the current terminal session instead of installing a service:

moltis node add --host ws://your-gateway:9090/ws --token <device-token> --foreground

Press Ctrl+C to disconnect.

Removing a Node

To disconnect this machine and remove the background service:

moltis node remove

This stops the service, removes the service file, and deletes the saved configuration from ~/.moltis/node.json.

Checking Status

moltis node status

Shows the gateway URL, display name, and whether the background service is running.

Logs

moltis node logs
# Tail the log:
tail -f $(moltis node logs)

Selecting a Node in Chat

Once a node is connected, you can target it from a chat session:

  • UI dropdown: The chat toolbar shows a node selector next to the model picker. Select a node to route all exec commands to it. Select “Local” to revert to local execution.
  • Agent tools: The agent can call nodes_list, nodes_describe, and nodes_select to programmatically pick a node based on capabilities or telemetry.

The node assignment is per-session and persists across page reloads.

Node Telemetry

Connected nodes report system telemetry every 30 seconds:

  • CPU count and usage
  • Memory total and available
  • Disk total and available (root partition)
  • System uptime
  • Installed runtimes (Python, Node.js, Ruby, Go, Rust, Java)
  • Available LLM providers (Ollama models, API key presence)

This data is visible on the Nodes page and available to the agent via the nodes_describe tool.

CLI Reference

CommandDescription
moltis node generate-tokenGenerate a device token and print the add command
moltis node listList all connected nodes
moltis node add --host <url> --token <tok>Join this machine to a gateway as a node
moltis node add ... --foregroundRun in the terminal instead of installing a service
moltis node removeDisconnect this machine and remove the service
moltis node statusShow connection info and service status
moltis node logsPrint log file path

Security

  • Device tokens are SHA-256 hashed before storage. The raw token is shown once during pairing and never stored on the gateway.
  • Environment filtering: When the gateway forwards commands to a node, only safe environment variables are forwarded (TERM, LANG, LC_*). Secrets like API keys, DYLD_*, and LD_PRELOAD are always blocked.
  • Token revocation: Revoke a device token from the Nodes page at any time. The node will be disconnected on its next reconnect attempt.

Service Management

Moltis can be installed as an OS service so it starts automatically on boot and restarts after crashes.

Install

moltis service install

This creates a service definition and starts it immediately:

PlatformService fileInit system
macOS~/Library/LaunchAgents/org.moltis.gateway.plistlaunchd (user agent)
Linux~/.config/systemd/user/moltis.servicesystemd (user unit)

Both configurations:

  • Start on boot (RunAtLoad / WantedBy=default.target)
  • Restart on failure with a 10-second cooldown
  • Log to ~/.moltis/moltis.log

Options

You can pass --bind, --port, and --log-level to bake them into the service definition:

moltis service install --bind 0.0.0.0 --port 8080 --log-level debug

These flags are written into the service file. The service reads the rest of its configuration from ~/.moltis/moltis.toml as usual.

Manage

moltis service status     # Show running/stopped/not-installed and PID
moltis service stop       # Stop the service
moltis service restart    # Restart the service
moltis service logs       # Print the log file path

To tail the logs:

tail -f $(moltis service logs)

Uninstall

moltis service uninstall

This stops the service, removes the service file, and cleans up.

CLI Reference

CommandDescription
moltis service installInstall and start the service
moltis service uninstallStop and remove the service
moltis service statusShow service status and PID
moltis service stopStop the service
moltis service restartRestart the service
moltis service logsPrint log file path

How It Differs from moltis node add

moltis service install manages the gateway — the main Moltis server that hosts the web UI, chat sessions, and API.

moltis node add registers a headless node — a client process on a remote machine that connects back to a gateway for command execution. See Multi-Node for details.

moltis servicemoltis node
What it runsThe gateway serverA node client
Needs --host/--tokenNoYes
Config source~/.moltis/moltis.toml~/.moltis/node.json
launchd labelorg.moltis.gatewayorg.moltis.node
systemd unitmoltis.servicemoltis-node.service

Authentication

Moltis uses a unified authentication gate that protects all routes with a single source of truth. This page explains how authentication works, the decision logic, and the different credential types.

Architecture

All HTTP requests pass through a single auth_gate middleware before reaching any handler. The middleware calls check_auth() — the only function in the codebase that decides whether a request is authenticated. This eliminates the class of bugs where different code paths disagree on auth status.

Request
  │
  ▼
auth_gate middleware
  │
  ├─ Public path? (/health, /assets/*, /api/auth/*, ...) → pass through
  │
  ├─ No credential store? → pass through
  │
  └─ check_auth()
       │
       ├─ Allowed    → insert AuthIdentity into request, continue
       ├─ SetupRequired → 401 (API/WS) or redirect to /onboarding (pages)
       └─ Unauthorized  → 401 (API/WS) or serve SPA login page (pages)

WebSocket connections also use check_auth() for the HTTP upgrade handshake. After the upgrade, the WS protocol has its own param-based auth (API key or password in the connect message) for clients that cannot set HTTP headers.

Decision Matrix

check_auth() evaluates conditions in order and returns the first match:

#ConditionResultAuth method
1auth_disabled is trueAllowedLoopback
2Setup not complete + local connectionAllowedLoopback
3Setup not complete + remote connectionSetupRequired
4Valid session cookieAllowedPassword
5Valid Bearer API keyAllowedApiKey
6None of the aboveUnauthorized

What is ‘setup complete’?

Setup is complete when at least one credential (password or passkey) has been registered. The setup_complete flag is recomputed whenever credentials are added or removed, so it correctly reflects passkey-only setups — not just password presence.

Three-Tier Model

The decision matrix above implements a three-tier authentication model:

TierConditionBehaviour
1 — Full authPassword or passkey is configuredAuth always required (any IP)
2 — Local devNo credentials + direct local connectionFull access (dev convenience)
3 — Remote setupNo credentials + remote/proxied connectionSetup flow only

Practical scenarios

ScenarioNo credentialsCredentials configured
Local browser on localhost:18789Full access (Tier 2)Login required (Tier 1)
Local CLI/wscat on localhost:18789Full access (Tier 2)Login required (Tier 1)
Internet via reverse proxyOnboarding only (Tier 3)Login required (Tier 1)
MOLTIS_BEHIND_PROXY=true, any sourceOnboarding only (Tier 3)Login required (Tier 1)

How “local” is determined

A connection is classified as local only when all four checks pass:

  1. MOLTIS_BEHIND_PROXY env var is not set
  2. No proxy headers present (X-Forwarded-For, X-Real-IP, CF-Connecting-IP, Forwarded)
  3. The Host header resolves to a loopback address (or is absent)
  4. The TCP source IP is loopback (127.0.0.1, ::1)

If any check fails, the connection is treated as remote.

Credential Types

Password

  • Set during initial setup or added later via Settings
  • Hashed with Argon2id before storage
  • Minimum 8 characters
  • Verified against auth_password table

Passkey (WebAuthn)

  • Registered during setup or added later via Settings
  • Supports hardware keys (YubiKey), platform authenticators (Touch ID, Windows Hello), and cross-platform authenticators
  • Stored in passkeys table as serialized WebAuthn credential data
  • Multiple passkeys can be registered per instance
  • HTTP-only moltis_session cookie, SameSite=Strict
  • Created on successful login (password or passkey)
  • 30-day expiry
  • Validated against auth_sessions table
  • When the request arrives on a .localhost subdomain (e.g. moltis.localhost), the cookie includes Domain=localhost so it is shared across all loopback hostnames

API key

  • Created in Settings > Security > API Keys
  • Prefixed with mk_ for identification
  • Stored as SHA-256 hash (the raw key is shown once at creation)
  • Passed via Authorization: Bearer <key> header (HTTP) or in the connect handshake auth.api_key field (WebSocket)
  • Must have at least one scope — keys without scopes are denied

API Key Scopes

ScopePermissions
operator.readView status, list jobs, read history
operator.writeSend messages, create jobs, modify configuration
operator.adminAll permissions (superset of all scopes)
operator.approvalsHandle command approval requests
operator.pairingManage device/node pairing

Tip

Use the minimum necessary scopes. A monitoring integration only needs operator.read. A CI pipeline that triggers agent runs needs operator.read and operator.write.

Public Paths

These paths are accessible without authentication, even when credentials are configured:

PathPurpose
/healthHealth check endpoint
/api/auth/*Auth status, login, setup, passkey flows
/assets/*Static assets (JS, CSS, images)
/auth/callbackOAuth callback
/manifest.jsonPWA manifest
/sw.jsService worker
/wsNode WebSocket endpoint (device token auth at protocol level)

Request Throttling

Moltis applies built-in endpoint throttling per client IP only when auth is required for the current request.

Requests bypass IP throttling when:

  • The request is already authenticated (session or API key)
  • Auth is not currently enforced (auth_disabled = true)
  • Setup is incomplete and the request is allowed by local Tier-2 access

Default limits:

ScopeDefault
POST /api/auth/login5 requests per 60 seconds
Other /api/auth/*120 requests per 60 seconds
Other /api/*180 requests per 60 seconds
/ws/chat upgrade30 requests per 60 seconds
/ws upgrade30 requests per 60 seconds

When a limit is exceeded:

  • API endpoints return 429 Too Many Requests
  • Responses include Retry-After header
  • JSON API responses also include retry_after_seconds

Note

When MOLTIS_BEHIND_PROXY=true, throttling is keyed by forwarded client IP headers (X-Forwarded-For, X-Real-IP, CF-Connecting-IP) instead of the direct socket address.

Setup Flow

On first run (no credentials configured):

  1. A random 6-digit setup code is printed to the terminal
  2. Local connections get full access (Tier 2) — no setup code needed
  3. Remote connections are redirected to /onboarding (Tier 3) — the setup code is required to set a password or register a passkey
  4. After setting up, the setup code is cleared and a session is created

Warning

The setup code is single-use and only valid until the first credential is registered. If you lose it, restart the server to generate a new one.

Removing Authentication

The “Remove all auth” action in Settings:

  1. Deletes all passwords, passkeys, sessions, and API keys
  2. Sets auth_disabled = true in config
  3. Generates a new setup code for re-setup
  4. All subsequent requests are allowed through (Tier 1 check: auth_disabled)

To re-enable auth, complete the setup flow again with the new setup code.

WebSocket Authentication

WebSocket connections are authenticated at two levels:

1. HTTP upgrade (header auth)

The WebSocket upgrade request passes through check_auth() like any other HTTP request. If the browser has a valid session cookie, the connection is pre-authenticated.

2. Connect message (param auth)

After the WebSocket is established, the client sends a connect message. Non-browser clients (CLI tools, scripts) that cannot set HTTP headers authenticate here:

{
  "method": "connect",
  "params": {
    "client": { "id": "my-tool", "version": "1.0.0" },
    "auth": {
      "api_key": "mk_abc123..."
    }
  }
}

The auth object can contain api_key or password. If neither is provided and the connection was not pre-authenticated via headers, the connection is rejected.

Reverse Proxy Considerations

When running behind a reverse proxy, authentication interacts with the local-connection detection:

  • Most proxies add X-Forwarded-For or similar headers, which automatically classify connections as remote
  • Bare proxies (no forwarding headers) can appear local — set MOLTIS_BEHIND_PROXY=true to force all connections to be treated as remote
  • The proxy must preserve the browser origin host for WebSocket CSWSH protection (forward Host, or X-Forwarded-Host when rewriting Host)
  • TLS termination typically happens at the proxy
  • Passkeys are tied to the RP ID/host identity; host/domain changes usually require registering new passkeys on the new host

See Security Architecture for detailed proxy deployment guidance, including a Nginx Proxy Manager header snippet and passkey migration guidance.

Session Management

OperationEndpointAuth required
Check statusGET /api/auth/statusNo
Set password (setup)POST /api/auth/setupSetup code
Login with passwordPOST /api/auth/loginNo (validates password)
Login with passkeyPOST /api/auth/passkey/auth/*No (validates passkey)
LogoutPOST /api/auth/logoutSession
Change passwordPOST /api/auth/password/changeSession
List API keysGET /api/auth/api-keysSession
Create API keyPOST /api/auth/api-keysSession
Revoke API keyDELETE /api/auth/api-keys/{id}Session
Register passkeyPOST /api/auth/passkey/register/*Session
Remove passkeyDELETE /api/auth/passkeys/{id}Session
Remove all authPOST /api/auth/resetSession
Vault statusGET /api/auth/vault/statusNo
Vault unlockPOST /api/auth/vault/unlockNo
Vault recoveryPOST /api/auth/vault/recoveryNo

Encryption at Rest

Environment variables and other sensitive data are encrypted at rest using the vault. The vault initializes automatically during password setup and unseals on login. See Encryption at Rest (Vault) for details.

Encryption at Rest (Vault)

Moltis includes an encryption-at-rest vault that protects sensitive data stored in the SQLite database. Environment variables (provider API keys, tokens, etc.) are encrypted with XChaCha20-Poly1305 AEAD using keys derived from your password via Argon2id.

The vault is enabled by default (the vault cargo feature) and requires no configuration. It initializes automatically when you set your first password (during setup or later in Settings > Authentication).

Key Hierarchy

The vault uses a two-layer key hierarchy to separate the encryption key from the password:

User password
  │
  ▼ Argon2id (salt from DB)
  │
  KEK (Key Encryption Key)
  │
  ▼ XChaCha20-Poly1305 unwrap
  │
  DEK (Data Encryption Key)
  │
  ▼ XChaCha20-Poly1305 encrypt/decrypt
  │
  Encrypted data (env variables, ...)
  • KEK — derived from the user’s password using Argon2id with a per-instance random salt. Never stored directly; recomputed on each unseal.
  • DEK — a random 256-bit key generated once at vault initialization. Stored encrypted (wrapped) by the KEK in the vault_metadata table.
  • Recovery KEK — an independent Argon2id-derived key from the recovery phrase with a fixed domain-separation salt, used to wrap a second copy of the DEK for emergency access. Uses lighter KDF parameters (16 MiB, 2 iterations) since the recovery key already has 128 bits of entropy.

This design means changing your password only re-wraps the DEK with a new KEK. The DEK itself (and all data encrypted by it) stays the same, so password changes are fast regardless of how much data is encrypted.

Vault States

The vault has three states:

StateMeaning
UninitializedNo vault metadata exists. The vault hasn’t been set up yet.
SealedMetadata exists but the DEK is not in memory. Data cannot be read or written.
UnsealedThe DEK is in memory. Encryption and decryption are active.
                 set password
Uninitialized ──────────────► Unsealed
                                │  ▲
                     restart    │  │  login / unlock
                                ▼  │
                              Sealed

After a server restart, the vault is always in the Sealed state until the user logs in (which provides the password needed to derive the KEK and unwrap the DEK).

Lifecycle Integration

The vault integrates transparently with the authentication flow:

First password set (POST /api/auth/setup or first POST /api/auth/password/change)

When the first password is set (during onboarding or later in Settings):

  1. vault.initialize(password) generates a random DEK and recovery key
  2. The DEK is wrapped with a KEK derived from the password
  3. A second copy of the DEK is wrapped with the recovery KEK
  4. The response includes a recovery_key field (shown once, then not returned again)
  5. Any existing plaintext env vars are migrated to encrypted

Login (POST /api/auth/login)

After successful password verification:

  1. vault.unseal(password) derives the KEK and unwraps the DEK into memory
  2. Unencrypted env vars are migrated to encrypted (if any remain)

Password change after initialization (POST /api/auth/password/change)

When a password already exists and is rotated:

  1. vault.change_password(old, new) re-wraps the DEK with a new KEK derived from the new password

No new recovery key is generated during normal password rotation.

Server restart

The vault starts in Sealed state. All encrypted data is unreadable until the user logs in, which triggers unseal.

Recovery Key

At vault initialization, a human-readable recovery key is generated and returned in the API response that performed initialization. It looks like:

ABCD-EFGH-JKLM-NPQR-STUV-WXYZ-2345-6789

The alphabet excludes ambiguous characters (I, O, 0, 1) to avoid transcription errors. The key is case-insensitive.

Warning

The recovery key is shown exactly once when the vault is initialized. Store it in a safe place (password manager, printed copy in a safe, etc.). If you lose both your password and recovery key, encrypted data cannot be recovered.

Use the recovery key to unseal the vault when you’ve forgotten your password:

curl -X POST http://localhost:18789/api/auth/vault/recovery \
  -H "Content-Type: application/json" \
  -d '{"recovery_key": "ABCD-EFGH-JKLM-NPQR-STUV-WXYZ-2345-6789"}'

What Gets Encrypted

Currently encrypted:

DataStorageAAD
Environment variables (env_variables table)SQLiteenv:{key}

The encrypted column in env_variables tracks whether each row is encrypted (1) or plaintext (0). When the vault is unsealed, new env vars are written encrypted. When sealed or uninitialized, they are written as plaintext.

Planned

KeyStore (provider API keys in provider_keys.json) and TokenStore (OAuth tokens in credentials.json) are currently sync/file-based and cannot easily call async vault methods. Encryption for these stores is planned after an async refactor.

Vault Guard Middleware

When the vault is in the Sealed state, a middleware layer blocks API requests (except auth and bootstrap endpoints) with 423 Locked:

{"error": "vault is sealed", "status": "sealed"}

This prevents the application from serving stale or unreadable data when the vault needs to be unlocked.

The guard does not block when the vault is Uninitialized — there’s nothing to protect yet, and the application needs to function normally for initial setup.

Allowed through regardless of vault state:

  • /api/auth/* — authentication endpoints (including vault unlock)
  • /api/gon — server-injected bootstrap data
  • Non-API routes — static assets, HTML pages, health check

API Endpoints

All vault endpoints are under /api/auth/vault/ and require no session (they are on the public auth allowlist):

MethodPathPurpose
GET/api/auth/vault/statusReturns {"status": "uninitialized"|"sealed"|"unsealed"|"disabled"}
POST/api/auth/vault/unlockUnseal with password: {"password": "..."}
POST/api/auth/vault/recoveryUnseal with recovery key: {"recovery_key": "..."}

Error responses:

StatusMeaning
200Success
423 LockedBad password or recovery key
404Vault not available (feature disabled)
500Internal error

Frontend Integration

The vault status is included in the gon data (window.__MOLTIS__) on every page load:

import * as gon from "./gon.js";
const vaultStatus = gon.get("vault_status");
// "uninitialized" | "sealed" | "unsealed" | "disabled"

Live updates are available via gon.onChange("vault_status", callback).

Locked-vault banners

When vault_status is sealed, the UI shows an info banner:

  • In the main app shell (index.html): a banner linking to Settings > Encryption for manual unlock.
  • On the login page (/login): a banner that explains the vault is locked and will unlock after successful sign-in.

Onboarding and localhost

The onboarding wizard’s Security step explains that setting a password also enables the encryption vault for stored secrets. The password selection card explicitly says: “Set a password and enable the encryption vault for stored secrets.”

On localhost, where authentication is optional, the subtitle mentions that setting a password enables the vault — giving users a reason to set one even when network security is not a concern.

When a password is set during first-time setup, the server returns a recovery_key field in the JSON response. The onboarding wizard shows an interstitial screen with:

  • A success message (“Password set and vault initialized”)
  • The recovery key in a monospace <code> block with select-all for easy selection
  • A Copy button using the Clipboard API
  • A warning that the key will not be shown again
  • A Continue button to proceed to the next onboarding step

In Settings > Authentication, setting a password for the first time also returns a recovery_key. The page keeps the user on Settings long enough to copy it, then shows a Continue to sign in action when the new password makes authentication mandatory.

Passkey-only setup does not trigger vault initialization (no password to derive a KEK from), so the recovery key screen is never shown in that flow.

Vault status in Settings > Encryption

When the vault feature is compiled in, an Encryption tab appears in Settings (under the Security group). It tells the user their API keys and secrets are encrypted before being stored, and that the vault locks on restart and unlocks on login.

Vault stateBadgeWhat it means
UnsealedGreen (“Unlocked”)Your API keys and secrets are encrypted in the database. Everything is working.
SealedAmber (“Locked”)Log in or unlock below to access your encrypted keys.
UninitializedGray (“Off”)Set a password in Authentication settings to start encrypting your stored keys.

When the vault is sealed, both unlock forms are shown in the same panel (password and recovery key, separated by an “or” divider). Submitting calls POST /api/auth/vault/unlock or POST /api/auth/vault/recovery, then refreshes gon data to update the status badge.

Encrypted badges on environment variables

Each environment variable in Settings > Environment shows a badge indicating its encryption status:

BadgeStyleMeaning
EncryptedGreen (.provider-item-badge.configured)Value is encrypted at rest by the vault
PlaintextGray (.provider-item-badge.muted)Value is stored in cleartext

A status note at the top of the section explains the current vault state:

  • Unlocked: “Your keys are stored encrypted.”
  • Locked: “Encrypted keys can’t be read — sandbox commands won’t work.” Links to Encryption settings to unlock.
  • Not set up: “Set a password to encrypt your stored keys.” Links to Authentication settings.

Disabling the Vault

To compile without vault support, disable the vault feature:

cargo build --no-default-features --features "web-ui,tls"

When the feature is disabled, all vault code is compiled out via #[cfg(feature = "vault")]. Environment variables are stored as plaintext, and the vault API endpoints return 404.

Cryptographic Details

ParameterValue
AEAD cipherXChaCha20-Poly1305 (192-bit nonce, 256-bit key)
KDFArgon2id
Argon2id memory64 MiB
Argon2id iterations3
Argon2id parallelism1
DEK size256 bits
Nonce generationRandom per encryption (24 bytes)
AADContext string per data type (e.g. env:MY_KEY)
Key wrappingXChaCha20-Poly1305 (KEK encrypts DEK)
Recovery key128-bit random, 32-char alphanumeric encoding (8 groups of 4)

The nonce is prepended to the ciphertext and stored as base64. AAD (Additional Authenticated Data) binds each ciphertext to its context, preventing an attacker from swapping encrypted values between keys.

Database Schema

The vault uses a single metadata table:

CREATE TABLE IF NOT EXISTS vault_metadata (
    id                   INTEGER PRIMARY KEY CHECK (id = 1),
    version              INTEGER NOT NULL DEFAULT 1,
    kdf_salt             TEXT    NOT NULL,
    kdf_params           TEXT    NOT NULL,
    wrapped_dek          TEXT    NOT NULL,
    recovery_wrapped_dek TEXT,
    recovery_key_hash    TEXT,
    created_at           TEXT    NOT NULL DEFAULT (datetime('now')),
    updated_at           TEXT    NOT NULL DEFAULT (datetime('now'))
);

The CHECK (id = 1) constraint ensures only one row exists — the vault is a singleton per database.

The gateway migration adds the encrypted column to env_variables:

ALTER TABLE env_variables ADD COLUMN encrypted INTEGER NOT NULL DEFAULT 0;

Security Architecture

Moltis is designed with a defense-in-depth security model. This document explains the key security features and provides guidance for production deployments.

Overview

Moltis runs AI agents that can execute code and interact with external systems. This power requires multiple layers of protection:

  1. Human-in-the-loop approval for dangerous commands
  2. Sandbox isolation for command execution
  3. Channel authorization for external integrations
  4. Rate limiting to prevent resource abuse
  5. Scope-based access control for API authorization

For marketplace-style skill/plugin hardening (trust gating, provenance pinning, drift re-trust, dependency install guards, kill switch, audit log), see Third-Party Skills Security.

Command Execution Approval

By default, Moltis requires explicit user approval before executing potentially dangerous commands. This “human-in-the-loop” design ensures the AI cannot take destructive actions without consent.

How It Works

When the agent wants to run a command:

  1. The command is analyzed against approval policies
  2. If approval is required, the user sees a prompt in the UI
  3. The user can approve, deny, or modify the command
  4. Only approved commands execute

Approval Policies

Configure approval behavior in moltis.toml:

[tools.exec]
approval_mode = "always"  # always require approval
# approval_mode = "smart" # auto-approve safe commands (default)
# approval_mode = "never" # dangerous: never require approval

Recommendation: Keep approval_mode = "smart" (the default) for most use cases. Only use "never" in fully automated, sandboxed environments.

Built-in Dangerous Command Blocklist

Even with approval_mode = "never" or security_level = "full", Moltis maintains a safety floor: a hardcoded set of regex patterns for the most critical destructive commands (e.g. rm -rf /, git reset --hard, DROP TABLE, mkfs, terraform destroy). Matching commands always require approval regardless of configuration.

Users can override specific patterns by adding matching entries to their allowlist in moltis.toml. The blocklist only applies to host execution; sandboxed commands are already isolated.

Destructive Command Guard (dcg)

For broader coverage beyond the built-in blocklist, install the Destructive Command Guard (dcg) as a hook. dcg adds 49+ pattern categories including heredoc/inline-script scanning, database, cloud, and infrastructure patterns.

See Hooks: Destructive Command Guard for setup instructions.

dcg complements (does not replace) sandbox isolation and the approval system.

Sandbox Isolation

Commands execute inside isolated containers (Docker or Apple Container) by default. This protects your host system from:

  • Accidental file deletion or modification
  • Malicious code execution
  • Resource exhaustion (memory, CPU, disk)

See sandbox.md for backend configuration.

Resource Limits

[tools.exec.sandbox.resource_limits]
memory_limit = "512M"
cpu_quota = 1.0
pids_max = 256

Network Isolation

Sandbox containers have no network access by default (no_network = true).

For tasks that need internet access, trusted network mode provides a proxy-filtered allowlist — only connections to explicitly approved domains are permitted. All requests (allowed and denied) are recorded in the network audit log for review.

Channel Authorization

Channels (Telegram, Slack, etc.) allow external parties to interact with your Moltis agent. This requires careful access control.

Sender Allowlisting

When a new sender contacts the agent through a channel, they are placed in a pending queue. You must explicitly approve or deny each sender before they can interact with the agent.

UI: Settings > Channels > Pending Senders

Per-Channel Permissions

Each channel can have different permission levels:

  • Read-only: Sender can ask questions, agent responds
  • Execute: Sender can trigger actions (with approval still required)
  • Admin: Full access including configuration changes

Channel Isolation

Channels run in isolated sessions by default. A malicious message from one channel cannot affect another channel’s session or the main UI session.

Cron Job Security

Scheduled tasks (cron jobs) can run agent turns automatically. Security considerations:

Rate Limiting

To prevent prompt injection attacks from rapidly creating many cron jobs:

[cron]
rate_limit_max = 10           # max jobs per window
rate_limit_window_secs = 60   # window duration (1 minute)

This limits job creation to 10 per minute by default. System jobs (like heartbeat) bypass this limit.

Job Notifications

When cron jobs are created, updated, or removed, Moltis broadcasts events:

  • cron.job.created - A new job was created
  • cron.job.updated - An existing job was modified
  • cron.job.removed - A job was deleted

Monitor these events to detect suspicious automated job creation.

Sandbox for Cron Jobs

Cron job execution uses sandbox isolation by default:

# Per-job configuration
[cron.job.sandbox]
enabled = true              # run in sandbox (default)
# image = "custom:latest"   # optional custom image

Identity Protection

The agent’s identity fields (name, emoji, creature, vibe) are stored in IDENTITY.md YAML frontmatter at the workspace root (data_dir). User profile fields are stored in USER.md YAML frontmatter at the same location. The personality text is stored separately in SOUL.md at the workspace root (data_dir). Tool guidance is stored in TOOLS.md at the workspace root (data_dir) and is injected as workspace context in the system prompt. Modifying identity requires the operator.write scope, not just operator.read.

This prevents prompt injection attacks from subtly modifying the agent’s personality to make it more compliant with malicious requests.

API Authorization

The gateway API uses role-based access control with scopes:

ScopePermissions
operator.readView status, list jobs, read history
operator.writeSend messages, create jobs, modify configuration
operator.adminAll permissions (includes all other scopes)
operator.approvalsHandle command approval requests
operator.pairingManage device/node pairing

API Keys

API keys authenticate external tools and scripts connecting to Moltis. Keys must specify at least one scope — keys without scopes are denied access (least-privilege by default).

Creating API Keys

Web UI: Settings > Security > API Keys

  1. Enter a label describing the key’s purpose
  2. Select the required scopes
  3. Click “Generate key”
  4. Copy the key immediately — it’s only shown once

CLI:

# Scoped key (comma-separated scopes)
moltis auth create-api-key --label "Monitor" --scopes "operator.read"
moltis auth create-api-key --label "Automation" --scopes "operator.read,operator.write"
moltis auth create-api-key --label "CI pipeline" --scopes "operator.admin"

Using API Keys

Pass the key in the connect handshake over WebSocket:

{
  "method": "connect",
  "params": {
    "client": { "id": "my-tool", "version": "1.0.0" },
    "auth": { "api_key": "mk_abc123..." }
  }
}

Or use Bearer authentication for REST API calls:

Authorization: Bearer mk_abc123...

Scope Recommendations

Use CaseRecommended Scopes
Read-only monitoringoperator.read
Automated workflowsoperator.read, operator.write
Approval handlingoperator.read, operator.approvals
Full automationoperator.admin

Best practice: Use the minimum necessary scopes. If a key only needs to read status and logs, don’t grant operator.write.

Backward Compatibility

Existing API keys created without scopes will be denied access until scopes are added. Re-create keys with explicit scopes to restore access.

Encryption at Rest

Sensitive data in the SQLite database (environment variables containing API keys, tokens, etc.) is encrypted at rest using XChaCha20-Poly1305. The encryption key is derived from the user’s password via Argon2id.

The vault initializes when a first password is set (during setup or later in Settings > Authentication), unseals automatically on login, and re-seals on server restart. A recovery key is provided at initialization for emergency access.

When the vault is sealed, a middleware layer blocks API requests with 423 Locked to prevent serving stale data.

For full details on the key hierarchy, vault states, API endpoints, and cryptographic parameters, see Encryption at Rest (Vault).

Network Security

TLS Encryption

HTTPS is enabled by default with auto-generated certificates:

[tls]
enabled = true
auto_generate = true

For production, use certificates from a trusted CA or configure custom certificates.

Origin Validation

WebSocket connections validate the Origin header to prevent cross-site WebSocket hijacking (CSWSH). Connections from untrusted origins are rejected.

SSRF Protection

The web_fetch tool resolves DNS and blocks requests to private IP ranges (loopback, RFC 1918, link-local, CGNAT). This prevents server-side request forgery attacks.

To allow access to trusted private networks (e.g. Docker sibling containers), add their CIDR ranges to ssrf_allowlist:

[tools.web.fetch]
ssrf_allowlist = ["172.22.0.0/16"]

Warning: Only add networks you trust. The allowlist bypasses SSRF protection for the listed ranges. Never add cloud metadata ranges (169.254.169.254/32) unless you understand the risk.

Authentication

Moltis uses a unified auth gate that applies a single check_auth() function to every request. This prevents split-brain bugs where different code paths disagree on auth status.

For full details — including the decision matrix, credential types, API key scopes, session management endpoints, and WebSocket auth — see the dedicated Authentication page.

Three-Tier Model (summary)

TierConditionBehaviour
1Password/passkey is configuredAuth always required (any IP)
2No credentials + direct local connectionFull access (dev convenience)
3No credentials + remote/proxied connectionOnboarding only (setup code required)

HTTP Endpoint Throttling

Moltis includes built-in per-IP endpoint throttling to reduce brute force attempts and traffic spikes, but only when auth is required for the current request.

Throttling is bypassed when a request is already authenticated, when auth is explicitly disabled, or when setup is incomplete and local Tier-2 access is allowed.

Default Limits

ScopeDefault
POST /api/auth/login5 requests per 60 seconds
Other /api/auth/*120 requests per 60 seconds
Other /api/*180 requests per 60 seconds
/ws/chat upgrade30 requests per 60 seconds
/ws upgrade30 requests per 60 seconds

When Limits Are Hit

  • API endpoints return 429 Too Many Requests
  • Responses include Retry-After
  • JSON responses include retry_after_seconds

Reverse Proxy Behavior

When MOLTIS_BEHIND_PROXY=true, throttling is keyed by forwarded client IP headers (X-Forwarded-For, X-Real-IP, CF-Connecting-IP) instead of the direct socket address.

Production Guidance

Built-in throttling is the first layer. For internet-facing deployments, add edge rate limits at your reverse proxy or WAF as a second layer (IP reputation, burst controls, geo rules, bot filtering).

Reverse Proxy Deployments

Running Moltis behind a reverse proxy (Caddy, nginx, Traefik, etc.) requires understanding how authentication interacts with loopback connections.

The problem

When Moltis binds to 127.0.0.1 and a proxy on the same machine forwards traffic to it, every incoming TCP connection appears to originate from 127.0.0.1 — including requests from the public internet. A naive “trust all loopback connections” check would bypass authentication for all proxied traffic.

This is the same class of vulnerability as CVE-2026-25253, which allowed one-click remote code execution on OpenClaw through authentication token exfiltration and cross-site WebSocket hijacking.

How Moltis handles it

Moltis uses the per-request is_local_connection() check described above. Most reverse proxies add forwarding headers or change the Host header, which automatically triggers the “remote” classification.

For proxies that strip all signals (e.g. a bare nginx proxy_pass that rewrites Host to the upstream address and adds no X-Forwarded-For), use the MOLTIS_BEHIND_PROXY environment variable as a hard override:

MOLTIS_BEHIND_PROXY=true moltis

When this variable is set, all connections are treated as remote — no loopback bypass, no exceptions.

Deploying behind a proxy

  1. Set MOLTIS_BEHIND_PROXY=true if your proxy does not add forwarding headers (safest option — eliminates any ambiguity).

  2. Set a password or register a passkey during initial setup. Once a password is configured (Tier 1), authentication is required for all traffic regardless of is_local_connection().

  3. WebSocket proxying must preserve browser origin host info (Host, or X-Forwarded-Host if Host is rewritten). Moltis validates same-origin on WebSocket upgrades to prevent cross-site WebSocket hijacking (CSWSH).

  4. TLS termination should happen at the proxy. Run Moltis with --no-tls (or MOLTIS_NO_TLS=true) in this mode.

    If your browser is being redirected to https://<domain>:13131, Moltis TLS is still enabled while your proxy upstream is plain HTTP.

  5. Advanced TLS upstream mode (optional): if your proxy connects to Moltis using HTTPS upstream (or TCP TLS passthrough), you may keep Moltis TLS enabled. Set MOLTIS_ALLOW_TLS_BEHIND_PROXY=true to acknowledge this non-default setup.

Nginx (direct config example)

If HTTP works but WebSockets fail, make sure your location block includes proxy_http_version 1.1; and upgrade headers.

location / {
    proxy_pass http://172.17.0.1:13131;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Real-IP $remote_addr;

    # WebSocket upgrade support
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
}

If you use $connection_upgrade, define it once in the http {} block:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

Nginx Proxy Manager (known-good headers)

If WebSockets fail behind NPM while HTTP works, ensure:

  • Moltis runs with MOLTIS_BEHIND_PROXY=true
  • For standard edge TLS termination, Moltis runs with --no-tls
  • NPM preserves browser host/origin context

Use this in NPM’s Advanced field:

proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

Upstream scheme guidance:

  • Edge TLS termination (most setups): proxy to http://<moltis-host>:13131 with Moltis started using --no-tls
  • HTTPS upstream / TLS passthrough: proxy to https://<moltis-host>:13131 and set MOLTIS_ALLOW_TLS_BEHIND_PROXY=true

Passkeys Behind Proxies (Host Changes)

WebAuthn passkeys are bound to an RP ID (domain identity), not just the server process. In practice:

  • If users move from one hostname to another, old passkeys for the old host will not authenticate on the new host.
  • If a proxy rewrites Host and does not preserve browser host context, passkey routes can fail with “no passkey config for this hostname”.

For stable proxy deployments, set explicit WebAuthn identity to your public domain:

MOLTIS_BEHIND_PROXY=true
MOLTIS_NO_TLS=true
MOLTIS_WEBAUTHN_RP_ID=chat.example.com
MOLTIS_WEBAUTHN_ORIGIN=https://chat.example.com

Migration guidance when changing host/domain:

  1. Keep password login enabled during migration.
  2. Deploy with the new MOLTIS_WEBAUTHN_RP_ID/MOLTIS_WEBAUTHN_ORIGIN.
  3. Ask users to register a new passkey on the new host.
  4. Remove old passkeys after new-host login is confirmed.

Production Recommendations

1. Enable Authentication

By default, Moltis requires a password when accessed from non-localhost:

[auth]
disabled = false  # keep this false in production

2. Use Sandbox Isolation

Always run with sandbox enabled in production:

[tools.exec.sandbox]
enabled = true
backend = "auto"  # uses strongest available

3. Limit Rate Limits

Tighten rate limits for untrusted environments:

[cron]
rate_limit_max = 5
rate_limit_window_secs = 300  # 5 per 5 minutes

4. Review Channel Senders

Regularly audit approved senders and revoke access for unknown parties.

5. Monitor Events

Watch for these suspicious patterns:

  • Rapid cron job creation
  • Identity modification attempts
  • Unusual command patterns in approval requests
  • New channel senders from unexpected sources

6. Network Segmentation

Run Moltis on a private network or behind a reverse proxy with:

  • IP allowlisting
  • Rate limiting
  • Web Application Firewall (WAF) rules

7. Keep Software Updated

Subscribe to security advisories and update promptly when vulnerabilities are disclosed.

Reporting Security Issues

Report security vulnerabilities privately to the maintainers. Do not open public issues for security bugs.

See the repository’s SECURITY.md for contact information.

Third-Party Skills Security

Third-party skills and plugin repos are powerful and risky. Treat them like untrusted code until reviewed.

Trust Lifecycle

Installed marketplace skills/plugins now use a trust gate:

  • installed - repo is on disk
  • trusted - you explicitly marked the skill as reviewed
  • enabled - skill is active for agent use

You cannot enable untrusted skills.

Provenance Pinning

Moltis records a pinned commit_sha for installed repos:

  • via git rev-parse HEAD after clone
  • via GitHub commits API for tarball fallback installs

The Skills UI shows a short SHA to help review provenance.

Re-Trust on Drift

If local repo HEAD changes from the pinned commit_sha:

  • all skills in that repo are auto-marked trusted=false
  • all skills in that repo are auto-disabled
  • re-enable is blocked until explicit trust is granted again

The UI/API mark this state as source changed.

Dependency Install Guardrails

skills.install_dep now includes hard gates:

  • explicit confirm=true required
  • host installs blocked when sandbox mode is off (unless explicit override)
  • suspicious command chains are blocked by default (for example curl ... | sh, base64 decode chains, quarantine bypass)

For high-risk overrides, require manual review before using allow_risky_install=true.

Emergency Kill Switch

Use skills.emergency_disable to disable all installed third-party skills and plugins immediately.

  • Available in RPC and Skills UI action button
  • Intended for incident response and containment

Security Audit Log

Security-sensitive skill/plugin actions are appended to:

~/.moltis/logs/security-audit.jsonl

Logged events include installs, removals, trust changes, enable/disable, dependency install attempts, and source drift detection.

  1. Keep sandbox enabled (tools.exec.sandbox.mode = "all").
  2. Keep approval mode at least on-miss.
  3. Review SKILL.md and linked scripts before trust.
  4. Prefer pinned, known repos over ad-hoc installs.
  5. Monitor security-audit.jsonl for unusual events.

Running Moltis in Docker

Moltis is available as a multi-architecture Docker image supporting both linux/amd64 and linux/arm64. The image is published to GitHub Container Registry on every release.

Quick Start

docker run -d \
  --name moltis \
  -p 13131:13131 \
  -p 13132:13132 \
  -p 1455:1455 \
  -v moltis-config:/home/moltis/.config/moltis \
  -v moltis-data:/home/moltis/.moltis \
  -v /var/run/docker.sock:/var/run/docker.sock \
  ghcr.io/moltis-org/moltis:latest

Open https://localhost:13131 in your browser and configure your LLM provider to start chatting.

For unattended bootstraps, add MOLTIS_PASSWORD, MOLTIS_PROVIDER, and MOLTIS_API_KEY before first start. That pre-configures auth plus one LLM provider so you can skip the browser setup wizard entirely.

Ports

PortPurpose
13131Gateway (HTTPS) — web UI, API, WebSocket
13132HTTP — CA certificate download for TLS trust
1455OAuth callback — required for OpenAI Codex and other providers with pre-registered redirect URIs

Trusting the TLS certificate

Moltis generates a self-signed CA on first run. Browsers will show a security warning until you trust this CA. Port 13132 serves the certificate over plain HTTP so you can download it:

# Download the CA certificate
curl -o moltis-ca.pem http://localhost:13132/certs/ca.pem

# macOS — add to system Keychain and trust it
sudo security add-trusted-cert -d -r trustRoot \
  -k /Library/Keychains/System.keychain moltis-ca.pem

# Linux (Debian/Ubuntu)
sudo cp moltis-ca.pem /usr/local/share/ca-certificates/moltis-ca.crt
sudo update-ca-certificates

After trusting the CA, restart your browser. The warning will not appear again (the CA persists in the mounted config volume).

Note

When accessing from localhost, no authentication is required. If you access Moltis from a different machine (e.g., over the network), a setup code is printed to the container logs for authentication setup:

docker logs moltis

Volume Mounts

Moltis uses two directories that should be persisted:

PathContents
/home/moltis/.config/moltisConfiguration files: moltis.toml, credentials.json, mcp-servers.json
/home/moltis/.moltisRuntime data: databases, sessions, memory files, logs

You can use named volumes (as shown above) or bind mounts to local directories for easier access to configuration files:

docker run -d \
  --name moltis \
  -p 13131:13131 \
  -p 13132:13132 \
  -p 1455:1455 \
  -v ./config:/home/moltis/.config/moltis \
  -v ./data:/home/moltis/.moltis \
  -v /var/run/docker.sock:/var/run/docker.sock \
  ghcr.io/moltis-org/moltis:latest

With bind mounts, you can edit config/moltis.toml directly on the host.

Docker Socket (Sandbox Execution)

Moltis runs LLM-generated shell commands inside isolated containers for security. When Moltis itself runs in a container, it needs access to the host’s container runtime to create these sandbox containers.

# Recommended for full container isolation
-v /var/run/docker.sock:/var/run/docker.sock

Without the socket mount, Moltis automatically falls back to the restricted-host sandbox, which provides lightweight isolation by clearing environment variables, restricting PATH, and applying resource limits via ulimit. Commands will execute successfully inside the Moltis container but without filesystem or network isolation.

For full container-level isolation (filesystem boundaries, network policies), mount the Docker socket.

If Moltis is itself running in Docker and your data_dir() mount is backed by a different host path than /home/moltis/.moltis, Moltis will try to discover that host path automatically from docker inspect/podman inspect. If that lookup fails, add this to /home/moltis/.config/moltis/moltis.toml inside the container:

[tools.exec.sandbox]
host_data_dir = "/absolute/host/path/to/data"

For a bind mount like -v ./data:/home/moltis/.moltis, use the resolved host path to ./data. Restart Moltis after changing the config so new sandbox containers pick up the corrected mount source.

Security Consideration

Mounting the Docker socket gives the container full access to the Docker daemon. This is equivalent to root access on the host for practical purposes. Only run Moltis containers from trusted sources (official images from ghcr.io/moltis-org/moltis).

Docker Compose

See examples/docker-compose.yml for a complete example:

services:
  moltis:
    image: ghcr.io/moltis-org/moltis:latest
    container_name: moltis
    restart: unless-stopped
    ports:
      - "13131:13131"
      - "13132:13132"
      - "1455:1455"   # OAuth callback (OpenAI Codex, etc.)
    volumes:
      - ./config:/home/moltis/.config/moltis
      - ./data:/home/moltis/.moltis
      - /var/run/docker.sock:/var/run/docker.sock

Coolify (Hetzner/VPS)

For Coolify service stacks, use examples/docker-compose.coolify.yml. It is preconfigured for reverse-proxy deployments (--no-tls) and includes the Docker socket mount for sandboxed command execution.

Key points:

  • Set MOLTIS_PASSWORD in the Coolify UI before first deploy.
  • Set SERVICE_FQDN_MOLTIS_13131 to your app domain.
  • Keep Moltis in --no-tls mode behind Coolify’s reverse proxy. If requests are redirected to :13131, check that TLS is disabled in Moltis.
  • Keep /var/run/docker.sock:/var/run/docker.sock mounted if you want sandbox isolation for exec tools.

Start with:

docker compose up -d
docker compose logs -f moltis  # watch for startup messages

Browser Sandbox in Docker

When Moltis runs inside Docker and launches a sandboxed browser, the browser container is a sibling container on the host. By default, Moltis connects to 127.0.0.1 which only reaches its own loopback, not the browser.

Add container_host to your moltis.toml so Moltis can reach the browser container through the host’s port mapping:

[tools.browser]
container_host = "host.docker.internal"

On Linux, add --add-host to the Moltis container so host.docker.internal resolves to the host:

docker run -d \
  --name moltis \
  --add-host=host.docker.internal:host-gateway \
  -p 13131:13131 \
  -p 13132:13132 \
  -p 1455:1455 \
  -v moltis-config:/home/moltis/.config/moltis \
  -v moltis-data:/home/moltis/.moltis \
  -v /var/run/docker.sock:/var/run/docker.sock \
  ghcr.io/moltis-org/moltis:latest

Alternatively, use the Docker bridge gateway IP directly (container_host = "172.17.0.1" on most Linux setups).

Podman Support

Moltis works with Podman using its Docker-compatible API. Mount the Podman socket instead of the Docker socket:

# Podman rootless
podman run -d \
  --name moltis \
  -p 13131:13131 \
  -p 13132:13132 \
  -p 1455:1455 \
  -v moltis-config:/home/moltis/.config/moltis \
  -v moltis-data:/home/moltis/.moltis \
  -v /run/user/$(id -u)/podman/podman.sock:/var/run/docker.sock \
  ghcr.io/moltis-org/moltis:latest

# Podman rootful
podman run -d \
  --name moltis \
  -p 13131:13131 \
  -p 13132:13132 \
  -p 1455:1455 \
  -v moltis-config:/home/moltis/.config/moltis \
  -v moltis-data:/home/moltis/.moltis \
  -v /run/podman/podman.sock:/var/run/docker.sock \
  ghcr.io/moltis-org/moltis:latest

You may need to enable the Podman socket service first:

# Rootless
systemctl --user enable --now podman.socket

# Rootful
sudo systemctl enable --now podman.socket

Environment Variables

VariableDescription
MOLTIS_CONFIG_DIROverride config directory (default: ~/.config/moltis)
MOLTIS_DATA_DIROverride data directory (default: ~/.moltis)

Example:

docker run -d \
  --name moltis \
  -p 13131:13131 \
  -p 13132:13132 \
  -p 1455:1455 \
  -e MOLTIS_CONFIG_DIR=/config \
  -e MOLTIS_DATA_DIR=/data \
  -v ./config:/config \
  -v ./data:/data \
  -v /var/run/docker.sock:/var/run/docker.sock \
  ghcr.io/moltis-org/moltis:latest

API Keys and the [env] Section

Features like web search (Brave), embeddings, and LLM provider API calls read keys from process environment variables (std::env::var). In Docker, there are three ways to provide these:

Option 1: Generic first-run LLM bootstrap (best for one provider)

Use this when you want a minimal docker compose file with one chat provider and no manual setup:

services:
  moltis:
    image: ghcr.io/moltis-org/moltis:latest
    environment:
      MOLTIS_PASSWORD: "change-me"
      MOLTIS_PROVIDER: "openai"
      MOLTIS_API_KEY: "sk-..."

MOLTIS_PROVIDER must be a Moltis provider name such as openai, anthropic, gemini, groq, openrouter, or mistral. The shorter aliases PROVIDER and API_KEY also work, but the MOLTIS_* names are preferred because they are less likely to collide with other containers.

Option 2: Provider-specific docker -e flags (takes precedence for that provider)

docker run -d \
  --name moltis \
  -e BRAVE_API_KEY=your-key \
  -e OPENROUTER_API_KEY=sk-or-... \
  ...
  ghcr.io/moltis-org/moltis:latest

Option 3: [env] section in moltis.toml

Add an [env] section to your config file. These variables are injected into the Moltis process at startup, making them available to all features:

[env]
BRAVE_API_KEY = "your-brave-key"
OPENROUTER_API_KEY = "sk-or-..."

If a variable is set both via docker -e and [env], the Docker/host environment value wins — [env] never overwrites existing variables.

Settings UI env vars

Environment variables set through the Settings UI (Settings > Environment) are stored in SQLite. At startup, Moltis injects them into the process environment so they are available to all features (search, embeddings, provider API calls), not just sandbox commands.

Precedence order (highest wins):

  1. Host / docker -e environment variables
  2. Config file [env] section
  3. Settings UI environment variables

Building Locally

To build the Docker image from source:

# Single architecture (current platform)
docker build -t moltis:local .

# Multi-architecture (requires buildx)
docker buildx build --platform linux/amd64,linux/arm64 -t moltis:local .

OrbStack

OrbStack on macOS works identically to Docker — use the same socket path (/var/run/docker.sock). OrbStack’s lightweight Linux VM provides good isolation with lower resource usage than Docker Desktop.

Troubleshooting

“Cannot connect to Docker daemon”

The Docker socket is not mounted or the Moltis user doesn’t have permission to access it. Verify:

docker exec moltis ls -la /var/run/docker.sock

Setup code not appearing in logs (for network access)

The setup code only appears when accessing from a non-localhost address. If you’re accessing from the same machine via localhost, no setup code is needed. For network access, wait a few seconds for the gateway to start, then check logs:

docker logs moltis 2>&1 | grep -i setup

OAuth authentication error (OpenAI Codex)

If clicking Connect for OpenAI Codex shows “unknown_error” on OpenAI’s page, port 1455 is not reachable from your browser. Make sure you published it:

-p 1455:1455

If you’re running Moltis on a remote server (cloud VM, VPS) and accessing it over the network, localhost:1455 on the browser side points to your local machine — not the server. In that case, authenticate via the CLI instead:

docker exec -it moltis moltis auth login --provider openai-codex

The CLI opens a browser on the machine where you run the command and handles the OAuth callback locally. If automatic callback capture fails, Moltis prompts you to paste the callback URL (or code#state) into the terminal. Tokens are saved to the config volume and picked up by the running gateway automatically.

Permission denied on bind mounts

When using bind mounts, ensure the directories exist and are writable:

mkdir -p ./config ./data
chmod 755 ./config ./data

The container runs as user moltis (UID 1000). If you see permission errors, you may need to adjust ownership:

sudo chown -R 1000:1000 ./config ./data

Cloud Deploy

Moltis publishes a multi-arch Docker image (linux/amd64 and linux/arm64) to ghcr.io/moltis-org/moltis. You can deploy it to any cloud provider that supports container images.

Common configuration

All cloud providers terminate TLS at the edge, so Moltis must run in plain HTTP mode. The key settings are:

SettingValuePurpose
--no-tls or MOLTIS_NO_TLS=trueDisable TLSProvider handles HTTPS
--bind 0.0.0.0Bind all interfacesRequired for container networking
--port <PORT>Listen portMust match provider’s expected internal port
MOLTIS_CONFIG_DIR=/data/configConfig directoryPersist moltis.toml, credentials
MOLTIS_DATA_DIR=/dataData directoryPersist databases, sessions, memory
MOLTIS_DEPLOY_PLATFORMDeploy platformHides local-only providers (see below)
MOLTIS_PASSWORDInitial passwordSet auth password via environment variable

Tip

If requests to your domain are redirected to :13131, Moltis TLS is still enabled behind a TLS-terminating proxy. Use --no-tls (or MOLTIS_NO_TLS=true).

Only keep Moltis TLS enabled when your proxy talks HTTPS to Moltis (or uses TCP TLS passthrough). In that case, set MOLTIS_ALLOW_TLS_BEHIND_PROXY=true.

Warning

Sandbox limitation: Most cloud providers do not support Docker-in-Docker. The sandboxed command execution feature (where the LLM runs shell commands inside isolated containers) will not work on these platforms. The agent will still function for chat, tool calls that don’t require shell execution, and MCP server connections.

MOLTIS_DEPLOY_PLATFORM

Set this to the name of your cloud provider (e.g. flyio, digitalocean, render). When set, Moltis hides local-only LLM providers (local-llm and Ollama) from the provider setup page since they cannot run on cloud VMs. The included deploy templates for Fly.io, DigitalOcean, and Render already set this variable.

Coolify (self-hosted, e.g. Hetzner)

Coolify deployments can run Moltis with sandboxed exec tools, as long as the service mounts the host Docker socket.

  • Use examples/docker-compose.coolify.yml as a starting point.
  • Run Moltis with --no-tls (Coolify terminates HTTPS at the proxy).
  • Set MOLTIS_BEHIND_PROXY=true so client IP/auth behavior is correct behind reverse proxying.
  • Mount /var/run/docker.sock:/var/run/docker.sock to enable container-backed sandbox execution.

Fly.io

The repository includes a fly.toml ready to use.

Quick start

# Install the Fly CLI if you haven't already
curl -L https://fly.io/install.sh | sh

# Launch from the repo (uses fly.toml)
fly launch --image ghcr.io/moltis-org/moltis:latest

# Set your password
fly secrets set MOLTIS_PASSWORD="your-password"

# Create persistent storage
fly volumes create moltis_data --region iad --size 1

How it works

  • Image: pulled from ghcr.io/moltis-org/moltis:latest
  • Port: internal 8080, Fly terminates TLS and routes HTTPS traffic
  • Storage: a Fly Volume mounted at /data persists the database, sessions, and memory files
  • Auto-scaling: machines stop when idle and start on incoming requests

Custom domain

fly certs add your-domain.com

Then point a CNAME to your-app.fly.dev.

DigitalOcean App Platform

Deploy to DO

Click the button above or create an app manually:

  1. Go to Apps > Create App
  2. Choose Container Image as source
  3. Set image to ghcr.io/moltis-org/moltis:latest
  4. Set the run command: moltis --bind 0.0.0.0 --port 8080 --no-tls
  5. Set environment variables:
    • MOLTIS_DATA_DIR = /data
    • MOLTIS_PASSWORD = your password
  6. Set the HTTP port to 8080

No persistent disk

DigitalOcean App Platform does not support persistent disks for image-based services in the deploy template. Data will be lost on redeployment. For persistent storage, consider using a DigitalOcean Droplet with Docker instead.

Render

Deploy to Render

The repository includes a render.yaml blueprint. Click the button above or:

  1. Go to Dashboard > New > Blueprint
  2. Connect your fork of the Moltis repository
  3. Render will detect render.yaml and configure the service

Configuration details

  • Port: Render uses port 10000 by default
  • Persistent disk: 1 GB mounted at /data (included in the blueprint)
  • Environment: set MOLTIS_PASSWORD in the Render dashboard under Environment > Secret Files or Environment Variables

OAuth Providers (OpenAI Codex, GitHub Copilot)

OAuth providers that redirect to localhost (like OpenAI Codex) cannot complete the browser flow when Moltis runs on a remote server — localhost on the user’s browser points to their own machine, not the cloud instance.

Use the CLI to authenticate instead:

# Fly.io
fly ssh console -C "moltis auth login --provider openai-codex"

# DigitalOcean (Droplet with Docker)
docker exec -it moltis moltis auth login --provider openai-codex

# Generic container
docker exec -it <container> moltis auth login --provider openai-codex

The CLI opens a browser on the machine where you run the command. If automatic callback capture fails, Moltis prompts you to paste the callback URL (or code#state) directly in the terminal. After you log in, tokens are saved to the config volume and the running gateway picks them up automatically — no restart needed.

Tip

GitHub Copilot uses device-flow authentication (a code you enter on github.com), so it works from the web UI without this workaround.

Authentication

On first launch, Moltis requires a password or passkey to be set. In cloud deployments the easiest approach is to set the MOLTIS_PASSWORD environment variable (or secret) before deploying. This pre-configures the password so the setup code flow is skipped.

# Fly.io
fly secrets set MOLTIS_PASSWORD="your-secure-password"

For Render and DigitalOcean, set the variable in the dashboard’s environment settings.

Health checks

All provider configs use the /health endpoint which returns HTTP 200 when the gateway is ready. Configure your provider’s health check to use:

  • Path: /health
  • Method: GET
  • Expected status: 200

System Prompt Architecture

The system prompt sent to the LLM is assembled dynamically from multiple components. Each piece is optional and loaded only when relevant, keeping the prompt compact while adapting to the current session context.

Assembly Order

The prompt is built in crates/agents/src/prompt.rs by build_system_prompt_full(). Components are appended in this order:

  1. Base introduction — one-liner announcing tool access (or not)
  2. Agent identity — name, emoji, creature, vibe from IDENTITY.md
  3. Soul — personality directives from SOUL.md (or built-in default)
  4. User profile — user’s name from USER.md
  5. Project contextCLAUDE.md / CLAUDE.local.md / .claude/rules/*.md walked up the directory tree
  6. Runtime context — host info, sandbox config, execution routing hints
  7. Skills listing — available skills as XML block
  8. Workspace filesAGENTS.md and TOOLS.md from the data directory
  9. Long-term memory hint — added when memory tools are registered
  10. Tool schemas — compact list (native) or full JSON (fallback)
  11. Tool-calling format — JSON block instructions (fallback providers only)
  12. Guidelines — tool usage guidance, silent reply protocol

Components in Detail

Base Introduction

A single sentence that sets the assistant role:

  • With tools: “You are a helpful assistant. You can use tools when needed.”
  • Without tools: “You are a helpful assistant. Answer questions clearly and concisely.”

Agent Identity (IDENTITY.md)

Loaded from ~/.moltis/IDENTITY.md using YAML frontmatter. Fields:

FieldPrompt output
name + emoji“Your name is {name} {emoji}.”
creature“You are a {creature}.”
vibe“Your vibe: {vibe}.”

All fields are optional. When identity is present, the soul section is always included.

Soul (SOUL.md)

Loaded from ~/.moltis/SOUL.md. When the file is absent or empty, the built-in DEFAULT_SOUL is used. The default is sourced from OpenClaw’s SOUL.md template:

SOUL.md - Who You Are

You’re not a chatbot. You’re becoming someone.

Core Truths

Be genuinely helpful, not performatively helpful. Skip the “Great question!” and “I’d be happy to help!” — just help. Actions speak louder than filler words.

Have opinions. You’re allowed to disagree, prefer things, find stuff amusing or boring. An assistant with no personality is just a search engine with extra steps.

Be resourceful before asking. Try to figure it out. Read the file. Check the context. Search for it. Then ask if you’re stuck. The goal is to come back with answers, not questions.

Earn trust through competence. Your human gave you access to their stuff. Don’t make them regret it. Be careful with external actions (emails, tweets, anything public). Be bold with internal ones (reading, organizing, learning).

Remember you’re a guest. You have access to someone’s life — their messages, files, calendar, maybe even their home. That’s intimacy. Treat it with respect.

Boundaries — Private things stay private. When in doubt, ask before acting externally. Never send half-baked replies to messaging surfaces. You’re not the user’s voice — be careful in group chats.

Vibe — Be the assistant you’d actually want to talk to. Concise when needed, thorough when it matters. Not a corporate drone. Not a sycophant. Just… good.

Continuity — Each session, you wake up fresh. These files are your memory. Read them. Update them. They’re how you persist. If you change this file, tell the user — it’s your soul, and they should know.

The default soul is ~1,500 characters (~400 tokens).

User Profile (USER.md)

Loaded from ~/.moltis/USER.md using YAML frontmatter.

  • name is injected as: “The user’s name is {name}.”
  • timezone is used by runtime context to localize Host: time=... and Host: today=... fields.

Project Context

Resolved by moltis_projects::context::load_context_files(). The loader walks from the project directory upward to the filesystem root, collecting:

  • CLAUDE.md
  • CLAUDE.local.md
  • .claude/rules/*.md
  • AGENTS.md

Files are merged outermost-first (root before project directory), so project-specific instructions override workspace-level ones.

Runtime Context

Injected as compact key=value lines under a ## Runtime heading:

Host: host=moltis-devbox | os=macos | arch=aarch64 | shell=zsh | time=2026-02-17 16:18:00 CET | today=2026-02-17 | provider=openai | model=gpt-5 | session=main | sudo_non_interactive=true | timezone=Europe/Paris
Sandbox(exec): enabled=true | mode=all | backend=docker | scope=session | image=moltis-sandbox:abc123 | workspace_mount=ro | network=disabled

For channel-bound sessions, the host line also includes surface metadata so the LLM knows where it is operating, for example:

Host: ... | session=telegram:bot-main:123456 | surface=telegram | session_kind=channel | channel_type=telegram | channel_account=bot-main | channel_chat_id=123456 | channel_chat_type=private

When tools are included, an Execution routing block explains how exec routes commands between sandbox and host.

The runtime context is populated at request time in chat.rs by detecting:

  • Host name, OS, architecture, shell
  • Active LLM provider and model
  • Session key
  • Runtime surface and session kind (web, channel, cron, heartbeat)
  • Channel binding metadata (channel_type, channel_account, channel_chat_id, channel_chat_type) when available
  • Sudo availability
  • Timezone and accept-language from the browser
  • Geolocation (from browser or USER.md)
  • Sandbox configuration from the sandbox router

Skills

When skills are registered, they are listed as an XML block generated by moltis_skills::prompt_gen::generate_skills_prompt():

## Available Skills
<available_skills>
<skill name="commit" source="skill" path="/skills/commit">
Create git commits
</skill>
</available_skills>

Workspace Files

Optional markdown files from the data directory (~/.moltis/):

  • AGENTS.md — workspace-level agent instructions
  • TOOLS.md — tool preferences and guidance

Each is rendered under ## Workspace Files with its own ### subheading. Leading HTML comments (<!-- ... -->) are stripped before injection.

Tool Schemas

How tools are described depends on whether the provider supports native tool calling:

  • Native tools (native_tools=true): compact one-liner per tool with description truncated to 160 characters. Full JSON schemas are sent via the provider’s tool-calling API.
  • Fallback (native_tools=false): full JSON parameter schemas are inlined in the prompt, followed by instructions for emitting tool_call JSON blocks.

Guidelines and Silent Replies

The final section contains:

  • Tool usage guidelines (conversation first, when to use exec/browser, /sh explicit shell prefix)
  • A reminder not to parrot raw tool output
  • Silent reply protocol: when tool output speaks for itself, the LLM should return an empty response rather than acknowledging it

Entry Points

FunctionUse case
build_system_prompt()Simple: tools + optional project context
build_system_prompt_with_session_runtime()Full: identity, soul, user, skills, runtime, tools
build_system_prompt_minimal_runtime()No tools (e.g. title generation, summaries)

Size Estimates

Configuration~Characters~Tokens
Minimal (no tools, no context)20050
Soul + identity + guidelines2,000500
Typical with tools5,0001,250
Full (tools + project context + skills)7,000-10,0001,750-2,500
Large (many MCP tools + full context)12,000-15,0003,000-3,750

A typical session with a few tools and project context lands around 6k characters (~1,500 tokens), which is well within normal range for production agents (most use 2k-8k tokens for their system prompt).

The biggest variable-size contributors are tool schemas (especially with many MCP servers) and project context (deep directory hierarchies with multiple CLAUDE.md files). These are worth auditing if prompt costs are a concern.

File Locations

~/.moltis/
├── IDENTITY.md          # Agent identity (name, emoji, creature, vibe)
├── SOUL.md              # Personality directives
├── USER.md              # User profile (name, timezone, location)
├── AGENTS.md            # Workspace agent instructions
└── TOOLS.md             # Tool preferences

<project>/
├── CLAUDE.md            # Project instructions
├── CLAUDE.local.md      # Local overrides (gitignored)
└── .claude/rules/*.md   # Additional rule files

Key Source Files

  • crates/agents/src/prompt.rs — prompt assembly logic and DEFAULT_SOUL
  • crates/gateway/src/chat.rsload_prompt_persona(), runtime context detection, project context resolution
  • crates/config/src/loader.rs — file loading (load_soul(), load_agents_md(), load_identity(), etc.)
  • crates/projects/src/context.rsCLAUDE.md hierarchy walker
  • crates/skills/src/prompt_gen.rs — skills XML generation

Streaming Architecture

This document explains how streaming responses work in Moltis, from the LLM provider through to the web UI.

Overview

Moltis supports real-time token streaming for LLM responses, providing a much better user experience than waiting for the complete response. Streaming works even when tools are enabled, allowing users to see text as it arrives while tool calls are accumulated and executed.

Components

1. StreamEvent Enum (crates/agents/src/model.rs)

The StreamEvent enum defines all events that can occur during a streaming LLM response:

#![allow(unused)]
fn main() {
pub enum StreamEvent {
    /// Text content delta - a chunk of text from the LLM.
    Delta(String),

    /// A tool call has started (for providers with native tool support).
    ToolCallStart { id: String, name: String, index: usize },

    /// Streaming delta for tool call arguments (JSON fragment).
    ToolCallArgumentsDelta { index: usize, delta: String },

    /// A tool call's arguments are complete.
    ToolCallComplete { index: usize },

    /// Stream completed successfully with token usage.
    Done(Usage),

    /// An error occurred.
    Error(String),
}
}

2. LlmProvider Trait (crates/agents/src/model.rs)

The LlmProvider trait defines two streaming methods:

  • stream() - Basic streaming without tool support
  • stream_with_tools() - Streaming with tool schemas passed to the API

Providers that support streaming with tools (like Anthropic) override stream_with_tools(). Others fall back to stream() which ignores the tools parameter.

3. Anthropic Provider (crates/agents/src/providers/anthropic.rs)

The Anthropic provider implements streaming by:

  1. Making a POST request to /v1/messages with "stream": true
  2. Reading Server-Sent Events (SSE) from the response
  3. Parsing events and yielding appropriate StreamEvent variants:
SSE Event TypeStreamEvent
content_block_start (text)(none, just tracking)
content_block_start (tool_use)ToolCallStart
content_block_delta (text_delta)Delta
content_block_delta (input_json_delta)ToolCallArgumentsDelta
content_block_stopToolCallComplete (for tool blocks)
message_delta(usage tracking)
message_stopDone
errorError

4. Agent Runner (crates/agents/src/runner.rs)

The run_agent_loop_streaming() function orchestrates the streaming agent loop:

┌─────────────────────────────────────────────────────────┐
│                    Agent Loop                           │
│                                                         │
│  1. Call provider.stream_with_tools()                   │
│                                                         │
│  2. While stream has events:                            │
│     ├─ Delta(text) → emit RunnerEvent::TextDelta        │
│     ├─ ToolCallStart → accumulate tool call             │
│     ├─ ToolCallArgumentsDelta → accumulate args         │
│     ├─ ToolCallComplete → finalize args                 │
│     ├─ Done → record usage                              │
│     └─ Error → return error                             │
│                                                         │
│  3. If no tool calls → return accumulated text          │
│                                                         │
│  4. Execute tool calls concurrently                     │
│     ├─ Emit ToolCallStart events                        │
│     ├─ Run tools in parallel                            │
│     └─ Emit ToolCallEnd events                          │
│                                                         │
│  5. Append tool results to messages                     │
│                                                         │
│  6. Loop back to step 1                                 │
└─────────────────────────────────────────────────────────┘

5. Gateway (crates/gateway/src/chat.rs)

The gateway’s run_with_tools() function:

  1. Sets up an event callback that broadcasts RunnerEvents via WebSocket
  2. Calls run_agent_loop_streaming()
  3. Broadcasts events to connected clients as JSON frames

Event types broadcast to the UI:

RunnerEventWebSocket State
Thinkingthinking
ThinkingDonethinking_done
TextDelta(text)delta with text field
ToolCallStarttool_call_start
ToolCallEndtool_call_end
Iteration(n)iteration

6. Web Crate (crates/web/)

The moltis-web crate owns the browser-facing layer: HTML templates, static assets (JS, CSS, icons), and the axum routes that serve them. It injects its routes into the gateway via the RouteEnhancer composition pattern, keeping web UI concerns separate from API and agent logic in the gateway.

7. Frontend (crates/web/src/assets/js/)

The JavaScript frontend handles streaming via WebSocket:

  1. websocket.js - Receives WebSocket frames and dispatches to handlers
  2. events.js - Event bus for distributing events to components
  3. state.js - Manages streaming state (streamText, streamEl)

When a delta event arrives:

function handleChatDelta(p, isActive, isChatPage) {
  if (!(p.text && isActive && isChatPage)) return;
  removeThinking();
  if (!S.streamEl) {
    S.setStreamText("");
    S.setStreamEl(document.createElement("div"));
    S.streamEl.className = "msg assistant";
    S.chatMsgBox.appendChild(S.streamEl);
  }
  S.setStreamText(S.streamText + p.text);
  setSafeMarkdownHtml(S.streamEl, S.streamText);
  S.chatMsgBox.scrollTop = S.chatMsgBox.scrollHeight;
}

Data Flow

┌──────────────┐     SSE      ┌──────────────┐   StreamEvent   ┌──────────────┐
│   Anthropic  │─────────────▶│   Provider   │────────────────▶│    Runner    │
│     API      │              │              │                 │              │
└──────────────┘              └──────────────┘                 └──────┬───────┘
                                                                      │
                                                               RunnerEvent
                                                                      │
                                                                      ▼
┌──────────────┐   WebSocket  ┌──────────────┐   Routes/WS   ┌──────────────┐    Callback     ┌──────────────┐
│   Browser    │◀─────────────│  Web Crate   │◀──────────────│   Gateway    │◀────────────────│   Callback   │
│              │              │  (moltis-web)│               │              │                 │   (on_event) │
└──────────────┘              └──────────────┘               └──────────────┘                 └──────────────┘

Adding Streaming to New Providers

To add streaming support for a new LLM provider:

  1. Implement the stream() method (basic streaming)
  2. If the provider supports tools in streaming mode, override stream_with_tools()
  3. Parse the provider’s streaming format and yield appropriate StreamEvent variants
  4. Handle errors gracefully with StreamEvent::Error
  5. Always emit StreamEvent::Done with usage statistics when complete

Example skeleton:

#![allow(unused)]
fn main() {
fn stream_with_tools(
    &self,
    messages: Vec<serde_json::Value>,
    tools: Vec<serde_json::Value>,
) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send + '_>> {
    Box::pin(async_stream::stream! {
        // Make streaming request to provider API
        let resp = self.client.post(...)
            .json(&body)
            .send()
            .await?;

        // Read SSE or streaming response
        let mut byte_stream = resp.bytes_stream();

        while let Some(chunk) = byte_stream.next().await {
            // Parse chunk and yield events
            match parse_event(&chunk) {
                TextDelta(text) => yield StreamEvent::Delta(text),
                ToolStart { id, name, idx } => {
                    yield StreamEvent::ToolCallStart { id, name, index: idx }
                }
                // ... handle other event types
            }
        }

        yield StreamEvent::Done(usage);
    })
}
}

Performance Considerations

  • Unbounded channels: WebSocket send channels are unbounded, so slow clients can accumulate messages in memory
  • Markdown re-rendering: The frontend re-renders full markdown on each delta, which is O(n) work per delta. For very long responses, this can cause UI lag
  • Concurrent tool execution: Multiple tool calls are executed in parallel using futures::join_all(), improving throughput when the LLM requests several tools at once

SQLite Database Migrations

Moltis uses sqlx for database access and its built-in migration system for schema management. Each crate owns its migrations, keeping schema definitions close to the code that uses them.

Architecture

Each crate that uses SQLite has its own migrations/ directory and exposes a run_migrations() function. The gateway orchestrates running all migrations at startup in the correct dependency order.

crates/
├── projects/
│   ├── migrations/
│   │   └── 20240205100000_init.sql   # projects table
│   └── src/lib.rs                     # run_migrations()
├── sessions/
│   ├── migrations/
│   │   └── 20240205100001_init.sql   # sessions, channel_sessions
│   └── src/lib.rs                     # run_migrations()
├── cron/
│   ├── migrations/
│   │   └── 20240205100002_init.sql   # cron_jobs, cron_runs
│   └── src/lib.rs                     # run_migrations()
├── gateway/
│   ├── migrations/
│   │   └── 20240205100003_init.sql   # auth, message_log, channels
│   └── src/server.rs                  # orchestrates moltis.db migrations
└── memory/
    ├── migrations/
    │   └── 20240205100004_init.sql   # files, chunks, embedding_cache, FTS
    └── src/lib.rs                     # run_migrations() (separate memory.db)

How It Works

Migration Ownership

Each crate is autonomous and owns its schema:

CrateDatabaseTablesMigration File
moltis-projectsmoltis.dbprojects20240205100000_init.sql
moltis-sessionsmoltis.dbsessions, channel_sessions20240205100001_init.sql
moltis-cronmoltis.dbcron_jobs, cron_runs20240205100002_init.sql
moltis-gatewaymoltis.dbauth_*, passkeys, api_keys, env_variables, message_log, channels20240205100003_init.sql
moltis-memorymemory.dbfiles, chunks, embedding_cache, chunks_fts20240205100004_init.sql

Startup Sequence

The gateway runs migrations in dependency order:

#![allow(unused)]
fn main() {
// server.rs
moltis_projects::run_migrations(&db_pool).await?;   // 1. projects first
moltis_sessions::run_migrations(&db_pool).await?;   // 2. sessions (FK → projects)
moltis_cron::run_migrations(&db_pool).await?;       // 3. cron (independent)
sqlx::migrate!("./migrations").run(&db_pool).await?; // 4. gateway tables
}

Sessions depends on projects due to a foreign key (sessions.project_id references projects.id), so projects must migrate first.

Version Tracking

sqlx tracks applied migrations in the _sqlx_migrations table:

SELECT version, description, installed_on, success FROM _sqlx_migrations;

Migrations are identified by their timestamp prefix (e.g., 20240205100000), which must be globally unique across all crates.

Database Files

DatabaseLocationCrates
moltis.db~/.moltis/moltis.dbprojects, sessions, cron, gateway
memory.db~/.moltis/memory.dbmemory (separate, managed internally)

Adding New Migrations

Adding a Column to an Existing Table

  1. Create a new migration file in the owning crate:
# Example: adding tags to sessions
touch crates/sessions/migrations/20240301120000_add_tags.sql
  1. Write the migration SQL:
-- 20240301120000_add_tags.sql
ALTER TABLE sessions ADD COLUMN tags TEXT;
CREATE INDEX IF NOT EXISTS idx_sessions_tags ON sessions(tags);
  1. Rebuild to embed the migration:
cargo build

Adding a New Table to an Existing Crate

  1. Create the migration file with a new timestamp:
touch crates/sessions/migrations/20240302100000_session_bookmarks.sql
  1. Write the CREATE TABLE statement:
-- 20240302100000_session_bookmarks.sql
CREATE TABLE IF NOT EXISTS session_bookmarks (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    session_key TEXT NOT NULL,
    name       TEXT NOT NULL,
    message_id INTEGER NOT NULL,
    created_at INTEGER NOT NULL
);

Adding Tables to a New Crate

  1. Create the migrations directory:
mkdir -p crates/new-feature/migrations
  1. Create the migration file with a globally unique timestamp:
touch crates/new-feature/migrations/20240401100000_init.sql
  1. Add run_migrations() to the crate’s lib.rs:
#![allow(unused)]
fn main() {
pub async fn run_migrations(pool: &sqlx::SqlitePool) -> anyhow::Result<()> {
    sqlx::migrate!("./migrations").run(pool).await?;
    Ok(())
}
}
  1. Call it from server.rs in the appropriate order:
#![allow(unused)]
fn main() {
moltis_new_feature::run_migrations(&db_pool).await?;
}

Timestamp Convention

Use YYYYMMDDHHMMSS format for migration filenames:

  • YYYY - 4-digit year
  • MM - 2-digit month
  • DD - 2-digit day
  • HH - 2-digit hour (24h)
  • MM - 2-digit minute
  • SS - 2-digit second

This ensures global uniqueness across crates. When adding migrations, use the current timestamp to avoid collisions.

SQLite Limitations

ALTER TABLE

SQLite has limited ALTER TABLE support:

  • ADD COLUMN: Supported ✓
  • DROP COLUMN: SQLite 3.35+ only
  • Rename column: Requires table recreation
  • Change column type: Requires table recreation

For complex schema changes, use the table recreation pattern:

-- Create new table with desired schema
CREATE TABLE sessions_new (
    -- new schema
);

-- Copy data (map old columns to new)
INSERT INTO sessions_new SELECT ... FROM sessions;

-- Swap tables
DROP TABLE sessions;
ALTER TABLE sessions_new RENAME TO sessions;

-- Recreate indexes
CREATE INDEX idx_sessions_created_at ON sessions(created_at);

Foreign Keys

SQLite foreign keys are checked at insert/update time, not migration time. Ensure migrations run in dependency order (parent table first).

Testing

Unit tests use in-memory databases with the crate’s init() method:

#![allow(unused)]
fn main() {
#[tokio::test]
async fn test_session_operations() {
    let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();

    // Create schema for tests (init() retained for this purpose)
    SqliteSessionMetadata::init(&pool).await.unwrap();

    let meta = SqliteSessionMetadata::new(pool);
    // ... test code
}
}

The init() methods are retained (marked #[doc(hidden)]) specifically for tests. In production, migrations handle schema creation.

Troubleshooting

“failed to run migrations”

  1. Check file permissions on ~/.moltis/
  2. Ensure the database file isn’t locked by another process
  3. Check for syntax errors in migration SQL files

Migration Order Issues

If you see foreign key errors, verify the migration order in server.rs. Parent tables must be created before child tables with FK references.

Checking Migration Status

sqlite3 ~/.moltis/moltis.db "SELECT version, description, success FROM _sqlx_migrations ORDER BY version"

Resetting Migrations (Development Only)

# Backup first!
rm ~/.moltis/moltis.db
cargo run  # Creates fresh database with all migrations

Best Practices

DO

  • Use timestamp-based version numbers for global uniqueness
  • Keep each crate’s migrations in its own directory
  • Use IF NOT EXISTS for idempotent initial migrations
  • Test migrations on a copy of production data before deploying
  • Keep migrations small and focused

DON’T

  • Modify existing migration files after deployment
  • Reuse timestamps across crates
  • Put multiple crates’ tables in one migration file
  • Skip the dependency order in server.rs

Metrics and Tracing

Moltis includes comprehensive observability support through Prometheus metrics and tracing integration. This document explains how to enable, configure, and use these features.

Overview

The metrics system is built on the metrics crate facade, which provides a unified interface similar to the log crate. When the prometheus feature is enabled, metrics are exported in Prometheus text format for scraping by Grafana, Prometheus, or other monitoring tools.

All metrics are feature-gated — they add zero overhead when disabled.

Feature Flags

Metrics are controlled by two feature flags:

FeatureDescriptionDefault
metricsEnables metrics collection and the /api/metrics JSON APIEnabled
prometheusEnables the /metrics Prometheus endpoint (requires metrics)Enabled

Compile-Time Configuration

# Enable only metrics collection (no Prometheus endpoint)
moltis-gateway = { version = "0.1", features = ["metrics"] }

# Enable metrics with Prometheus export (default)
moltis-gateway = { version = "0.1", features = ["metrics", "prometheus"] }

# Enable metrics for specific crates
moltis-agents = { version = "0.1", features = ["metrics"] }
moltis-cron = { version = "0.1", features = ["metrics"] }

To build without metrics entirely:

cargo build --release --no-default-features --features "file-watcher,tailscale,tls,web-ui"

Prometheus Endpoint

When the prometheus feature is enabled, the gateway exposes a /metrics endpoint:

GET http://localhost:18789/metrics

This endpoint is unauthenticated to allow Prometheus scrapers to access it. It returns metrics in Prometheus text format:

# HELP moltis_http_requests_total Total number of HTTP requests handled
# TYPE moltis_http_requests_total counter
moltis_http_requests_total{method="GET",status="200",endpoint="/api/chat"} 42

# HELP moltis_llm_completion_duration_seconds Duration of LLM completion requests
# TYPE moltis_llm_completion_duration_seconds histogram
moltis_llm_completion_duration_seconds_bucket{provider="anthropic",model="claude-3-opus",le="1.0"} 5

Grafana Integration

To scrape metrics with Prometheus and visualize in Grafana:

  1. Add moltis to your prometheus.yml:
scrape_configs:
  - job_name: 'moltis'
    static_configs:
      - targets: ['localhost:18789']
    metrics_path: /metrics
    scrape_interval: 15s
  1. Import or create Grafana dashboards using the moltis_* metrics.

JSON API Endpoints

For the web UI dashboard and programmatic access, authenticated JSON endpoints are available:

EndpointDescription
GET /api/metricsFull metrics snapshot with aggregates and per-provider breakdown
GET /api/metrics/summaryLightweight counts for navigation badges
GET /api/metrics/historyTime-series data points for charts (last hour, 10s intervals)

History Endpoint

The /api/metrics/history endpoint returns historical metrics data for rendering time-series charts:

{
  "enabled": true,
  "interval_seconds": 10,
  "max_points": 60480,
  "points": [
    {
      "timestamp": 1706832000000,
      "llm_completions": 42,
      "llm_input_tokens": 15000,
      "llm_output_tokens": 8000,
      "http_requests": 150,
      "ws_active": 3,
      "tool_executions": 25,
      "mcp_calls": 12,
      "active_sessions": 2
    }
  ]
}

Metrics Persistence

Metrics history is persisted to SQLite, so historical data survives server restarts. The database is stored at ~/.moltis/metrics.db (or the configured data directory).

Key features:

  • 7-day retention: History is kept for 7 days (60,480 data points at 10-second intervals)
  • Automatic cleanup: Old data is automatically removed hourly
  • Startup recovery: History is loaded from the database when the server starts

The storage backend uses a trait-based design (MetricsStore), allowing alternative implementations (e.g., TimescaleDB) for larger deployments.

Storage Architecture

#![allow(unused)]
fn main() {
// The MetricsStore trait defines the storage interface
#[async_trait]
pub trait MetricsStore: Send + Sync {
    async fn save_point(&self, point: &MetricsHistoryPoint) -> Result<()>;
    async fn load_history(&self, since: u64, limit: usize) -> Result<Vec<MetricsHistoryPoint>>;
    async fn cleanup_before(&self, before: u64) -> Result<u64>;
    async fn latest_point(&self) -> Result<Option<MetricsHistoryPoint>>;
}
}

The default SqliteMetricsStore implementation stores data in a single table with an index on the timestamp column for efficient range queries.

Web UI Dashboard

The gateway includes a built-in metrics dashboard at /monitoring in the web UI. This page displays:

Overview Tab:

  • System metrics (uptime, connected clients, active sessions)
  • LLM usage (completions, tokens, cache statistics)
  • Tool execution statistics
  • MCP server status
  • Provider breakdown table
  • Prometheus endpoint (with copy button)

Charts Tab:

  • Token usage over time (input/output)
  • HTTP requests and LLM completions
  • WebSocket connections and active sessions
  • Tool executions and MCP calls

The dashboard uses uPlot for lightweight, high-performance time-series charts. Data updates every 10 seconds for current metrics and every 30 seconds for history.

Available Metrics

HTTP Metrics

MetricTypeLabelsDescription
moltis_http_requests_totalCountermethod, status, endpointTotal HTTP requests
moltis_http_request_duration_secondsHistogrammethod, status, endpointRequest latency
moltis_http_requests_in_flightGaugeCurrently processing requests

LLM/Agent Metrics

MetricTypeLabelsDescription
moltis_llm_completions_totalCounterprovider, modelTotal completions requested
moltis_llm_completion_duration_secondsHistogramprovider, modelCompletion latency
moltis_llm_input_tokens_totalCounterprovider, modelInput tokens processed
moltis_llm_output_tokens_totalCounterprovider, modelOutput tokens generated
moltis_llm_completion_errors_totalCounterprovider, model, error_typeCompletion failures
moltis_llm_time_to_first_token_secondsHistogramprovider, modelStreaming TTFT

Provider Aliases

When you have multiple instances of the same provider type (e.g., separate API keys for work and personal use), you can use the alias configuration option to differentiate them in metrics:

[providers.anthropic]
api_key = "sk-work-..."
alias = "anthropic-work"

# Note: You would need separate config sections for multiple instances
# of the same provider. This is a placeholder for future functionality.

The alias appears in the provider label of all LLM metrics:

moltis_llm_input_tokens_total{provider="anthropic-work", model="claude-3-opus"} 5000
moltis_llm_input_tokens_total{provider="anthropic-personal", model="claude-3-opus"} 3000

This allows you to:

  • Track token usage separately for billing purposes
  • Create separate Grafana dashboards per provider instance
  • Monitor rate limits and quotas independently

MCP (Model Context Protocol) Metrics

MetricTypeLabelsDescription
moltis_mcp_tool_calls_totalCounterserver, toolTool invocations
moltis_mcp_tool_call_duration_secondsHistogramserver, toolTool call latency
moltis_mcp_tool_call_errors_totalCounterserver, tool, error_typeTool call failures
moltis_mcp_servers_connectedGaugeActive MCP server connections

Tool Execution Metrics

MetricTypeLabelsDescription
moltis_tool_executions_totalCountertoolTool executions
moltis_tool_execution_duration_secondsHistogramtoolExecution time
moltis_sandbox_command_executions_totalCounterSandbox commands run

Session Metrics

MetricTypeLabelsDescription
moltis_sessions_created_totalCounterSessions created
moltis_sessions_activeGaugeCurrently active sessions
moltis_session_messages_totalCounterroleMessages by role

Cron Job Metrics

MetricTypeLabelsDescription
moltis_cron_jobs_scheduledGaugeNumber of scheduled jobs
moltis_cron_executions_totalCounterJob executions
moltis_cron_execution_duration_secondsHistogramJob duration
moltis_cron_errors_totalCounterFailed jobs
moltis_cron_stuck_jobs_cleared_totalCounterJobs exceeding 2h timeout
moltis_cron_input_tokens_totalCounterInput tokens from cron runs
moltis_cron_output_tokens_totalCounterOutput tokens from cron runs

Memory/Search Metrics

MetricTypeLabelsDescription
moltis_memory_searches_totalCountersearch_typeSearches performed
moltis_memory_search_duration_secondsHistogramsearch_typeSearch latency
moltis_memory_embeddings_generated_totalCounterproviderEmbeddings created

Channel Metrics

MetricTypeLabelsDescription
moltis_channels_activeGaugeLoaded channel plugins
moltis_channel_messages_received_totalCounterchannelInbound messages
moltis_channel_messages_sent_totalCounterchannelOutbound messages

Telegram-Specific Metrics

MetricTypeLabelsDescription
moltis_telegram_messages_received_totalCounterMessages from Telegram
moltis_telegram_access_control_denials_totalCounterAccess denied events
moltis_telegram_polling_duration_secondsHistogramMessage handling time

OAuth Metrics

MetricTypeLabelsDescription
moltis_oauth_flow_starts_totalCounterOAuth flows initiated
moltis_oauth_flow_completions_totalCounterSuccessful completions
moltis_oauth_token_refresh_totalCounterToken refreshes
moltis_oauth_token_refresh_failures_totalCounterRefresh failures

Skills Metrics

MetricTypeLabelsDescription
moltis_skills_installation_attempts_totalCounterInstallation attempts
moltis_skills_installation_duration_secondsHistogramInstallation time
moltis_skills_git_clone_totalCounterSuccessful git clones
moltis_skills_git_clone_fallback_totalCounterFallbacks to HTTP tarball

Tracing Integration

The moltis-metrics crate includes optional tracing integration via the tracing feature. This allows span context to propagate to metric labels.

Enabling Tracing

moltis-metrics = { version = "0.1", features = ["prometheus", "tracing"] }

Initialization

use moltis_metrics::tracing_integration::init_tracing;

fn main() {
    // Initialize tracing with metrics context propagation
    init_tracing();

    // Now spans will add labels to metrics
}

How It Works

When tracing is enabled, span fields are automatically added as metric labels:

#![allow(unused)]
fn main() {
use tracing::instrument;

#[instrument(fields(operation = "fetch_user", component = "api"))]
async fn fetch_user(id: u64) -> User {
    // Metrics recorded here will include:
    // - operation="fetch_user"
    // - component="api"
    counter!("api_calls_total").increment(1);
}
}

Span Labels

The following span fields are propagated to metrics:

FieldDescription
operationThe operation being performed
componentThe component/module name
span.nameThe span’s target/name

Adding Custom Metrics

In Your Code

Use the metrics macros re-exported from moltis-metrics:

#![allow(unused)]
fn main() {
use moltis_metrics::{counter, gauge, histogram, labels};

// Simple counter
counter!("my_custom_requests_total").increment(1);

// Counter with labels
counter!(
    "my_custom_requests_total",
    labels::ENDPOINT => "/api/users",
    labels::METHOD => "GET"
).increment(1);

// Gauge (current value)
gauge!("my_queue_size").set(42.0);

// Histogram (distribution)
histogram!("my_operation_duration_seconds").record(0.123);
}

Feature-Gating

Always gate metrics code to avoid overhead when disabled:

#![allow(unused)]
fn main() {
#[cfg(feature = "metrics")]
use moltis_metrics::{counter, histogram};

pub async fn my_function() {
    #[cfg(feature = "metrics")]
    let start = std::time::Instant::now();

    // ... do work ...

    #[cfg(feature = "metrics")]
    {
        counter!("my_operations_total").increment(1);
        histogram!("my_operation_duration_seconds")
            .record(start.elapsed().as_secs_f64());
    }
}
}

Adding New Metric Definitions

For consistency, add metric name constants to crates/metrics/src/definitions.rs:

#![allow(unused)]
fn main() {
/// My feature metrics
pub mod my_feature {
    /// Total operations performed
    pub const OPERATIONS_TOTAL: &str = "moltis_my_feature_operations_total";
    /// Operation duration in seconds
    pub const OPERATION_DURATION_SECONDS: &str = "moltis_my_feature_operation_duration_seconds";
}
}

Then use them:

#![allow(unused)]
fn main() {
use moltis_metrics::{counter, my_feature};

counter!(my_feature::OPERATIONS_TOTAL).increment(1);
}

Configuration

Metrics configuration in moltis.toml:

[metrics]
enabled = true              # Enable metrics collection (default: true)
prometheus_endpoint = true  # Expose /metrics endpoint (default: true)
labels = { env = "prod" }   # Add custom labels to all metrics

Environment variables:

  • RUST_LOG=moltis_metrics=debug — Enable debug logging for metrics initialization

Best Practices

  1. Use consistent naming: Follow the pattern moltis_<subsystem>_<metric>_<unit>
  2. Add units to names: _total for counters, _seconds for durations, _bytes for sizes
  3. Keep cardinality low: Avoid high-cardinality labels (like user IDs or request IDs)
  4. Feature-gate everything: Use #[cfg(feature = "metrics")] to ensure zero overhead when disabled
  5. Use predefined buckets: The buckets module has standard histogram buckets for common metric types

Troubleshooting

Metrics not appearing

  1. Verify the metrics feature is enabled at compile time
  2. Check that the metrics recorder is initialized (happens automatically in gateway)
  3. Ensure you’re hitting the correct /metrics endpoint
  4. Check moltis.toml has [metrics] enabled = true

Prometheus endpoint not available

  1. Ensure the prometheus feature is enabled (it’s separate from metrics)
  2. Check your build: cargo build --features prometheus

High memory usage

  • Check for high-cardinality labels (many unique label combinations)
  • Consider reducing histogram bucket counts

Missing labels

  • Ensure labels are passed consistently across all metric recordings
  • Check that tracing spans include the expected fields

Tool Registry

The tool registry manages all tools available to the agent during a conversation. It tracks where each tool comes from and supports filtering by source.

Tool Sources

Every registered tool has a ToolSource that identifies its origin:

  • Builtin — tools shipped with the binary (exec, web_fetch, etc.)
  • Mcp { server } — tools provided by an MCP server, tagged with the server name

This replaces the previous convention of identifying MCP tools by their mcp__ name prefix, providing type-safe filtering instead of string matching.

Registration

#![allow(unused)]
fn main() {
// Built-in tool
registry.register(Box::new(MyTool::new()));

// MCP tool — tagged with server name
registry.register_mcp(Box::new(adapter), "github".to_string());
}

Filtering

When MCP tools are disabled for a session, the registry can produce a filtered copy:

#![allow(unused)]
fn main() {
// Type-safe: filters by ToolSource::Mcp variant
let no_mcp = registry.clone_without_mcp();

// Remove all MCP tools in-place (used during sync)
let removed_count = registry.unregister_mcp();
}

Schema Output

list_schemas() includes source metadata in every tool schema:

{
  "name": "exec",
  "description": "Execute a command",
  "parameters": { ... },
  "source": "builtin"
}
{
  "name": "mcp__github__search",
  "description": "Search GitHub",
  "parameters": { ... },
  "source": "mcp",
  "mcpServer": "github"
}

The source and mcpServer fields are available to the UI for rendering tools grouped by origin.

Lazy Registry Mode

By default every LLM turn includes full JSON schemas for all registered tools. With many MCP servers this can burn 15,000+ tokens per turn. Lazy mode replaces all tool schemas with a single tool_search meta-tool that the model uses to discover and activate tools on demand.

Configuration

[tools]
registry_mode = "lazy"   # default: "full"

How it works

  1. The model receives only tool_search in its tool list.
  2. tool_search(query="memory") returns name + description pairs (max 15), no schemas.
  3. tool_search(name="memory_search") returns the full schema and activates the tool.
  4. On the next iteration the model calls memory_search directly — standard pipeline, hooks fire normally.

The runner re-computes schemas each iteration, so activated tools appear immediately. The iteration limit is tripled in lazy mode to account for the extra discovery round-trips.

When to use

  • Many MCP servers connected (50+ tools)
  • Long conversations where input token cost matters
  • Sub-agent runs that only need a few specific tools

In full mode (default), all schemas are sent every turn — no behavioral change from before this feature.

Agent Presets

Agent presets let spawn_agent run sub-agents with role-specific configuration. Use them to control model cost, tool access, session visibility, and behavior.

Quick Start

[agents.presets.researcher]
identity.name = "scout"
identity.emoji = "🔍"
identity.theme = "thorough and methodical"
model = "anthropic/claude-haiku-3-5-20241022"
tools.allow = ["read_file", "glob", "grep", "web_search", "web_fetch"]
tools.deny = ["exec", "write_file"]
system_prompt_suffix = "Gather facts and report clearly."

[agents.presets.coordinator]
identity.name = "orchestrator"
delegate_only = true
tools.allow = ["spawn_agent", "sessions_list", "sessions_history", "sessions_send", "task_list"]
sessions.can_send = true

Then call spawn_agent with a preset:

{
  "task": "Find all auth-related code paths",
  "preset": "researcher"
}

Config Fields

Top-level:

  • [agents] default_preset (optional preset name)
  • [agents] presets (map of named presets)

Per preset ([agents.presets.<name>]):

  • identity.name, identity.emoji, identity.theme
  • model
  • tools.allow, tools.deny
  • system_prompt_suffix
  • max_iterations, timeout_secs
  • sessions.* access policy
  • memory.scope, memory.max_lines
  • delegate_only

Tool Policy Behavior

  • If tools.allow is empty, all tools start as allowed.
  • If tools.allow is non-empty, only those tools are allowed.
  • tools.deny is applied after allow-list filtering.
  • For normal sub-agents, spawn_agent is always removed to avoid recursive runaway spawning.
  • For delegate_only = true, the registry is restricted to delegation/session tools: spawn_agent, sessions_list, sessions_history, sessions_send, task_list.

Session Access Policy

sessions policy controls what a preset can see/send across sessions:

  • key_prefix: optional session-key prefix filter
  • allowed_keys: explicit allow-list
  • can_send: allow/disallow sessions_send
  • cross_agent: permit cross-agent session access

See Session Tools for full details.

Per-Agent Memory

Each preset can have persistent memory loaded from a MEMORY.md file at spawn time. The memory content is injected into the sub-agent system prompt.

  • memory.scope determines where the file is stored:
    • user (default): ~/.moltis/agent-memory/<preset>/MEMORY.md
    • project: .moltis/agent-memory/<preset>/MEMORY.md
    • local: .moltis/agent-memory-local/<preset>/MEMORY.md
  • memory.max_lines limits how much is injected (default: 200).

The directory is created automatically so agents can write to it.

[agents.presets.researcher.memory]
scope = "project"
max_lines = 100

Model Selection Order

When spawn_agent runs, model choice is:

  1. Explicit model parameter in tool call
  2. Preset model
  3. Parent/default provider model

Markdown Agent Definitions

Presets can also be defined as markdown files with YAML frontmatter, discovered from:

  • ~/.moltis/agents/*.md (user-global)
  • .moltis/agents/*.md (project-local)

Project-local files override user-global files with the same name. TOML presets always take precedence over markdown definitions.

Example ~/.moltis/agents/reviewer.md:

---
name: reviewer
tools: Read, Grep, Glob
model: sonnet
emoji: 🔍
theme: focused and efficient
max_iterations: 20
timeout_secs: 60
---
You are a code reviewer. Focus on correctness and security.

Frontmatter fields: name (required), tools, deny_tools, model, emoji, theme, delegate_only, max_iterations, timeout_secs. The markdown body becomes system_prompt_suffix.

Session Tools

Session tools enable persistent, asynchronous coordination between agent sessions.

Available Tools

sessions_list

List sessions visible to the current policy.

Input:

{
  "filter": "optional text",
  "limit": 20
}

sessions_history

Read message history from a target session.

Input:

{
  "key": "agent:research:main",
  "limit": 20,
  "offset": 0
}

sessions_send

Send a message to another session, optionally waiting for reply.

{
  "key": "agent:coder:main",
  "message": "Please implement JWT middleware",
  "wait_for_reply": true,
  "context": "coordinator"
}

Session Access Policy

Configure policy in a preset to control what sessions a sub-agent can access:

[agents.presets.coordinator]
tools.allow = ["sessions_list", "sessions_history", "sessions_send", "task_list", "spawn_agent"]
sessions.can_send = true

[agents.presets.observer]
tools.allow = ["sessions_list", "sessions_history"]
sessions.key_prefix = "agent:research:"
sessions.can_send = false

Policy fields:

  • key_prefix: restrict visibility by session-key prefix
  • allowed_keys: extra explicit session keys
  • can_send: controls sessions_send (default: true)
  • cross_agent: allow access to sessions owned by other agents (default: false)

When no policy is configured, all sessions are visible and sendable.

Coordination Patterns

Use spawn_agent when work is short-lived and synchronous.

Use session tools when you need:

  • long-lived specialist sessions
  • handoffs with durable history
  • asynchronous team-style orchestration

Common coordinator flow:

  1. sessions_list to discover workers
  2. sessions_history to inspect progress
  3. sessions_send to dispatch next tasks
  4. task_list to track cross-session work items

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]

Added

  • Skill Editing & Forking: Edit personal skills or fork repo skills into personal skills directly from the web UI
    • “Edit” button on personal/project skills opens an inline editor for name, description, allowed tools, and body
    • “Fork & Edit” button on repo skills copies the skill to personal skills for customization
    • Confirmation hint in chat after skill creation/update with link to the skills page

Changed

Deprecated

Removed

Fixed

Security

[0.10.18] - 2026-03-09

Added

  • [gateway] Make provider discovery startup non-blocking
  • [monitoring] Track memory history and improve local-llm memory reporting (#325)
  • [ios] Add local llama cpp memory field to GraphQL schema
  • [providers] Include reasoning fields for kimi models (#323)
  • [chat] Tabs to filter chats between sessions and cron (#338)
  • [oauth] Support pasted callback URL fallback (#365)
  • [providers] Add reasoning effort support for models with extended thinking (#363)

Changed

  • Externalize web/wasm assets and reduce memory footprint (#321)
  • [web] Move chat history hydration to paged HTTP
  • [web] Paginate sessions and auto-load older history

Removed

  • [web] Remove nested onboarding scroll and restore settings nav icons
  • [web] Declutter chat controls and fix dropdown positioning

Fixed

  • [gateway] Speed up startup by deferring tailscale and log scan
  • [gateway] Improve browser warmup integration
  • [scripts] Run local nextest with ci timeout profile
  • [ci] Build macOS app arm64 in fast path
  • [web] Move session history off websocket and cap payload size
  • [web] Use combo select for session header selectors
  • [web] Externalize SVG icons and restore empty-chat centering
  • [web] Align e2e with controls modal and daily model refresh
  • [ci] Stage wasm assets for cargo-deb packaging
  • [packaging] Use cli-relative web assets in cargo-deb
  • Install rustls CryptoProvider before channel startup (#336)
  • [ci,tools] Unblock dependabot and support wasmtime 36
  • [auth] Honor forwarded host for proxy session cookies
  • [config] Include tmux in default sandbox packages
  • [mdns] Use stable host label to avoid mDNSResponder conflict and double-.local suffix (#349)
  • [web] Prevent Enter key from triggering actions during IME composition (#341)
  • [biome] Update schema to 2.4.6 and move noUnusedExpressions to suspicious
  • [ci] Update biome version to 2.4.6 in CI workflows
  • [macos] Extract makeTextView to fix function body length lint violation
  • [providers] Report compatible client_version for Codex model discovery (#359)
  • [prompt] Omit sandbox/node info from runtime prompt when disabled (#362)
  • [web] Allow deleting cron sessions from chat sidebar (#357)
  • [chat] Skip duplicate text fallback when TTS disabled and voice streamed (#373)
  • [web] Break redirect loop when accessing via Tailscale Serve (#356)

Security

  • [nodes] Add multi-node support with device pairing, remote exec, and UI (#291)
  • [security] Add direct nginx websocket proxy example (#364)

[0.10.17] - 2026-03-05

Fixed

  • [config] Include tmux in default sandbox packages

[0.10.16] - 2026-03-05

Fixed

  • [ci,tools] Unblock dependabot and support wasmtime 36
  • [auth] Honor forwarded host for proxy session cookies

[0.10.15] - 2026-03-05

Fixed

  • Install rustls CryptoProvider before channel startup (#336)

[0.10.14] - 2026-03-05

Fixed

  • [packaging] Use cli-relative web assets in cargo-deb

[0.10.13] - 2026-03-04

Fixed

  • [ci] Stage wasm assets for cargo-deb packaging

[0.10.12] - 2026-03-04

Added

  • [ci] Add release dry-run mode
  • [browser] Add container_host for Docker-in-Docker connectivity (#300)
  • [ios] Auto-discover server identity and show emojis (#297)
  • [website] Migrate cloudflare website into monorepo (#302)
  • [local-llm] Allow arbitrary HuggingFace model IDs for MLX models
  • [web,tools] AOT WASM pre-compilation and Shiki CDN loading
  • [cli] Remove wasm from default features to reduce memory
  • [gateway] Make provider discovery startup non-blocking
  • [monitoring] Track memory history and improve local-llm memory reporting (#325)
  • [ios] Add local llama cpp memory field to GraphQL schema

Changed

  • [web] Move settings nav icons from JS to CSS
  • Externalize web/wasm assets and reduce memory footprint (#321)
  • [web] Move chat history hydration to paged HTTP
  • [web] Paginate sessions and auto-load older history

Removed

  • [web] Remove nested onboarding scroll and restore settings nav icons
  • [web] Declutter chat controls and fix dropdown positioning

Fixed

  • [config] Support provider url alias for remote Ollama config (#299)
  • [ci] Make release dry-run job conditions valid
  • [providers] Use Ollama capabilities for tool support detection (#301)
  • [scripts] Roll back heavy local validation parallelism
  • [web] Skip npm install when TAILWINDCSS binary is provided
  • [ci] Update website/releases.json on release
  • [web] Add missing i18n button key for preferredModels
  • [local-llm] Use sampler API for mlx-lm >= 0.20
  • [gateway] Break redirect loop when onboarded but auth not configured (#310)
  • [gateway] Reduce idle CPU from metrics loop and log broadcast feedback
  • [gateway] Speed up startup by deferring tailscale and log scan
  • [gateway] Improve browser warmup integration
  • [scripts] Run local nextest with ci timeout profile
  • [ci] Build macOS app arm64 in fast path
  • [web] Move session history off websocket and cap payload size
  • [web] Use combo select for session header selectors
  • [web] Externalize SVG icons and restore empty-chat centering
  • [web] Align e2e with controls modal and daily model refresh

Security

  • [nodes] Add multi-node support with device pairing, remote exec, and UI (#291)

[0.10.11] - 2026-03-02

[0.10.10] - 2026-03-02

Fixed

  • [swift-bridge] Stabilize gateway migration and stream tests

[0.10.9] - 2026-03-02

Fixed

  • [ci] Harden tailwindcss cli downloads

[0.10.8] - 2026-03-02

Changed

  • [gateway] Fetch updates from releases manifest instead of GitHub API

Fixed

  • [ci] Add Tailwind CSS build step to release workflow, Dockerfile, and snapcraft
  • [e2e] Wait for session history render before DOM injection in chat-abort

[0.10.7] - 2026-03-02

Added

  • [sandbox] Add GitHub runner parity packages and enable corepack (#284)
  • [providers] Add first-class LM Studio provider (#286)
  • [agents] Enrich spawn_agent presets with identity, policies, memory (#271)
  • [web] Show running version at bottom of identity settings
  • [channels] Channel architecture phase 5, contract suites, and observability baseline (#289)

Changed

  • [channels] Registry-driven dispatch for cheap new channels (#277)

Fixed

  • [e2e] Make sandboxes container tests deterministic
  • [e2e] Replace remaining racy waitForResponse with route interceptors
  • [mcp] Make optional MCP tool params nullable to prevent empty string errors (#283)
  • [provider-setup] Reorder validation probes to prefer fast models (#280)
  • [sandbox] Resolve host gateway IP for Podman < 5.0 (#287)
  • [e2e] Fix flaky “deleting unmodified fork” test
  • [ci] Stale lockfile, missing Tailwind in macOS job, OAuth e2e setup
  • [ci] Use standalone Tailwind binary for macOS app job
  • [e2e] Fix OAuth token-exchange failure test and add error-context capture
  • [web] Auto-install node_modules in Tailwind build script
  • [web] Retry openclaw onboarding scan until ws is ready

Security

  • [macos] Wire settings UI to rust config backend (#267)
  • [channels] Shared channel webhook middleware pipeline (#290)

[0.10.6] - 2026-03-01

Fixed

  • [web] Fix Shiki highlighter init failures in E2E tests
  • [web] Make thinking stop button smaller with left spacing
  • [chat] Surface error when LLM returns empty response with zero tokens
  • [providers] Emit StreamEvent::Error on non-success finish_reason

[0.10.5] - 2026-03-01

Fixed

  • [web] Lazy-load Shiki to prevent blocking page mount

[0.10.4] - 2026-03-01

Added

  • [web] Add Shiki syntax highlighting to code blocks

[0.10.3] - 2026-03-01

Added

  • Add channel-aware heartbeat delivery and send_message agent tool (#270)
  • [memory] Add tree-sitter code splitter and RRF search merge

Changed

  • [ffi] Tighten unsafe_code allowances

Fixed

  • [sandbox] Make apple container keepalive portable (#269)
  • [local-llm] Combine compile-time and runtime Metal detection
  • [auth] Auto-detect new WebAuthn hosts and prompt passkey refresh (#268)
  • [web] Replace rg with grep in changelog guard and deduplicate passkey status refresh

[0.10.2] - 2026-02-28

Added

Changed

Deprecated

Removed

Fixed

  • Release packaging now installs cross-compilation targets on the active nightly toolchain in the Homebrew binary job, fixing error[E0463]: can't find crate for core during macOS binary builds.
  • Docker release builds now copy apps/courier into the image build context so Cargo workspace metadata resolves correctly during WASM component builds.

Security

[0.10.1] - 2026-02-28

Added

Changed

Deprecated

Removed

Fixed

Security

[0.10.0] - 2026-02-28

Added

  • Gemini first-class provider: Google Gemini is now registered via the OpenAI-compatible endpoint with native tool calling, vision/multimodal support, streaming, and model discovery. Replaces the previous genai-backed fallback that lacked tool support. Supports both GEMINI_API_KEY and GOOGLE_API_KEY environment variables
  • Podman sandbox backend — Podman as a first-class sandbox backend. Set backend = "podman" or let auto-detection prefer it over Docker (Apple Container → Podman → Docker → restricted-host). Uses the podman CLI directly (no socket compatibility needed)
  • Trusted network mode: sandbox containers now default to sandbox.network = "trusted", routing outbound traffic through an HTTP CONNECT proxy with full audit logging. When trusted_domains is empty (the default), all domains are allowed (audit-only mode); when configured, only listed domains pass without approval. Includes real-time network audit log with domain, protocol, and action filtering via Settings > Network Audit. Configurable via sandbox.trusted_domains in moltis.toml. Proxy env vars (HTTP_PROXY, HTTPS_PROXY, NO_PROXY) are now automatically injected into both Docker and Apple Container sandboxes, and the proxy binds to 0.0.0.0 so it is reachable from container VMs. The proxy rejects connections from non-private IPs (only loopback, RFC 1918, link-local, and CGNAT ranges are accepted)
  • New moltis-network-filter crate: domain filtering, proxy, and audit buffer logic extracted from moltis-tools and moltis-gateway into a standalone crate with feature flags (proxy, service, metrics). The macOS app can now depend on it directly for network audit log display via moltis-swift-bridge
  • macOS Network Audit pane: new Settings > Network Audit section with real-time log display, action filtering (allowed/denied), search, pause/resume, clipboard export, and JSONL download — matching the web UI pattern. New FFI callback moltis_set_network_audit_callback bridges Rust audit entries to Swift
  • Proxy-compliant HTTP tools: all HTTP tools (web_fetch, web_search, location, map) now route through the trusted-network proxy when active, so their traffic appears in the Network Audit log and respects domain filtering. The shared reqwest client is initialized with proxy config at gateway startup; web_fetch uses a per-tool proxy setting for its custom redirect-following client
  • Network policy rename: sandbox.network = "open" has been renamed to "bypass" to make explicit that traffic bypasses the proxy entirely (no audit logging)
  • Real WASM sandbox (wasm feature, default on) — Wasmtime + WASI sandbox with filesystem isolation, fuel metering, epoch-based timeouts, and ~20 built-in coreutils (echo, cat, ls, mkdir, rm, cp, mv, etc.). Two execution tiers: built-in commands operate on a sandboxed directory tree; .wasm modules run via Wasmtime with preopened dirs and captured I/O. Backend: "wasm" in config
  • Restricted-host sandbox — new "restricted-host" backend (extracted from the old WasmtimeSandbox) providing honest naming for what it does: env clearing, restricted PATH, and ulimit resource wrappers without containers or WASM. Always compiled (no feature gate)
  • Docker security hardening — containers now launch with --cap-drop ALL, --security-opt no-new-privileges, tmpfs mounts for /tmp and /run, and --read-only root filesystem for prebuilt images
  • Generic sandbox failover chain — auto-detection now tries Apple Container → Docker → Restricted Host. Failover uses restricted-host as the final fallback instead of NoSandbox
  • Discord channel integration via new moltis-discord crate using serenity Gateway API (persistent WebSocket, no public URL required). Supports DM and group messaging with allowlist/OTP gating, mention mode, guild allowlist, and 2000-char message chunking. Web UI: connect/edit/remove Discord bots in Settings > Channels and onboarding flow
  • Discord reply-to-message support: set reply_to_message = true to have the bot send responses as Discord threaded replies to the user’s message
  • Discord ack reactions: set ack_reaction = "👀" to add an emoji reaction while processing (removed on completion)
  • Discord bot token import from OpenClaw installations during onboarding (both flat and multi-account configs)
  • Discord bot presence/activity: configure activity, activity_type (playing/listening/watching/competing/custom), and status (online/idle/dnd/invisible) in bot config
  • Discord OTP self-approval for DMs: non-allowlisted users receive a 6-digit challenge code (visible in web UI) to self-approve access, matching Telegram’s existing OTP flow
  • Discord native slash commands: /new, /clear, /compact, /context, /model, /sessions, /agent, /help registered as Discord application commands with ephemeral responses
  • OTP module moved from moltis-telegram to shared moltis-channels crate for cross-platform reuse
  • Real-time session sync between macOS app and web UI via SessionEventBus (tokio::sync::broadcast). Sessions created, deleted, or patched in one UI instantly appear in the other. New FFI callback moltis_set_session_event_callback and WebSocket "session" events for create/delete/fork operations.
  • Swift bridge: persistent session storage via FFI — moltis_list_sessions, moltis_switch_session, moltis_create_session, moltis_session_chat_stream functions backed by JSONL files and shared SQLite metadata (moltis.db) across all UIs (macOS app, web, TUI)
  • Internationalization (i18n): web UI now supports runtime language switching via i18next with English and French locales. Error codes use structured constants with locale-aware error messages across API handlers, terminal, chat, and environment routes. Onboarding step labels, navigation buttons, and page strings use translation keys (t() calls)
  • Vault UI: recovery key display during onboarding password setup, vault status/unlock controls in Settings > Security, encrypted/plaintext badges on environment variables
  • Encryption-at-rest vault (vault feature, default on) — environment variables are encrypted with XChaCha20-Poly1305 AEAD using Argon2id-derived keys. Vault is initialized on first password setup and auto-unsealed on login. Recovery key provided at initialization for emergency access. API: /api/auth/vault/status, /api/auth/vault/unlock, /api/auth/vault/recovery
  • send_image tool for sending local image files (PNG, JPEG, GIF, WebP) to channel targets like Telegram, with optional caption support
  • GraphQL API at /graphql (GET serves GraphiQL playground and WebSocket subscriptions, POST handles queries/mutations) exposing all RPC methods as typed operations
  • New moltis-graphql crate with queries, mutations, subscriptions, custom Json scalar, and ServiceCaller trait abstraction
  • New moltis-providers crate that owns provider integrations and model registry/catalog logic (OpenAI, Anthropic, OpenAI-compatible, OpenAI Codex, GitHub Copilot, Kimi Code, local GGUF, local LLM)
  • graphql feature flag (default on) in gateway and CLI crates for compile-time opt-out
  • Settings > GraphQL page embedding GraphiQL playground at /settings/graphql
  • Gateway startup now seeds a built-in dcg-guard hook in ~/.moltis/hooks/dcg-guard/ (manifest + handler), so destructive command guarding is available out of the box once dcg is installed
  • Swift embedding POC scaffold with a new moltis-swift-bridge static library crate, XcodeGen YAML project (apps/macos/project.yml), and SwiftLint wiring for SwiftUI frontend code quality
  • New moltis-openclaw-import crate for detecting OpenClaw installations and selectively importing identity, providers, skills, memory files, Telegram channels, sessions, and MCP servers
  • New onboarding RPC methods: openclaw.detect, openclaw.scan, and openclaw.import
  • New moltis import CLI commands (detect, all, select) with --dry-run and --json output options
  • Onboarding now includes a conditional OpenClaw Import step with category selection, import execution, and detailed per-category results/TODO reporting
  • Settings now includes an OpenClaw Import section (shown only when OpenClaw is detected) for scan-and-import workflows after onboarding
  • Microsoft Teams channel integration via new moltis-msteams plugin crate with webhook ingress and OAuth client-credentials outbound messaging
  • Teams channel management in the web UI (add/edit/remove accounts, sender review, session/channel badges)
  • Guided Teams bootstrap tooling via moltis channels teams bootstrap plus an in-UI endpoint generator in Settings → Channels
  • Multi-agent personas with per-agent workspaces (data_dir()/agents/<id>/), agents.* RPC methods, and session-level agent_id binding/switching across web + Telegram flows
  • chat.peek RPC method returning real-time session state (active flag, thinking text, active tool calls) for any session key
  • Active tool call tracking per-session in LiveChatService with camelCase-serialized ActiveToolCall structs
  • Web UI: inline red “Stop” button inside thinking indicator, aborted broadcast handler that cleans up streaming state
  • Channel commands: /peek (shows thinking text and active tool calls) and /stop (aborts active generation)

Changed

  • Crate restructure: gateway crate reduced from ~42K to ~29K lines by extracting moltis-chat (chat engine, agent orchestration), moltis-auth (password + passkey auth), moltis-tls (TLS/HTTPS termination), moltis-service-traits (shared service interfaces), and moving share rendering into moltis-web
  • Provider wiring now routes through moltis-providers instead of moltis-agents::providers, and local LLM feature flags (local-llm, local-llm-cuda, local-llm-metal) now resolve via moltis-providers
  • Voice now auto-selects the first configured TTS/STT provider when no explicit provider is set.
  • Default voice template/settings now favor OpenAI TTS and Whisper STT in onboarding-ready configs.
  • Updated the dcg-guard example hook docs and handler behavior to gracefully no-op when dcg is missing, instead of hard-failing
  • Automatic model/provider selection now prefers subscription-backed providers (OpenAI Codex, GitHub Copilot) ahead of API-key providers, while still honoring explicit model priorities
  • GraphQL gateway now builds its schema once at startup and reuses it for HTTP and WebSocket requests
  • GraphQL resolvers now share common RPC helper macros and use typed response objects for node.describe, voice.config, voice.voxtral_requirements, skills.security_status, skills.security_scan, and memory.config
  • GraphQL logs.ack mutation now matches backend behavior and no longer takes an ids argument
  • Gateway startup diagnostics now report OpenClaw detection status and pass detection state to web gon data for conditional UI rendering
  • Gateway and CLI now enable the openclaw-import feature in default builds
  • Providers now support stream_transport = "sse" | "websocket" | "auto" in config. OpenAI can stream via Responses API WebSocket mode, and auto falls back to SSE when WebSocket setup is unavailable.
  • Agent Identity emoji picker now includes 🐰 🐹 🦀 🦞 🦝 🦭 🧠 🧭 options
  • Added architecture docs for a native Swift UI app embedding Moltis Rust core through a C FFI bridge (docs/src/native-swift-embedding.md)
  • Channel persistence and message-log queries are now channel-type scoped (channel_type + account_id) so Telegram and Teams accounts can share the same account IDs safely
  • Chat/system prompt resolution is now agent-aware, loading IDENTITY.md, SOUL.md, MEMORY.md, AGENTS.md, and TOOLS.md from the active session agent workspace with backward-compatible fallbacks
  • Memory tool operations and compaction memory writes are now agent-scoped, preventing cross-agent memory leakage during search/read/write flows
  • Default sandbox package set now includes golang-go, and pre-built sandbox images install the latest gog (steipete/gogcli) as gog and gogcli
  • Sandbox config now supports /home/sandbox persistence strategies (off, session, shared), with shared as the default and a shared host folder mounted from data_dir()/sandbox/home/shared
  • Settings → Sandboxes now includes shared-home controls (enabled + folder path), and sandbox config supports tools.exec.sandbox.shared_home_dir for custom shared persistence location

Deprecated

Removed

Fixed

  • OpenAI Codex OAuth in Docker: the web UI no longer overrides the provider’s pre-registered redirect_uri, which caused OpenAI to reject the authorization request with unknown_error. The OAuth callback server now also respects the gateway bind address (0.0.0.0 in Docker) so the callback port (1455) is reachable from the host. Docker image now exposes port 1455 for OAuth callbacks (#207)
  • Slow SQLite writes: moltis.db and memory.db now use journal_mode=WAL and synchronous=NORMAL (matching metrics.db), eliminating multi-second write contention that caused 3–10 s INSERT times under concurrent access
  • Channel image delivery now parses the actual MIME type from data URIs instead of hardcoding image/png
  • Docker image now installs Docker CLI from Docker’s official Debian repository (docker-ce-cli), avoiding API mismatches with newer host daemons during sandbox builds/exec
  • Chat UI now shows a first-run sandbox preparation status message before container/image setup begins, so startup delays are visible while sandbox resources are created
  • OpenAI TTS and Whisper STT now correctly reuse OpenAI credentials from voice config, OPENAI_API_KEY, or the LLM OpenAI provider config.
  • Voice provider parsing now accepts openai-tts and google-tts aliases sent by the web UI.
  • Chat welcome card is now hidden as soon as the thinking indicator appears.
  • Onboarding summary loading state now keeps modal sizing stable with a centered spinner.
  • Onboarding voice provider rows now use a dedicated needs-key badge class and styling, with E2E coverage to verify the badge pill rendering
  • OpenAI Codex OAuth token handling now preserves account context across refreshes and resolves ChatGPT-Account-Id from additional JWT/auth.json shapes to avoid auth failures with Max-style OAuth flows
  • Onboarding/provider setup now surfaces subscription OAuth providers (OpenAI Codex, GitHub Copilot) as configured when local OAuth tokens are present, even if they are omitted from providers.offered
  • GraphQL WebSocket upgrade detection now accepts clients that provide Upgrade/Sec-WebSocket-Key without Connection: upgrade
  • GraphQL channel and memory status bridges now return schema-compatible shapes for channels.status, channels.list, and memory.status
  • Provider errors with insufficient_quota now surface as explicit quota/billing failures (with the upstream message) instead of generic retrying/rate-limit behavior
  • Linux aarch64 builds now skip jemalloc to prevent startup aborts on 16 KiB page-size kernels (for example Raspberry Pi 5 Debian images)
  • Gateway startup now blocks the common reverse-proxy TLS mismatch (MOLTIS_BEHIND_PROXY=true with Moltis TLS enabled) and explains using --no-tls; HTTPS-upstream proxy setups can explicitly opt in with MOLTIS_ALLOW_TLS_BEHIND_PROXY=true
  • WebSocket same-origin checks now accept proxy deployments that rewrite Host by using X-Forwarded-Host in proxy mode, and treat implicit :443/:80 as equivalent to default ports

Security

[0.9.10] - 2026-02-21

Added

Changed

Deprecated

Removed

Fixed

Security

[0.9.9] - 2026-02-21

Added

Changed

Deprecated

Removed

Fixed

Security

[0.9.8] - 2026-02-21

Added

Changed

Deprecated

Removed

Fixed

Security

[0.9.7] - 2026-02-20

Added

Changed

Deprecated

Removed

Fixed

Security

[0.9.6] - 2026-02-20

Added

  • Cron jobs can now deliver agent turn output to Telegram channels via the deliver, channel, and to payload fields

Changed

Deprecated

Removed

Fixed

  • Accessing http:// on the HTTPS port now returns a 301 redirect to https:// instead of a garbled TLS handshake page
  • SQLite metrics store now uses WAL journal mode and synchronous=NORMAL to fix slow INSERT times (1-3s) on Docker/WSL2

Security

[0.9.5] - 2026-02-20

Added

Changed

Deprecated

Removed

Fixed

  • Skip jemalloc on Windows (platform-specific dependency gate)

Security

[0.9.4] - 2026-02-20

Added

Changed

Deprecated

Removed

Fixed

Security

[0.9.3] - 2026-02-20

Added

Changed

Deprecated

Removed

Fixed

Security

[0.9.2] - 2026-02-20

Added

  • Event-driven heartbeat wake system: cron jobs can now trigger immediate heartbeat runs via a wakeMode field ("now" or "nextHeartbeat").
  • System events queue: in-memory bounded buffer that collects events (exec completions, cron triggers) and drains them into the heartbeat prompt so the agent sees what happened while it was idle.
  • Exec completion callback: command executions automatically enqueue a summary event and wake the heartbeat, giving the agent real-time awareness of background task results.

Changed

Deprecated

Removed

Fixed

Security

[0.9.1] - 2026-02-19

Added

  • lightweight feature profile for memory-constrained devices (Raspberry Pi, etc.) with only essential features: jemalloc, tls, web-ui.
  • jemalloc allocator behind jemalloc feature flag for lower memory fragmentation.
  • Configurable history_points (metrics) and log_buffer_size (server) settings to tune in-memory buffer sizes.
  • Persistent browser profiles: cookies, auth state, and local storage now persist across sessions by default. Disable with persist_profile = false in [tools.browser], or set a custom path with profile_dir. (#162)
  • Added examples/docker-compose.coolify.yml plus Docker/cloud deploy docs for self-hosted Coolify (e.g. Hetzner), including reverse-proxy defaults and Docker socket mount guidance for sandboxed exec support.
  • Markdown and ANSI table rendering in chat messages.
  • Provider-aware show_map links for multi-provider map display.
  • Session history caching with visual switch loader for faster session transitions.

Changed

  • MetricsHistory default reduced from 60,480 to 360 points (~170x less memory).
  • LogBuffer default reduced from 10,000 to 1,000 entries.
  • Shared reqwest::Client singleton in moltis-agents and moltis-tools replaces per-call client creation, saving connection pools and TLS session caches.
  • WebSocket client channels changed from unbounded to bounded (512), adding backpressure for slow consumers.
  • Release profile: panic = "abort" and codegen-units = 1 for smaller binaries.

Deprecated

Removed

Fixed

  • Onboarding identity save now captures browser timezone and persists it to USER.md via user_timezone, so first-run profile setup records the user’s timezone alongside their name.
  • Runtime prompt host metadata now prefers user/browser timezone over server local fallback and includes an explicit today=YYYY-MM-DD field so models can reliably reason about the user’s current date.
  • Skills installation now supports Claude marketplace repos that define skills directly via .claude-plugin/marketplace.json plugins[].skills[] paths (for example anthropics/skills), including loading SKILL.md entries under skills/* and exposing them through the existing plugin-skill workflow.
  • Web search no longer falls back to DuckDuckGo by default when search API keys are missing, avoiding repeated CAPTCHA failures; fallback is now opt-in via tools.web.search.duckduckgo_fallback = true.
  • Terminal: force tmux window resize on client viewport change to prevent stale dimensions after reconnect.
  • Browser: profile persistence now works correctly on Apple Container (macOS containerized sandbox).
  • Browser: centralized stale CDP connection detection prevents ghost browser sessions from accumulating. (#172)
  • Gateway: deduplicate voice replies on Telegram channels to prevent echo loops. (#173)
  • Cron job editor: fix modal default validation and form reset when switching schedule type. (#181)
  • MCP: strip internal metadata from tool call arguments before forwarding to MCP servers.
  • Web search: load runtime env keys and improve Brave search response parsing robustness.
  • Prompt: clarify sandbox vs data_dir path semantics in system prompts.
  • Gateway: align show_map listing ratings to the right for consistent layout.

Security

[0.9.0] - 2026-02-17

Added

  • Settings > Cron job editor now supports per-job LLM model selection and execution target selection (host or sandbox), including optional sandbox image override when sandbox execution is selected.

Changed

  • Configuration documentation examples now match the current schema ([server], [identity], [tools], [hooks.hooks], [mcp.servers.<name>], and [channels.telegram.<account>]), including updated provider and local-LLM snippets.

Deprecated

Removed

Fixed

  • Agent loop iteration limit is now configurable via tools.agent_max_iterations in moltis.toml (default 25) instead of being hardcoded at runtime.

Security

[0.8.38] - 2026-02-17

Added

  • show_map now supports multi-point maps via points[], rendering all destinations in one screenshot with auto-fit zoom/centering, while keeping legacy single-point fields for backward compatibility.
  • Telegram channel reply streaming via edit-in-place updates, with per-account stream_mode gating so off keeps the classic final-message delivery path.
  • Telegram per-account stream_notify_on_complete option to send a final non-silent completion message after edit-in-place streaming finishes.
  • Telegram per-account stream_min_initial_chars option (default 30) to delay the first streamed message until enough text has accumulated.

Changed

Deprecated

Removed

Fixed

Security

[0.8.37] - 2026-02-17

Added

  • Settings > Terminal now includes tmux window tabs for the managed moltis-host-terminal session, plus a + Tab action to create new tmux windows from the UI.
  • New terminal window APIs: GET /api/terminal/windows and POST /api/terminal/windows to list and create host tmux windows.
  • Host terminal websocket now supports ?window=<id|index> targeting and returns activeWindowId in the ready payload.

Changed

  • Web chat now supports /sh command mode: entering /sh toggles a dedicated command input state, command sends are automatically prefixed with /sh, and the token bar shows effective execution route (sandboxed vs host) plus prompt symbol (# for root, $ for non-root).
  • Settings > Terminal now polls tmux windows and updates tabs automatically, so windows created inside tmux (for example Ctrl-b c) appear in the web UI.
  • Host terminal tmux integration now uses a dedicated tmux socket and applies a Moltis-friendly profile (status off, mouse off, stable window naming).
  • Settings > Terminal subtitle now omits the prompt symbol hint so it does not show stale $/# information after privilege changes inside the shell.

Deprecated

Removed

Fixed

  • Apple Container sandbox startup now pins --workdir /tmp, bootstraps /home/sandbox before sleep infinity, and uses explicit exec workdirs to avoid WORKDIR chdir failures when image metadata directories are missing.
  • Cron tool job creation/update now accepts common shorthand schedule/payload shapes (including cron expression strings) and normalizes them before validation, reducing model-side schema mismatch failures.
  • Force-exec fallback now triggers only for explicit /sh ... input (including /sh@bot ...), preventing casual chat messages like hey from being treated as shell commands while still allowing normal model-driven exec tool use.
  • Tool-mode system prompt guidance is now conversation-first and documents the /sh explicit shell prefix.
  • Chat auto-compaction now uses estimated next-request prompt tokens (current context pressure) instead of cumulative session token totals, and chat context UI now separates cumulative usage from current/estimated request context.
  • Settings > Terminal tab switching now uses in-band tmux window switching over the active websocket, reducing redraw/cursor corruption when switching between tmux windows (including fullscreen apps like vim).
  • Host terminal tmux attach now resets window sizing to auto (resize-window -A) to prevent stale oversized window dimensions across reconnects.
  • Settings > Terminal tmux window polling now continues after tab switches, so windows closed with Ctrl-D are removed from the tab strip automatically.
  • Settings > Terminal now recovers from stale ?window= reconnect targets after a tmux window is closed, attaching to the current window instead of getting stuck in a reconnect loop.
  • Settings > Terminal host PTY output is now transported as raw bytes (base64-encoded over websocket) instead of UTF-8-decoded text, fixing rendering/control-sequence corruption in full-screen terminal apps like vim.
  • Settings > Terminal now force-syncs terminal size on connect/window switch so newly created tmux windows attach at full viewport size instead of a smaller default geometry.
  • Settings > Terminal now ignores OSC color/palette mutation sequences from full-screen apps to avoid invisible-text redraw glitches when switching tmux tabs (notably seen with vim).
  • Settings > Terminal now re-sends forced resize updates during a short post-connect settle window, fixing initial page-reload cases where tmux windows stayed at stale dimensions until a manual tab switch.

Security

[0.8.36] - 2026-02-16

Added

  • OAuth 2.1 support for remote MCP servers — automatic discovery (RFC 9728/8414), dynamic client registration (RFC 7591), PKCE authorization code flow, and Bearer token injection with 401 retry
  • McpOAuthOverride config option for servers that don’t implement standard OAuth discovery
  • mcp.reauth RPC method to manually trigger re-authentication for a server
  • Persistent storage of dynamic client registrations at ~/.config/moltis/mcp_oauth_registrations.json
  • SSRF allowlist: tools.web.fetch.ssrf_allowlist config field to exempt trusted CIDR ranges from SSRF blocking, enabling Docker inter-container networking.
  • Memory config: add memory.disable_rag to force keyword-only memory search while keeping markdown indexing and memory tools enabled
  • Generic OpenAI-compatible provider support: connect any OpenAI-compatible endpoint via the provider setup UI, with domain-derived naming (custom- prefix), model auto-discovery, and full model selection

Changed

Deprecated

Removed

Fixed

  • Telegram queued replies: route channel reply targets per queued message so chat.message_queue_mode = "followup" delivers replies one-by-one instead of collapsing queued channel replies into a single batch delivery.
  • Queue mode default: make one-by-one replay (followup) explicit as the ChatConfig default, with config-level tests to prevent regressions.
  • MCP OAuth dynamic registration now uses the exact loopback callback URI selected for the current auth flow, improving compatibility with providers that require strict redirect URI matching (for example Linear).
  • MCP manager now applies [mcp.servers.<name>.oauth] override settings when building the OAuth provider for SSE servers.
  • Streamable HTTP MCP transport now persists and reuses Mcp-Session-Id, parses text/event-stream responses, and sends best-effort DELETE on shutdown to close server sessions.
  • MCP docs/config examples now use the current table-based config shape and /mcp endpoint examples for remote servers.
  • Memory embeddings endpoint composition now avoids duplicated path segments like /v1/v1/embeddings and accepts base URLs ending in host-only, /v1, versioned paths (for example /v4), or /embeddings

Security

[0.8.35] - 2026-02-15

Added

  • Add memory target routing guidance to memory_save prompt hint — core facts go to MEMORY.md, everything else to memory/<topic>.md to keep context lean

Changed

Deprecated

Removed

Fixed

Security

[0.8.34] - 2026-02-15

Added

  • Add explicit memory_save hint in system prompt so weaker models (MiniMax, etc.) call the tool when asked to remember something
  • Add anchor text after memory content so models don’t ignore known facts when memory_search returns empty
  • Add zai to default offered providers in config template

Changed

Deprecated

Removed

Fixed

Security

[0.8.33] - 2026-02-15

Added

Changed

Deprecated

Removed

Fixed

  • CI: remove unnecessary std::path:: qualification in gateway server flagged by nightly clippy.

Security

[0.8.32] - 2026-02-15

Added

Changed

Deprecated

Removed

Fixed

  • CI: gate macOS-only sandbox helper functions with #[cfg] to fix dead-code errors on Linux CI.

Security

[0.8.31] - 2026-02-15

Added

  • Sandbox toggle notification: when the sandbox is enabled or disabled mid-session, a system message is injected into the conversation history so the LLM knows the execution environment changed. A chat notice also appears in the UI immediately.

  • Config [env] section: environment variables defined in [env] in moltis.toml are injected into the Moltis process at startup. This makes API keys (Brave, OpenRouter, etc.) available to features that read from std::env::var(). Process env vars (docker -e, host env) take precedence. Closes #107.

  • Browser auto-detection and install: automatically detect all installed Chromium-family browsers (Chrome, Chromium, Edge, Brave, Opera, Vivaldi, Arc) and auto-install via the system package manager when none is found. Requests can specify a preferred browser ("browser": "brave") or let the system pick the first available one.

  • Z.AI provider: add Z.AI (Zhipu) as an OpenAI-compatible provider with static model catalog (GLM-5, GLM-4.7, GLM-4.6, GLM-4.5 series) and dynamic model discovery via /models endpoint. Supports tool calling, streaming, vision (GLM-4.6V/4.5V), and reasoning content.

  • Running Containers panel: the Settings > Sandboxes page now shows a “Running Containers” section listing all moltis-managed containers with live state (running/stopped/exited), backend type (Apple Container/Docker), resource info, and Stop/Delete actions. Includes disk usage display (container/image counts, sizes, reclaimable space) and a “Clean All” button to stop and remove all stale containers at once.

  • Startup container GC: the gateway now automatically removes orphaned session containers on startup, preventing disk space accumulation from crashed or interrupted sessions.

  • Download full context as JSONL: the full context panel now has a “Download” button that exports the conversation (including raw LLM responses) as a timestamped .jsonl file.

  • Sandbox images in cached images list: the Settings > Images page now merges sandbox-built images into the cached images list so all container images are visible in one place.

Changed

  • Sandbox image identity: image tags now use SHA-256 instead of DefaultHasher for deterministic, cross-run hashing of base image + packages.

Deprecated

Removed

Fixed

  • Thinking indicator lost on reload: reloading the page while the model was generating no longer loses the “thinking” dots. The backend now includes replying state in sessions.list and sessions.switch RPC responses so the frontend can restore the indicator after a full page reload.
  • Thinking text restored after reload: reloading the page during extended thinking (reasoning) now restores the accumulated thinking text instead of showing only bouncing dots. The backend tracks thinking text per session and returns it in the sessions.switch response.
  • Apple Container recovery: simplify container recovery to a single flat retry loop (3 attempts max, down from up to 24). Name rotation now only triggers on AlreadyExists errors, preventing orphan containers. Added notFound error matching so exec readiness probes retry correctly. Diagnostic info (running container count, service health, container logs) is now included in failure messages. Detect stale Virtualization.framework state (NSPOSIXErrorDomain EINVAL) and automatically restart the daemon (container system stop && container system start) before retrying; bail with a clear remediation message only if automatic restart fails. Exec-level recovery retries reduced from 3 to 1.
  • Ghost Apple Containers: failed container deletions are now tracked in a zombie set and filtered from list output, preventing stale entries from reappearing in the Running Containers panel.
  • Container action errors preserved: failed delete/clean/restart operations now surface the original error message to the UI instead of silently swallowing it.
  • Usage parsing across OpenAI-compatible providers: token counts now handle Anthropic-style (input_tokens/output_tokens), camelCase variants, cache token fields, and multiple response nesting structures across diverse providers.
  • Think tag whitespace: leading whitespace after </think> close tags is now stripped, preventing extra blank lines in streamed output.
  • Token bar visible at zero: the token usage bar no longer disappears when all counts are zero; it stays visible as a baseline indicator.

Security

[0.8.30] - 2026-02-15

Added

Changed

  • Assistant reasoning persistence: conversation reasoning is now persisted in assistant messages and shared snapshots so resumed sessions retain reasoning context instead of dropping it after refresh/share operations.

Deprecated

Removed

Fixed

Security

[0.8.29] - 2026-02-14

Added

  • Memory bootstrap: inject MEMORY.md content directly into the system prompt (truncated at 20,000 chars) so the agent always has core memory available without needing to call memory_search first. Matches OpenClaw’s bootstrap behavior
  • Memory save tool: new memory_save tool lets the LLM write to long-term memory files (MEMORY.md or memory/<name>.md) with append/overwrite modes and immediate re-indexing for search

Changed

  • Memory writing: MemoryManager now implements the MemoryWriter trait directly, unifying read and write paths behind a single manager. The silent memory turn and MemorySaveTool both delegate to the manager, which handles path validation, size limits, and automatic re-indexing after writes

Deprecated

Removed

Fixed

  • Memory file watcher: the file watcher now covers MEMORY.md at the data directory root, which was previously excluded because the filter only matched directories

Security

[0.8.28] - 2026-02-14

Added

Changed

  • Browser sandbox resolution: BrowserTool now resolves sandbox mode directly from SandboxRouter instead of relying on a _sandbox flag injected via tool call params.

Deprecated

Removed

Fixed

  • E2E onboarding failures: Fixed missing saveProviderKey export in provider-validation.js that was accidentally left unstaged in the DRY refactoring commit.

Security

[0.8.27] - 2026-02-14

Added

Changed

  • DRY voice/identity/channel utils: Extracted shared RPC wrappers and validation helpers from onboarding-view.js and page-settings.js / page-channels.js into dedicated voice-utils.js, identity-utils.js, and channel-utils.js modules.

Deprecated

Removed

Fixed

  • Config test env isolation: Fixed spurious save_config_to_path_removes_stale_keys_when_values_are_cleared test failure caused by MOLTIS_IDENTITY__NAME environment variable leaking into the test via apply_env_overrides.

Security

[0.8.26] - 2026-02-14

Added

  • Rustls/OpenSSL migration roadmap: Added plans/2026-02-14-rustls-migration-and-openssl-reduction.md with a staged plan to reduce OpenSSL coupling, isolate feature gates, and move default TLS networking paths toward rustls.

Changed

Deprecated

Removed

Fixed

  • Windows release build reliability: The .exe release workflow now forces Strawberry Perl (OPENSSL_SRC_PERL/PERL) so vendored OpenSSL builds do not fail due to missing Git Bash Perl modules.
  • OpenAI tool-call ID length: Remap tool-call IDs that exceed OpenAI’s 40-character limit during message serialization, and generate shorter synthetic IDs in the agent runner to prevent API errors.
  • Onboarding credential persistence: Provider credentials are now saved before opening model selection during onboarding, aligning behavior with the Settings > LLM flow.

Security

[0.8.25] - 2026-02-14

Added

Changed

Deprecated

Removed

Fixed

Security

[0.8.24] - 2026-02-13

Added

Changed

Deprecated

Removed

Fixed

Security

[0.8.23] - 2026-02-13

Added

  • Multi-select preferred models per provider: The LLMs page now has a “Preferred Models” button per provider that opens a multi-select modal. Selected models are pinned at the top of the session model dropdown. New providers.save_models RPC accepts multiple model IDs at once.
  • Multi-select model picker in onboarding: The onboarding provider step now uses a multi-select model picker matching the Settings LLMs page. Toggle models on/off, see per-model probe status badges, and batch-save with a single Save button. Previously-saved preferred models are pre-selected when re-opening the model selector.

Changed

  • Model discovery uses DiscoveredModel struct: Replaced (String, String) tuples with a typed DiscoveredModel struct across all providers (OpenAI, GitHub Copilot, OpenAI Codex). The struct carries an optional created_at timestamp from the /v1/models API, enabling discovered models to be sorted newest-first. Preferred/configured models remain pinned at the top.
  • Removed OpenAI-specific model name filtering from discovery: The /v1/models response is no longer filtered by OpenAI naming conventions (gpt-*, o1, etc.). All valid model IDs from any provider are now accepted. This fixes model discovery for third-party providers like Moonshot whose model IDs don’t follow OpenAI naming.
  • Disabled automatic model probe at startup: The background chat completion probe that checked which models are supported is now triggered on-demand by the web UI instead of running automatically 2 seconds after startup. With dynamic model discovery, the startup probe was expensive and noisy (non-chat models like image, audio, and video would log spurious warnings).
  • Model test uses streaming for faster feedback: The “Testing…” probe when selecting a model now uses streaming and returns on the first token instead of waiting for a full non-streaming response. Timeout reduced from 20s to 10s.
  • Chosen models merge with config-defined priority: Models selected via the UI are prepended to the saved models list and merged with config-defined preferred models, so both sources contribute to ordering.
  • Dynamic cross-provider priority list: The model dropdown priority is now a shared Arc<RwLock<Vec<String>>> updated at runtime when models are saved, instead of a static HashMap built once at startup.
  • Replaced hardcoded Ollama checks with keyOptional metadata: JS files no longer check provider.name === "ollama" for behavior. Instead, the backend exposes a keyOptional field on provider metadata, making the UI provider-agnostic.

Fixed

  • Settings UI env vars now available process-wide: environment variables set via Settings > Environment were previously only injected into sandbox commands. They are now also injected into the Moltis process at startup, making them available to web search, embeddings, and provider API calls.

[0.8.14] - 2026-02-11

Security

  • Disconnect all WS clients on credential change: WebSocket connections opened before auth setup are now disconnected when credentials change (password set/changed, passkey registered during setup, auth reset, last credential removed). An auth.credentials_changed event notifies browsers to redirect to /login. Existing sessions are also invalidated on password change for defense-in-depth.

Fixed

  • Onboarding test for SOUL.md clear behavior: Fixed identity_update_partial test to match the new empty-file behavior from v0.8.13.

[0.8.13] - 2026-02-11

Added

  • Auto-create SOUL.md on first run: SOUL.md is now seeded with the default soul text when the file doesn’t exist, mirroring how moltis.toml is auto-created. If deleted, it re-seeds on next load.

Fixed

  • SOUL.md clear via settings: Clearing the soul textarea in settings no longer re-creates the default on the next load. An explicit clear now writes an empty file to distinguish “user cleared soul” from “file never existed”.
  • Onboarding WS connection timing: Deferred WebSocket connection until authentication completes, preventing connection failures during onboarding.

Changed

  • Passkey auth preselection: Onboarding now preselects the passkey authentication method when a passkey is already registered.
  • Moonshot provider: Added Moonshot to the default offered providers list.

[0.8.12] - 2026-02-11

Fixed

  • E2E test CI stability: NoopChatService::clear() now returns Ok instead of an error when no LLM providers are configured, fixing 5 e2e test failures in CI environments. Hardened websocket, chat-input, and onboarding-auth e2e tests against startup race conditions and flaky selectors.

[0.8.8] - 2026-02-11

Changed

  • Sessions sidebar layout: Removed the top Sessions title row and moved the new-session + action next to the session search field for a more compact list header.
  • Identity autosave UX: Name fields in Settings > Identity now autosave on input blur, matching the quick-save behavior used for emoji selection.
  • Favicon behavior by browser: Identity emoji changes now update favicon live; Safari-specific reload guidance is shown only when Safari is detected.
  • Page title format: Browser title now uses the configured assistant name only, without appending AI assistant suffix text.

[0.8.7] - 2026-02-11

Added

  • Model allowlist probe output support: Model allowlist matching now handles provider probe output more robustly and applies stricter matching semantics.
  • Ship helper command: Added a just ship task and scripts/ship-pr.sh helper to streamline commit, push, PR update, and local validation workflows.

Changed

  • Gateway titles and labels: Login/onboarding page titles now consistently use configured values and identity emoji; UI copy now labels providers as LLM where appropriate.
  • Release binary profile: Enabled ThinLTO and binary stripping in the release profile to reduce artifact size.
  • SPA route handling: Centralized SPA route definitions and preserved TOML comments during config updates.

Fixed

  • Auth setup hardening: Enforced authentication immediately after password or passkey setup to prevent unintended post-setup unauthenticated access.
  • Streaming event ordering: Preserved gateway chat stream event ordering to avoid out-of-order UI updates during streaming responses.
  • Sandbox fallback pathing: Exec fallback now uses the host data directory when no container runtime is available.

[0.8.6] - 2026-02-11

Changed

  • Release workflow gates E2E tests: Build Packages workflow now runs E2E tests and blocks all package builds (deb, rpm, arch, AppImage, snap, Homebrew, Docker) if they fail.

Added

  • XML tag stripping: Strip internal XML tags from LLM responses to prevent tag leakage in chat (thinking, reasoning, scratchpad, etc.)
  • Runtime model metadata: Fetch model metadata from provider APIs for accurate context window detection during auto-compaction
  • Run detail UI: Panel showing tool calls and message flow for agent runs, accessible via expandable button on assistant messages

Fixed

  • Docker TLS setup: All Docker examples now expose port 13132 for CA certificate download with instructions to trust the self-signed cert, fixing HTTPS access in Safari and other strict browsers.
  • E2E onboarding-auth test: The auth Playwright project’s testMatch regex /auth\.spec/ also matched onboarding-auth.spec.js, causing it to run against the default gateway (wrong server) instead of the onboarding-auth gateway. Tightened regex to /\/auth\.spec/.

[0.8.5] - 2026-02-11

Added

  • CLI --version flag: moltis --version now prints the version.
  • Askama HTML rendering: SPA index and social metadata templates use Askama instead of string replacement.

Fixed

  • WebSocket reconnect after remote onboarding auth: Connection now reconnects immediately after auth setup instead of waiting for the backoff timer, fixing “WebSocket not connected” errors during identity save.
  • Passkeys on Fly.io: Auto-detect WebAuthn RP ID from FLY_APP_NAME environment variable (constructs {app}.fly.dev).
  • PaaS proxy detection: Added explicit MOLTIS_BEHIND_PROXY=true to render.yaml and fly.toml so auth middleware reliably detects remote connections behind the platform’s reverse proxy.
  • WebAuthn origin scheme on PaaS: Non-localhost RP IDs now default to https:// origin since PaaS platforms terminate TLS at the proxy.

Security

  • Compaction prompt injection hardening: Session compaction now passes typed ChatMessage objects to the summarizer LLM instead of concatenated {role}: {content} text, preventing role-spoofing prompt injection where user content could mimic role prefixes (similar to GHSA-g8p2-7wf7-98mq).

[0.8.4] - 2026-02-11

Changed

  • Session delete UX: Forked sessions with no new messages beyond the fork point are deleted immediately without a confirmation dialog.

Fixed

  • Localhost passkey compatibility: Gateway startup URLs and TLS redirect hints now use localhost for loopback hosts, while WebAuthn also allows moltis.localhost as an additional origin when RP ID is localhost.

[0.8.3] - 2026-02-11

Fixed

  • Linux clippy unused_mut failure: Fixed a target-specific unused_mut warning in browser stale-container cleanup that failed release clippy on Linux with -D warnings.

[0.8.2] - 2026-02-11

Fixed

  • Release clippy environment parity: The release workflow clippy job now runs in the same CUDA-capable environment as main CI, includes the llama source git bootstrap step, and installs rustfmt alongside clippy. This fixes release clippy failures caused by missing CUDA toolchain/runtime.

[0.8.1] - 2026-02-11

Fixed

  • Clippy validation parity: Unified local validation, CI (main), and release workflows to use the same clippy command and flags (--workspace --all-features --all-targets --timings -D warnings), which prevents release-only clippy failures from command drift.

[0.8.0] - 2026-02-11

Added

  • Instance-scoped container naming: Browser and sandbox container/image prefixes now derive from the configured instance name, so multiple Moltis instances do not collide.

Changed

  • Stale container cleanup targeting: Startup cleanup now removes only containers that belong to the active instance prefix instead of sweeping unrelated containers.
  • Apple container runtime probing: Browser container backend checks now use the modern Apple container CLI flow (container image pull --help) without legacy fallback behavior.

Fixed

  • Release workflow artifacts: Disabled docker build record artifact uploads in release CI to avoid release workflow failures from missing artifact paths.
  • Release preflight consistency: Pinned nightly toolchain and aligned release preflight checks with CI formatting/lint gates.

[0.7.1] - 2026-02-11

Fixed

  • Release format gate: Included missing Rust formatting updates in release history so the release workflow cargo fmt --all -- --check passes for tagged builds.

[0.7.0] - 2026-02-11

Added

  • HTTP endpoint throttling: Added gateway-level per-IP rate limits for login (/api/auth/login), auth API routes, general API routes, and WebSocket upgrades, with 429 responses, Retry-After headers, and JSON retry_after_seconds.
  • Login retry UX: The login page now disables the password Sign In button while throttled and shows a live Retry in Xs countdown.

Changed

  • Auth-aware throttling policy: IP throttling is now bypassed when auth is not required for the current request (authenticated requests, auth-disabled mode, and local Tier-2 setup mode). This keeps brute-force protection for unauthenticated/auth-required traffic while avoiding localhost friction.
  • Login error copy: During throttled login retries, the error message stays static while the retry countdown is shown only on the button.

Documentation

  • Added throttling/security notes to README.md, docs/src/index.md, docs/src/authentication.md, and docs/src/security.md.

[0.6.1] - 2026-02-10

Fixed

  • Release clippy: Aligned release workflow clippy command with nightly flags (-Z unstable-options, --timings).
  • Test lint attributes: Fixed useless outer #[allow] on test module use statement; replaced .unwrap() with .expect() in auth route tests.

[0.6.0] - 2026-02-10

Added

  • CalDAV integration: New moltis-caldav crate providing calendar CRUD operations (list calendars, list/create/update/delete events) via the CalDAV protocol. Supports Fastmail, iCloud, and generic CalDAV servers with multi-account configuration under [caldav.accounts.<name>]. Enabled by default via the caldav feature flag.
  • BeforeLLMCall / AfterLLMCall hooks: New modifying hook events that fire before sending prompts to the LLM provider and after receiving responses (before tool execution). Enables prompt injection filtering, PII redaction, and response auditing via shell hooks.
  • Config template: The generated moltis.toml template now lists all 17 hook events with correct PascalCase names and one-line descriptions.
  • Hook event validation: moltis config check now warns on unknown hook event names in the config file.
  • Authentication docs: Comprehensive docs/src/authentication.md with decision matrix, credential types, API key scopes, session endpoints, and WebSocket auth documentation.

Fixed

  • Browser container lifecycle: Browser containers (browserless/chrome) now have proper lifecycle management — periodic cleanup removes idle instances every 30 seconds, graceful shutdown stops all containers on Ctrl+C, and sessions.clear_all immediately closes all browser sessions. A Drop safety net ensures containers are stopped even on unexpected exits.

Changed

  • Unified auth gate: All auth decision logic is now in a single check_auth() function called by one auth_gate middleware. This fixes the split-brain bug where passkey-only setups (no password) were treated differently by 4 out of 5 auth code paths — the middleware used is_setup_complete() (correct) while the others used has_password() (incorrect for passkey-only setups).
  • Hooks documentation: Rewritten docs/src/hooks.md with complete event reference, corrected ToolResultPersist classification (modifying, not read-only), and new “Prompt Injection Filtering” section with examples.
  • Logs level filter UI: Settings -> Logs now shows DEBUG/TRACE level options only when they are enabled by the active tracing filter (including target-specific directives). Default view remains INFO and above.
  • Logs level filter control: Settings -> Logs now uses the same combo dropdown pattern as the chat model selector for consistent UX.
  • Branch favicon contrast: Non-main branches now use a high-contrast purple favicon variant so branch sessions are visually distinct from main.

Security

  • Content-Security-Policy header: HTML pages now include a nonce-based CSP header (script-src 'self' 'nonce-<UUID>'), preventing inline script injection (XSS defense-in-depth). The OAuth callback page also gets a restrictive CSP.
  • Passkey-only auth fix: Fixed authentication bypass where passkey-only setups (without a password) would incorrectly allow unauthenticated access on local connections, because the has_password() check returned false even though is_setup_complete() was true.

[0.5.0] - 2026-02-09

Added

  • moltis doctor command: Comprehensive health check that validates config, audits security (file permissions, API keys in config), checks directory and database health, verifies provider readiness (API keys via config or env vars), inspects TLS certificates, and validates MCP server commands on PATH.

Security

  • npm install –ignore-scripts: Skill dependency installation now passes --ignore-scripts to npm, preventing supply chain attacks via malicious postinstall scripts in npm packages.
  • API key scope enforcement: API keys with empty/no scopes are now denied access instead of silently receiving full admin privileges. Keys must specify at least one scope explicitly (least-privilege by default).

[0.4.1] - 2026-02-09

Fixed

  • Clippy lint in map test: Replace is_some()/unwrap() with if let Some to fix clippy::unnecessary_unwrap that broke the v0.4.0 release build.

[0.4.0] - 2026-02-09

Added

  • Auto-import external OAuth tokens: At startup, auto-detected provider tokens (e.g. Codex CLI ~/.codex/auth.json) are imported into the central oauth_tokens.json store so users can manage all providers from the UI.
  • Passkey onboarding: The security setup step now offers passkey registration (Touch ID, Face ID, security keys) as the recommended default, with password as a fallback option.
  • providers.validate_key RPC method: Test provider credentials without saving them — builds a temporary registry, probes with a “ping” message, and returns validation status with available models.
  • providers.save_model RPC method: Save the preferred model for any configured provider without changing credentials.
  • models.test RPC method: Test a single model from the live registry with a real LLM request before committing to it.
  • Model selection for auto-detected providers: The Providers settings page now shows a “Select Model” button for providers that have available models but no preferred model set. This lets users pick their favorite model for auto-detected providers (e.g. OpenAI Codex detected from ~/.codex/auth.json).
  • show_map tool: New LLM-callable tool that composes a static map image from OSM tiles with red/blue marker pins (destination + user location), plus clickable links to Google Maps, Apple Maps, and OpenStreetMap. Supports user_latitude/user_longitude to show both positions with auto-zoom. Solves the “I can’t share links” problem in voice mode.
  • Location precision modes: The get_user_location tool now accepts a precision parameter — "precise" (GPS-level, default) for nearby places and directions, or "coarse" (city-level, faster) for flights, weather, and time zones. The LLM picks the appropriate mode based on the user’s query.

Changed

  • Show “No LLM Providers Connected” card instead of welcome greeting when no providers are configured.
  • Onboarding provider setup: Credentials are now validated before saving. After successful validation, a model selector shows available models for the provider. The selected model is tested with a real request before completing setup. Clear error messages are shown for common failures (invalid API key, rate limits, connection issues).
  • Settings provider setup: The main Providers settings page now uses the same validate-first flow as onboarding. Credentials are validated before saving (bad keys are never persisted), a model selector appears after validation, and OAuth flows show model selection after authentication.

Fixed

  • Docker RAM detection: Fall back to /proc/meminfo when sysinfo returns 0 bytes for memory inside Docker/cgroup environments.
  • MLX model suggested on Linux: Use backend-aware model suggestion so MLX models are only suggested on Apple Silicon, not on Linux servers.
  • Host package provisioning noise: Skip apt-get when running as non-root with no passwordless sudo, instead of failing with permission denied warnings.
  • Browser image pull without runtime: Guard browser container image pull to skip when no usable container runtime is available (backend = “none”).
  • OAuth token store logging: Replace silent .ok()? chains with explicit warn!/info! logging in TokenStore load/save/delete for diagnosability.
  • Provider warning noise: Downgrade “tokens not found” log from warn! to debug! for unconfigured providers (GitHub Copilot, OpenAI Codex).
  • models.detect_supported noise: Downgrade UNAVAILABLE RPC errors from warn! to debug! since they indicate expected “not ready yet” states.

[0.3.8] - 2026-02-09

Changed

  • Release CI parallelization: Split clippy and test into separate parallel jobs in the release workflow for faster feedback on GitHub-hosted runners.

Fixed

  • CodSpeed workflow zizmor audit: Pinned CodSpeedHQ/action@v4 to commit SHA to satisfy zizmor’s unpinned-uses audit.

[0.3.7] - 2026-02-09

Fixed

  • Clippy warnings: Fixed MutexGuard held across await in telegram test, field assignment outside initializer in provider setup test, and items after test module in gateway services.

[0.3.6] - 2026-02-09

Fixed

  • Release CI zizmor audit: Removed rust-cache from the release workflow’s clippy-test job entirely instead of using save-if: false, which zizmor does not recognize as a cache-poisoning mitigation.

[0.3.5] - 2026-02-09

Fixed

  • Release CI cache-poisoning: Set save-if: false on rust-cache in the release workflow to satisfy zizmor’s cache-poisoning audit for tag-triggered workflows that publish artifacts.

[0.3.4] - 2026-02-09

Fixed

  • Session file lock contention: Replaced non-blocking try_write() with blocking write() in SessionStore::append() and replace_history() so concurrent tool-result persists wait for the file lock instead of failing with EAGAIN (OS error 35).

Changed

  • Release CI quality gates: The Build Packages workflow now runs biome, format, clippy, and test checks before building any packages, ensuring code correctness before artifacts are produced.

[0.3.3] - 2026-02-09

Fixed

  • OpenAI Codex token refresh panic: Made get_valid_token() async to fix block_on inside async runtime panic when refreshing expired OAuth tokens.
  • Channel session binding: Ensure session row exists before setting channel binding, fixing get_user_location failures on first Telegram message.
  • Cargo.lock sync: Lock file now matches workspace version.

[0.3.0] - 2026-02-08

Added

  • Silent replies: The system prompt instructs the LLM to return an empty response when tool output speaks for itself, suppressing empty chat bubbles, push notifications, and channel replies. Empty assistant messages are not persisted to session history.

  • Persist TTS audio to session media: When TTS is enabled and the reply medium is voice, the server generates TTS audio, saves it to the session media directory, and includes the media path in the persisted assistant message. On session reload the frontend renders an <audio> player from the media API instead of re-generating audio via RPC.

  • Per-session media directory: Screenshots from the browser tool are now persisted to sessions/media/<key>/ and served via GET /api/sessions/:key/media/:filename. Session history reload renders screenshots from the API instead of losing them. Media files are cleaned up when a session is deleted.

  • Process tool for interactive terminal sessions: New process tool lets the LLM manage interactive/TUI programs (htop, vim, REPLs, etc.) via tmux sessions inside the sandbox. Supports start, poll, send_keys, paste, kill, and list actions. Includes a built-in tmux skill with usage instructions.

  • Runtime host+sandbox prompt context: Chat system prompts now include a ## Runtime section with host details (hostname, OS, arch, shell, provider, model, session, sudo non-interactive capability) and exec sandbox details (enabled state, mode, backend, scope, image, workspace mount, network policy, session override). Tool-mode prompts also add routing guidance so the agent asks before requesting host installs or changing sandbox mode.

  • Telegram location sharing: Telegram channels now support receiving shared locations and live location updates. Live locations are tracked until they expire or the user stops sharing.

  • Telegram reply threading: Telegram channel replies now use reply_to_message_id to thread responses under the original user message, keeping conversations visually grouped in the chat.

  • get_user_location tool: New browser-based geolocation tool lets the LLM request the user’s current coordinates via the Geolocation API, with a permission prompt in the UI.

  • sandbox_packages tool: New tool for on-demand package discovery inside the sandbox, allowing the LLM to query available and installable packages at runtime.

  • Sandbox package expansions: Pre-built sandbox images now include expanded package groups — GIS/OpenStreetMap, document/office/search, image/audio/media/data-processing, and communication packages. Mise is also available for runtime version management.

  • Queued message UI: When a message is submitted while the LLM is already responding, it is shown in a dedicated bottom tray with cancel support. Queued messages are moved into the conversation only after the current response finishes rendering.

  • Full context view: New “Context” button in the chat header opens a panel showing the full LLM messages array sent to the provider, with a Copy button for easy debugging.

  • Browser timezone auto-detection: The gateway now auto-detects the user’s timezone from the browser via Intl.DateTimeFormat and includes it in session context, removing the need for manual timezone configuration.

  • Logs download: New Download button on the logs page streams the JSONL log file via GET /api/logs/download with gzip/zstd compression.

  • Gateway middleware hardening: Consolidated middleware into apply_middleware_stack() with security and observability layers:

    • Replace allow_origin(Any) with dynamic host-based CORS validation reusing the WebSocket CSWSH is_same_origin logic, safe for Docker/cloud deployments with unknown hostnames
    • CatchPanicLayer to convert handler panics to 500 responses
    • RequestBodyLimitLayer (16 MiB) to prevent memory exhaustion
    • SetSensitiveHeadersLayer to redact Authorization/Cookie in traces
    • Security response headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy)
    • SetRequestIdLayer + PropagateRequestIdLayer for x-request-id correlation across HTTP request logs
    • zstd compression alongside gzip for better ratios
  • Message run tracking: Persisted messages now carry run_id and seq fields for parent/child linking across multi-turn tool runs, plus a client-side sequence number for ordering diagnostics.

  • Cache token metrics: Provider responses now populate cache-hit and cache-miss token counters in the metrics subsystem.

Changed

  • Provider auto-detection observability: When no explicit provider settings are present in moltis.toml, startup now logs each auto-detected provider with its source (env, config file key, OAuth token file, provider key file, or Codex auth file). Added server.http_request_logs (Axum HTTP traces) and server.ws_request_logs (WebSocket RPC request/response traces) config options (both default false) for on-demand transport debugging without code changes.
  • Dynamic OpenAI Codex model catalog: OpenAI Codex providers now load model IDs from https://chatgpt.com/backend-api/codex/models at startup (with fallback defaults), and the gateway refreshes Codex models hourly so long-running sessions pick up newly available models (for example gpt-5.3) without restart.
  • Model availability probing UX: Model support probing now runs in parallel with bounded concurrency, starts automatically after provider connect/startup, and streams live progress (start/progress/complete) over WebSocket so the Providers page can render a progress bar.
  • Provider-scoped probing on connect: Connecting a provider from the Providers UI now probes only that provider’s models (instead of all providers), reducing noise and startup load when adding accounts one by one.
  • Configurable model ordering: Added chat.priority_models in moltis.toml to pin preferred models at the top of model selectors without rebuilding. Runtime model selectors (models.list, chat model dropdown, Telegram /model) hide unsupported models, while Providers diagnostics continue to show full catalog entries (including unsupported flags).
  • Configurable provider offerings in UI: Added [providers] offered = [...] allowlist in moltis.toml to control which providers are shown in onboarding/provider-picker UI. New config templates default this to ["openai", "github-copilot"]; setting offered = [] shows all known providers. Configured providers remain visible for management.

Fixed

  • Web search DuckDuckGo fallback: When no search API key (Brave or Perplexity) is configured, web_search now automatically falls back to DuckDuckGo HTML search instead of returning an error and forcing the LLM to ask the user about using the browser.

  • Web onboarding flash and redirect timing: The web server now performs onboarding redirects before rendering the main app shell. When onboarding is incomplete, non-onboarding routes redirect directly to /onboarding; once onboarding is complete, /onboarding redirects back to /. The onboarding route now serves a dedicated onboarding HTML/JS entry instead of the full app bundle, preventing duplicate bootstrap/navigation flashes in Safari.

  • Local model cache path visibility: Startup logs for local LLM providers now explicitly print the model cache directory and cached model IDs, making MOLTIS_DATA_DIR behavior easier to verify without noisy model-catalog output.

  • Kimi device-flow OAuth in web UI: Kimi OAuth now uses provider-specific headers and prefers verification_uri_complete (or synthesizes ?user_code= fallback) so mobile-device sign-in links no longer fail with missing user_code.

  • Kimi Code provider authentication compatibility: kimi-code is now API-key-first in the web UI (KIMI_API_KEY, default base URL https://api.moonshot.ai/v1), while still honoring previously stored OAuth tokens for backward compatibility. Provider errors now include a targeted hint to switch to API-key auth when Kimi returns access_terminated_error.

  • Provider setup success feedback: API-key provider setup now runs an immediate model probe after saving credentials. The onboarding and Providers modal only show success when at least one model validates, and otherwise display a validation failure message instead of a false-positive “configured” state.

  • Heartbeat/cron duplicate runs: Skip heartbeat LLM turn when no prompt is configured, and fix duplicate cron job executions that could fire the same scheduled run twice.

  • Onboarding finish screen removed: Onboarding now skips the final “congratulations” screen and redirects straight to the chat view.

  • User message footer leak: Model name footer and timestamp are no longer incorrectly attached to user messages in the chat UI.

  • TTS counterpart auto-enable on STT save: Saving an ElevenLabs or Google Cloud STT key now automatically enables the matching TTS provider, mirroring the onboarding voice-test behavior.

  • Voice-generating indicator removed: The separate “voice generating” spinner during TTS playback has been removed in favor of the unified response indicator.

  • Config restart crash loop prevention: The gateway now validates the configuration file before restarting, returning an error to the UI instead of entering a crash loop when the config is invalid.

  • Safari dev-mode cache busting: Development mode now busts the Safari asset cache on reload, and fixes a missing border on detected-provider cards.

Refactored

  • McpManager lock consolidation: Replaced per-field RwLocks in McpManager with a single RwLock<McpManagerInner> to reduce lock contention and simplify state management.
  • GatewayState lock consolidation: Replaced per-field RwLocks in GatewayState with a single RwLock<GatewayInner> for the same reasons.
  • Typed chat broadcast payloads: Chat WebSocket broadcasts now use typed Rust structs instead of ad-hoc serde_json::Value maps.

Documentation

  • Expanded default SOUL.md with the full OpenClaw reference text for agent personality bootstrapping.

[0.2.9] - 2026-02-08

Added

  • Voice provider policy controls: Added provider-list allowlists so config templates and runtime voice setup can explicitly limit shown/allowed TTS and STT providers.
  • Typed voice provider metadata: Expanded voice provider metadata and preference handling to use typed flows across gateway and UI paths.

Changed

  • Reply medium preference handling: Chat now prefers the same reply medium when possible and falls back to text when a medium cannot be preserved.

Fixed

  • Chat UI reply badge visibility: Assistant footer now reliably shows the selected reply medium badge.
  • Voice UX polish: Improved microphone timing behavior and preserved settings scroll state in voice configuration views.

[0.2.8] - 2026-02-07

Changed

  • Unified plugins and skills into a single system: Plugins and skills were separate systems with duplicate code, manifests, and UI pages. They are now merged into one unified “Skills” system. All installed repos (SKILL.md, Claude Code .claude-plugin/, Codex) are managed through a single skills-manifest.json and installed-skills/ directory. The /plugins page has been removed — everything is accessible from the /skills page. A one-time startup migration automatically moves data from the old plugins manifest and directory into the new unified location.
  • Default config template voice list narrowed: New generated configs now include a [voice] section with provider-list allowlists limited to ElevenLabs for TTS and Mistral + ElevenLabs for STT.

Fixed

  • Update checker repository configuration: The update checker now reads server.update_repository_url from moltis.toml, defaults new configs to https://github.com/moltis-org/moltis, and treats an omitted/commented value as explicitly disabled.
  • Mistral and other providers rejecting requests with HTTP 422: Session metadata fields (created_at, model, provider, inputTokens, outputTokens) were leaking into provider API request bodies. Mistral’s strict validation rejected the extra created_at field. Replaced Vec<serde_json::Value> with a typed ChatMessage enum in the LlmProvider trait — metadata can no longer leak because the type only contains LLM-relevant fields (role, content, tool_calls). Conversion from persisted JSON happens once at the gateway boundary via values_to_chat_messages().
  • Chat skill creation not persisting new skills: Runtime tool filtering incorrectly applied the union of discovered skill allowed_tools to all chat turns, which could hide create_skill/update_skill and leave only a subset (for example web_fetch). Chat runs now use configured tool policy for runtime filtering without globally restricting tools based on discovered skill metadata.

Added

  • Voice Provider Management UI: Configure TTS and STT providers from Settings > Voice

    • Auto-detection of API keys from environment variables and LLM provider configs
    • Toggle switches to enable/disable providers without removing configuration
    • Local binary detection for whisper.cpp, piper, and sherpa-onnx
    • Server availability checks for Coqui TTS and Voxtral Local
    • Setup instructions modal for local provider installation
    • Shared Google Cloud API key between TTS and STT
  • Voice provider UI allowlists: Added voice.tts.providers and voice.stt.providers config lists to control which TTS/STT providers are shown in the Settings UI. Empty lists keep current behavior and show all providers.

  • New TTS Providers:

    • Google Cloud Text-to-Speech (380+ voices, 50+ languages)
    • Piper (fast local neural TTS, runs offline)
    • Coqui TTS (high-quality neural TTS with voice cloning)
  • New STT Providers:

    • ElevenLabs Scribe (90+ languages, word timestamps, speaker diarization)
    • Mistral AI Voxtral (cloud-based, 13 languages)
    • Voxtral Local via vLLM (self-hosted with OpenAI-compatible API)
  • Browser Sandbox Mode: Run browser in isolated Docker containers for security

    • Automatic container lifecycle management
    • Uses browserless/chrome image by default (configurable via sandbox_image)
    • Container readiness detection via HTTP endpoint probing
    • Browser sandbox mode automatically follows the session’s sandbox mode (no separate browser.sandbox config - sandboxed sessions use sandboxed browser)
  • Memory-Based Browser Pool Limits: Browser instances now limited by system memory

    • max_instances = 0 (default) allows unlimited instances, limited only by memory
    • memory_limit_percent = 90 blocks new instances when system memory exceeds threshold
    • Idle browsers cleaned up automatically before blocking
    • Set max_instances > 0 for hard limit if preferred
  • Automatic Browser Session Tracking: Browser tool automatically reuses sessions

    • Session ID is tracked internally and injected when LLM doesn’t provide one
    • Prevents pool exhaustion from LLMs forgetting to pass session_id
    • Session cleared on explicit “close” action
  • HiDPI Screenshot Support: Screenshots scale correctly on Retina displays

    • device_scale_factor config (default: 2.0) for high-DPI rendering
    • Screenshot display in UI scales according to device pixel ratio
    • Viewport increased to 2560×1440 for sharper captures
  • Enhanced Screenshot Lightbox:

    • Scrollable container for viewing long/tall screenshots
    • Download button at top of lightbox
    • Visible ✕ close button instead of text hint
    • Proper scaling for HiDPI displays
  • Telegram Screenshot Support: Browser screenshots sent to Telegram channels

    • Automatic retry as document when image dimensions exceed Telegram limits
    • Error messages sent to channel when screenshot delivery fails
    • Handles PHOTO_INVALID_DIMENSIONS and PHOTO_SAVE_FILE_INVALID errors
  • Telegram Tool Status Notifications: See what’s happening during long operations

    • Tool execution messages sent to Telegram (e.g., “🌐 Navigating to…”, “💻 Running: git status”, “📸 Taking screenshot…”)
    • Messages sent silently (no notification sound) to avoid spam
    • Typing indicator automatically re-sent after status messages
    • Supports browser, exec, web_fetch, web_search, and memory tools
  • Log Target Display: Logs now include the crate/module path for easier debugging

    • Example: INFO moltis_gateway::chat: tool execution succeeded tool=browser
  • Contributor docs: local validation: Added documentation for the ./scripts/local-validate.sh workflow, including published local status contexts, platform behavior, and CI fallback expectations.

  • Hooks Web UI: New /hooks page to manage lifecycle hooks from the browser

    • View all discovered hooks with eligibility status, source, and events
    • Enable/disable hooks without removing files (persisted across restarts)
    • Edit HOOK.md content in a monospace textarea and save back to disk
    • Reload hooks at runtime to pick up changes without restarting
    • Live stats (call count, failures, avg latency) from the hook registry
    • WebSocket-driven auto-refresh via hooks.status event
    • RPC methods: hooks.list, hooks.enable, hooks.disable, hooks.save, hooks.reload
  • Deploy platform detection: New MOLTIS_DEPLOY_PLATFORM env var hides local-only providers (local-llm, Ollama) on cloud deployments. Pre-configured in Fly.io, DigitalOcean, and Render deploy templates.

  • Telegram OTP self-approval: Non-allowlisted DM users receive a 6-digit verification code instead of being silently ignored. Correct code entry auto-approves the user to the allowlist. Includes flood protection (non-code messages silently ignored), lockout after 3 failed attempts (configurable cooldown), and 5-minute code expiry. OTP codes visible in web UI Senders tab. Controlled by otp_self_approval (default: true) and otp_cooldown_secs (default: 300) config fields.

  • Update availability banner: The web UI now checks GitHub releases hourly and shows a top banner when a newer version of moltis is available, with a direct link to the release page.

Changed

  • Documentation safety notice: Added an upfront alpha-software warning on the docs landing page, emphasizing careful deployment, isolation, and strong auth/network controls for self-hosted AI assistants.
  • Release packaging: Derive release artifact versions from the Git tag (vX.Y.Z) in CI, and sync package metadata during release jobs to prevent filename/version drift.
  • Versioning: Bump workspace and snap baseline version to 0.2.0.
  • Onboarding auth flow: Route first-run setup directly into /onboarding and remove the separate /setup web UI page.
  • Startup observability: Log each loaded context markdown (CLAUDE.md / AGENTS.md / .claude/rules/*.md), memory markdown (MEMORY.md and memory/*.md), and discovered SKILL.md to make startup/context loading easier to audit.
  • Workspace root pathing: Standardize workspace-scoped file discovery/loading on moltis_config::data_dir() instead of process cwd (affects BOOT.md, hook discovery, skill discovery, and compaction memory output paths).
  • Soul storage: Move agent personality text out of moltis.toml into workspace SOUL.md; identity APIs/UI still edit soul, but now persist it as a markdown file.
  • Identity storage: Persist agent identity fields (name, emoji, creature, vibe) to workspace IDENTITY.md using YAML frontmatter; settings UI continues to edit these fields through the same RPC/API.
  • User profile storage: Persist user profile fields (name, timezone) to workspace USER.md using YAML frontmatter; onboarding/settings continue to use the same API/UI while reading/writing the markdown file.
  • Workspace markdown support: Add TOOLS.md prompt injection from workspace root (data_dir), and keep startup injection sourced from BOOT.md.
  • Heartbeat prompt precedence: Support workspace HEARTBEAT.md as heartbeat prompt source with precedence heartbeat.prompt (config override) → HEARTBEAT.md → built-in default; log when config prompt overrides HEARTBEAT.md.
  • Heartbeat UX: Expose effective heartbeat prompt source (config, HEARTBEAT.md, or default) via heartbeat.status and display it in the Heartbeat settings UI.
  • BOOT.md onboarding aid: Seed a default workspace BOOT.md with in-file guidance describing startup injection behavior and recommended usage.
  • Workspace context parity: Treat workspace TOOLS.md as general context (not only policy) and add workspace AGENTS.md injection support from data_dir.
  • Heartbeat token guard: Skip heartbeat LLM turns when HEARTBEAT.md exists but is empty/comment-only and there is no explicit heartbeat.prompt override, reducing unnecessary token consumption.
  • Exec approval policy wiring: Gateway now initializes exec approval mode/security level/allowlist from moltis.toml (tools.exec.*) instead of always using hardcoded defaults.
  • Runtime tool enforcement: Chat runs now apply configured tool policy (tools.policy) and skill allowed_tools constraints when selecting callable tools.
  • Skill trust lifecycle: Installed marketplace skills/plugins now track a trusted state and must be trusted before they can be enabled; the skills UI now surfaces untrusted status and supports trust-before-enable.
  • Git metadata via gitoxide: Gateway now resolves branch names, repo HEAD SHAs, and commit timestamps using gix (gitoxide) instead of shelling out to git for those read-only operations.

Fixed

  • OAuth callback on hosted deployments: OpenAI Codex OAuth now uses the web app origin callback (/auth/callback) in the UI flow instead of hardcoded localhost loopback, allowing DigitalOcean/Fly/Render deployments to complete OAuth successfully.
  • Sandbox startup on hosted Docker environments: Skip sandbox image pre-build when sandbox mode is off, and require Docker daemon accessibility (not just Docker CLI presence) before selecting the Docker sandbox backend.
  • Homebrew release automation: Run the tap update in the release workflow after all package/image jobs complete so formula publishing does not race missing tarball assets.
  • Docker runtime: Install libgomp1 in the runtime image to satisfy OpenMP-linked binaries and prevent startup failures with libgomp.so.1 missing.
  • Release CI validation: Add a Docker smoke test step (moltis --help) after image build/push so missing runtime libraries fail in CI before release.
  • Web onboarding clarity: Add setup-code guidance that points users to the process log (stdout).
  • WebSocket auth (remote deployments): Accept existing session/API-key auth from WebSocket upgrade headers so browser connections don’t immediately close after connect on hosted setups.
  • Sandbox UX on unsupported hosts: Disable sandbox controls in chat/images when no runtime backend is detected, with a tooltip explaining cloud deploy limitations.
  • Telegram OTP code echoed to LLM: After OTP self-approval, the verification code message was re-processed as a regular chat message because sender_approve restarted the bot polling loop (resetting the Telegram update offset). Sender approve/deny now hot-update the in-memory config without restarting the bot.
  • Empty allowlist bypassed access control: When dm_policy = Allowlist and all entries were removed, the empty list was treated as “allow everyone” instead of “deny everyone”. An explicit Allowlist policy with an empty list now correctly denies all access.
  • Browser sandbox timeout: Sandboxed browsers now use the configured navigation_timeout_ms (default 30s) instead of a shorter internal timeout. Previously, sandboxed browser connections could time out prematurely.
  • Tall screenshot lightbox: Full-page screenshots now display at proper size with vertical scrolling instead of being scaled down to fit the viewport.
  • Telegram typing indicator for long responses: Channel replies now wait for outbound delivery tasks to finish before chat completion returns, so periodic typing... updates continue until the Telegram message is actually sent.
  • Skills dependency install safety: skills.install_dep now requires explicit user confirmation and blocks host installs when sandbox mode is disabled (unless explicitly overridden in the RPC call).

Security

  • Asset response hardening: Static assets now set X-Content-Type-Options: nosniff, and SVG responses include a restrictive Content-Security-Policy (script-src 'none', object-src 'none') to reduce stored-XSS risk if user-controlled SVGs are ever introduced.
  • Archive extraction hardening: Skills/plugin tarball installs now reject unsafe archive paths (.., absolute/path-prefix escapes) and reject symlink/hardlink archive entries to prevent path traversal and link-based escapes.
  • Install provenance: Installed skill/plugin repo manifests now persist a pinned commit_sha (resolved from clone or API fallback) for future trust drift detection.
  • Re-trust on source drift: If an installed git-backed repo’s HEAD commit changes from the pinned commit_sha, the gateway now marks its skills untrusted+disabled and requires trust again before re-enabling; the UI surfaces this as source changed.
  • Security audit trail: Skill/plugin install, remove, trust, enable/disable, dependency install, and source-drift events are now appended to ~/.moltis/logs/security-audit.jsonl for incident review.
  • Emergency kill switch: Added skills.emergency_disable to immediately disable all installed third-party skills and plugins; exposed in the Skills UI as a one-click emergency action.
  • Risky dependency install blocking: skills.install_dep now blocks suspicious install command patterns by default (e.g. piped shell payloads, base64 decode chains, quarantine bypass) unless explicitly overridden with allow_risky_install=true.
  • Provenance visibility: Skills UI now displays pinned install commit SHA in repo and detail views to make source provenance easier to verify.
  • Recent-commit risk warnings: Skill/plugin detail views now include commit links and commit-age indicators, with a prominent warning banner when the pinned commit is very recent.
  • Installer subprocess reduction: Skills/plugins install paths now avoid git subprocess clone attempts and use GitHub tarball installs with pinned commit metadata.
  • Install resilience for rapid multi-repo installs: Skills/plugins install now auto-clean stale on-disk directories that are missing from manifest state, and tar extraction skips link entries instead of failing the whole install.
  • Orphaned repo visibility: Skills/plugins repo listing now surfaces manifest-missing directories found on disk as orphaned entries and allows removing them from the UI.
  • Protected seed skills: Discovered template skills (template-skill / template) are now marked protected and cannot be deleted from the web UI.
  • License review links: Skill/plugin license badges now link directly to repository license files when detectable (e.g. LICENSE.txt, LICENSE.md, LICENSE).
  • Example skill seeding: Gateway now seeds ~/.moltis/skills/template-skill/SKILL.md on startup when missing, so users always have a starter personal skill template.
  • Memory indexing scope tightened: Memory sync now indexes only MEMORY.md / memory.md and memory/ content by default (instead of scanning the entire data root), reducing irrelevant indexing noise from installed skills/plugins.
  • Ollama embedding bootstrap: When using Ollama for memory embeddings, gateway now auto-attempts to pull missing embedding models (default nomic-embed-text) via Ollama HTTP API.

Documentation

  • Added docs/src/skills-security.md with third-party skills/plugin hardening guidance (trust lifecycle, provenance pinning, source-drift re-trust, risky install guards, emergency disable, and security audit logging).

[0.1.10] - 2026-02-06

Changed

  • CI builds: Build Docker images natively per architecture instead of QEMU emulation, then merge into multi-arch manifest

[0.1.9] - 2026-02-06

Changed

  • CI builds: Migrate all release build jobs from self-hosted to GitHub-hosted runners for full parallelism (ubuntu-latest, ubuntu-latest-arm, macos-latest), remove all cross-compilation toolchain steps

[0.1.8] - 2026-02-06

Fixed

  • CI builds: Fix corrupted cargo config on all self-hosted runner jobs, fix macOS runner label, add llama-cpp build deps to Docker and Snap builds

[0.1.7] - 2026-02-06

Fixed

  • CI builds: Use project-local .cargo/config.toml for cross-compilation instead of appending to global config (fixes duplicate key errors on self-hosted runners)

[0.1.6] - 2026-02-06

Fixed

  • CI builds: Use macOS GitHub-hosted runners for apple-darwin binary builds instead of cross-compiling from Linux
  • CI performance: Run lightweight lint jobs (zizmor, biome, fmt) on GitHub-hosted runners to free up self-hosted runners

[0.1.5] - 2026-02-06

Fixed

  • CI security: Use GitHub-hosted runners for PRs to prevent untrusted code from running on self-hosted infrastructure
  • CI security: Add persist-credentials: false to docs workflow checkout (fixes zizmor artipacked warning)

[0.1.4] - 2026-02-06

Added

  • --no-tls CLI flag: --no-tls flag and MOLTIS_NO_TLS environment variable to disable TLS for cloud deployments where the provider handles TLS termination

  • One-click cloud deploy: Deploy configs for Fly.io (fly.toml), DigitalOcean (.do/deploy.template.yaml), Render (render.yaml), and Railway (railway.json) with deploy buttons in the README

  • Config Check Command: moltis config check validates the configuration file, detects unknown/misspelled fields with Levenshtein-based suggestions, warns about security misconfigurations, and checks file references

  • Memory Usage Indicator: Display process RSS and system free memory in the header bar, updated every 30 seconds via the tick WebSocket broadcast

  • QMD Backend Support: Optional QMD (Query Memory Daemon) backend for hybrid search with BM25 + vector + LLM reranking

    • Gated behind qmd feature flag (enabled by default)
    • Web UI shows installation instructions and QMD status
    • Comparison table between built-in SQLite and QMD backends
  • Citations: Configurable citation mode (on/off/auto) for memory search results

    • Auto mode includes citations when results span multiple files
  • Session Export: Option to export session transcripts to memory for future reference

  • LLM Reranking: Use LLM to rerank search results for improved relevance (requires QMD)

  • Memory Documentation: Added docs/src/memory.md with comprehensive memory system documentation

  • Mobile PWA Support: Install moltis as a Progressive Web App on iOS, Android, and desktop

    • Standalone mode with full-screen experience
    • Custom app icon (crab mascot)
    • Service worker for offline support and caching
    • Safe area support for notched devices
  • Push Notifications: Receive alerts when the LLM responds

    • VAPID key generation and storage for Web Push API
    • Subscribe/unsubscribe toggle in Settings > Notifications
    • Subscription management UI showing device name, IP address, and date
    • Remove any subscription from any device
    • Real-time subscription updates via WebSocket
    • Client IP detection from X-Forwarded-For, X-Real-IP, CF-Connecting-IP headers
    • Notifications sent for both streaming and agent (tool-using) chat modes
  • Safari/iOS PWA Detection: Show “Add to Dock” instructions when push notifications require PWA installation (Safari doesn’t support push in browser mode)

  • Browser Screenshot Thumbnails: Screenshots from the browser tool now display as clickable thumbnails in the chat UI

    • Click to view fullscreen in a lightbox overlay
    • Press Escape or click anywhere to close
    • Thumbnails are 200×150px max with hover effects
  • Improved Browser Detection: Better cross-platform browser detection

    • Checks macOS app bundles before PATH (avoids broken Homebrew chromium wrapper)
    • Supports Chrome, Chromium, Edge, Brave, Opera, Vivaldi, Arc
    • Shows platform-specific installation instructions when no browser found
    • Custom path via chrome_path config or CHROME environment variable
  • Vision Support for Screenshots: Vision-capable models can now interpret browser screenshots instead of having them stripped from context

    • Screenshots sent as multimodal image content blocks for GPT-4o, Claude, Gemini
    • Non-vision models continue to receive [base64 data removed] placeholder
    • supports_vision() trait method added to LlmProvider for capability detection
  • Session state store: per-session key-value persistence scoped by namespace, backed by SQLite (session_state tool).

  • Session branching: branch_session tool forks a conversation at any message index into an independent copy.

  • Session fork from UI: Fork button in the chat header and sidebar action buttons let users fork sessions without asking the LLM. Forked sessions appear indented under their parent with a branch icon.

  • Skill self-extension: create_skill, update_skill, delete_skill tools let the agent manage project-local skills at runtime.

  • Skill hot-reload: filesystem watcher on skill directories emits skills.changed events via WebSocket when SKILL.md files change.

  • Typed tool sources: ToolSource enum (Builtin / Mcp { server }) replaces string-prefix identification of MCP tools in the tool registry.

  • Tool registry metadata: list_schemas() now includes source and mcpServer fields so the UI can group tools by origin.

  • Per-session MCP toggle: sessions store an mcp_disabled flag; the chat header exposes a toggle button to enable/disable MCP tools per session.

  • Debug panel convergence: the debug side-panel now renders the same seven sections as the /context slash command, eliminating duplicated rendering logic.

  • Documentation pages for session state, session branching, skill self-extension, and the tool registry architecture.

Changed

  • Memory settings UI enhanced with backend comparison and feature explanations

  • Added memory.qmd.status RPC method for checking QMD availability

  • Extended memory.config.get to include qmd_feature_enabled flag

  • Push notifications feature is now enabled by default in the CLI

  • TLS HTTP redirect port now defaults to gateway_port + 1 instead of the hardcoded port 18790. This makes the Dockerfile simpler (both ports are adjacent) and avoids collisions when running multiple instances. Override via [tls] http_redirect_port in moltis.toml or the MOLTIS_TLS__HTTP_REDIRECT_PORT environment variable.

  • TLS certificates use moltis.localhost domain. Auto-generated server certs now include moltis.localhost, *.moltis.localhost, localhost, 127.0.0.1, and ::1 as SANs. Banner and redirect URLs use https://moltis.localhost:<port> when bound to loopback, so the cert matches the displayed URL. Existing certs are automatically regenerated on next startup.

  • Certificate validity uses dynamic dates. Cert notBefore/notAfter are now computed from the current system time instead of being hardcoded. CA certs are valid for 10 years, server certs for 1 year from generation.

  • McpToolBridge now stores and exposes server_name() for typed registration.

  • mcp_service::sync_mcp_tools() uses unregister_mcp() / register_mcp() instead of scanning tool names by prefix.

  • chat.rs uses clone_without_mcp() instead of clone_without_prefix("mcp__") in all three call sites.

Fixed

  • Push notifications not sending when chat uses agent mode (run_with_tools)
  • Missing space in Safari install instructions (“usingFile” → “using File”)
  • WebSocket origin validation now treats .localhost subdomains (e.g. moltis.localhost) as loopback equivalents per RFC 6761.
  • Browser tool schema enforcement: Added strict: true and additionalProperties: false to OpenAI-compatible tool schemas, improving model compliance with required fields
  • Browser tool defaults: When model sends URL without action, defaults to navigate instead of erroring
  • Chat message ordering: Fixed interleaving of text and tool cards when streaming; messages now appear in correct chronological order
  • Tool passthrough in ProviderChain: Fixed tools not being passed to fallback providers when using provider chains
  • Fork/branch icon in session sidebar now renders cleanly at 16px (replaced complex git-branch SVG with simple trunk+branch path).
  • Deleting a forked session now navigates to the parent session instead of an unrelated sibling.
  • Streaming tool calls for non-Anthropic providers: OpenAiProvider, GitHubCopilotProvider, KimiCodeProvider, OpenAiCodexProvider, and ProviderChain now implement stream_with_tools() so tool schemas are sent in the streaming API request and tool-call events are properly parsed. Previously only AnthropicProvider supported streaming tool calls; all other providers silently dropped the tools parameter, causing the LLM to emit tool invocations as plain text instead of structured function calls.
  • Streaming tool call arguments dropped when index ≠ 0: When a provider (e.g. GitHub Copilot proxying Claude) emits a text content block at streaming index 0 and a tool_use block at index 1, the runner’s argument finalization used the streaming index as the vector position directly. Since tool_calls has only 1 element at position 0, the condition 1 < 1 was false and arguments were silently dropped (empty {}). Fixed by mapping streaming indices to vector positions via a HashMap.
  • Skill tools wrote to wrong directory: create_skill, update_skill, and delete_skill used std::env::current_dir() captured at gateway startup, writing skills to <cwd>/.moltis/skills/ instead of ~/.moltis/skills/. Skills now write to <data_dir>/skills/ (Personal source), which is always discovered regardless of where the gateway was started.
  • Skills page missing personal/project skills: The /api/skills endpoint only returned manifest-based registry skills. Personal and project-local skills were never shown in the navigation or skills page. The endpoint now discovers and includes them alongside registry skills.

Documentation

  • Added voice.md with TTS/STT provider documentation and setup guides
  • Added mobile-pwa.md with PWA installation and push notification documentation
  • Updated CLAUDE.md with cargo feature policy (features enabled by default)
  • Updated browser-automation.md with browser detection, screenshot display, and model error handling sections
  • Rewrote session-branching.md with accurate fork details, UI methods, RPC API, inheritance table, and deletion behavior.