Moltis
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.
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?
| Feature | Moltis | Other Solutions |
|---|---|---|
| Deployment | Single binary | Node.js + dependencies |
| Memory Safety | Rust ownership | Garbage collection |
| Secret Handling | Zeroed on drop | “Eventually collected” |
| Sandbox | Docker + Apple Container | Docker only |
| Startup | Milliseconds | Seconds |
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
- Cross-Session Recall — Search earlier sessions for relevant snippets and prior decisions
- Automatic Checkpoints — Restore built-in skill and memory mutations without touching git history
- Remote Exec Targets — Route command execution locally, through a paired node, or over SSH
- Context Hardening — Load
CLAUDE.md,AGENTS.md,.cursorrules, and rule directories with safety scanning - 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:
- Open the URL shown in your browser (e.g.,
http://localhost:13131) - Add your LLM API key
- Start chatting!
Authentication is only required when accessing Moltis from a non-localhost address. On localhost, you can start using it immediately.
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
- Quickstart — Up and running in 5 minutes
- Installation — All installation methods
- Configuration —
moltis.tomlreference - End-to-End Testing — Browser regression coverage for the web UI
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
- Streaming — How real-time streaming works
- Metrics & Tracing — Observability
Security
Moltis applies defense in depth:
- Authentication — Password or passkey (WebAuthn) required for non-localhost access
- SSRF Protection — Blocks requests to internal networks
- Secret Handling —
secrecy::Secretzeroes memory on drop - Sandboxed Execution — Commands never run on the host
- Origin Validation — Prevents Cross-Site WebSocket Hijacking
- No Unsafe Code —
unsafeis denied workspace-wide
Community
- GitHub: github.com/moltis-org/moltis
- Issues: Report bugs
- Discussions: Ask questions
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.)
- 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 - Models appear automatically in the model picker.
Or configure via the web UI: Settings → Providers → enter your API key.
Option B: OAuth (Codex / Copilot)
- In Moltis, go to Settings → Providers
- Click OpenAI Codex or GitHub Copilot → Connect
- Complete the OAuth flow
Option C: Local LLM (Offline)
- In Moltis, go to Settings → Providers
- Click Local LLM
- 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:
- Create a bot via @BotFather
- Copy the bot token
- In Moltis: Settings → Telegram → Enter token → Save
- Message your bot!
Connect Discord
- Create a bot in the Discord Developer Portal
- Enable Message Content Intent and copy the bot token
- In Moltis: Settings → Channels → Connect Discord → Enter token → Connect
- Invite the bot to your server and @mention it!
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
| Command | Description |
|---|---|
/new | Start a new session |
/model <name> | Switch models |
/agent | List or switch chat agents |
/mode | List or switch temporary session modes |
/clear | Clear chat history |
/help | Show available commands |
File Locations
| Path | Contents |
|---|---|
~/.config/moltis/moltis.toml | Configuration |
~/.config/moltis/provider_keys.json | API keys |
~/.moltis/ | Data (sessions, memory, logs) |
Getting Help
- Documentation: docs.moltis.org
- GitHub Issues: github.com/moltis-org/moltis/issues
- Discussions: github.com/moltis-org/moltis/discussions
Installation
Moltis is distributed as a single self-contained binary. Choose the installation method that works best for your setup.
Quick Install (Recommended)
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
Package filenames are versioned on every release. Use the installer script below instead of hardcoding GitHub release asset names.
Debian / Ubuntu (.deb)
# Install the latest .deb package
curl -fsSL https://www.moltis.org/install.sh | sh -s -- --method=deb
Fedora / RHEL (.rpm)
# Install the latest .rpm package
curl -fsSL https://www.moltis.org/install.sh | sh -s -- --method=rpm
Arch Linux (.pkg.tar.zst)
# Install the latest package
curl -fsSL https://www.moltis.org/install.sh | sh -s -- --method=arch
Snap
sudo snap install moltis
AppImage
# Install the latest AppImage
curl -fsSL https://www.moltis.org/install.sh | sh -s -- --method=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:
- Open
http://localhost:<port>in your browser (the port is shown in the terminal output) - Configure your LLM provider (API key)
- Start chatting!
Moltis picks a random available port on first install to avoid conflicts. The port is saved in your config and reused on subsequent runs.
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
Removing these directories deletes all your conversations, memory, and settings permanently.
Comparison
How Moltis compares to the larger open-source personal agent projects: OpenClaw and Hermes Agent.
Disclaimer: This page is based on source snapshots captured while writing: OpenClaw
90eb5b0from 2026-04-01, Hermes Agent9f22977from 2026-04-20, and Moltis5d044c6from 2026-04-22. Projects move quickly, so check each repository for current behavior before making a deployment decision.
At a Glance
| OpenClaw | Hermes Agent | Moltis | |
|---|---|---|---|
| Primary stack | TypeScript, with Swift/Kotlin companion apps | Python, with TypeScript TUI/web surfaces | Rust |
| Main runtime | Node.js 22.16+/24 + npm/pnpm/bun | Python + uv/pip, optional Node UI pieces | Single Rust binary |
| Main shape | Broad gateway, channel, node, app, and plugin ecosystem | CLI/gateway agent with a learning loop and research tooling | Persistent personal agent server with modular crates |
| Local checkout size* | ~1.1M app LoC | ~152K app LoC | ~270K Rust LoC |
| Crates/modules | npm packages, extensions, apps | Python packages, plugins, tools, TUI | 59 Rust workspace crates |
| Sandbox/backends | App-level permissions, browser/node tools | Local, Docker, SSH, Daytona, Singularity, Modal | Docker/Podman + Apple Container + WASM |
| Auth/access | Pairing and gateway controls | CLI and messaging gateway setup | Password + Passkey + API keys + Vault |
| Voice I/O | Voice wake and talk modes | Voice memo transcription | Built-in STT + TTS providers |
| MCP | Plugin/integration support | MCP integration | stdio + HTTP/SSE |
| Skills | Bundled, managed, and workspace skills | Self-improving skills, Skills Hub support | Bundled/workspace skills + autonomous improvement + OpenClaw import |
| Memory/RAG | Plugin-backed memory and context engine | Agent-curated memory, session search, user modeling | SQLite + FTS + vector memory |
* LoC measured with tokei, excluding node_modules, generated build output,
dist, and target. Counts are a rough auditability signal, not a quality
metric.
Architecture Approach
OpenClaw, ecosystem-first personal assistant
OpenClaw is a full-featured personal assistant platform. The local checkout shows a TypeScript gateway with macOS, iOS, and Android companion surfaces, plus a large channel list, node tools, browser/canvas support, plugin extensions, onboarding, and managed/workspace skills.
Hermes Agent, learning-loop CLI and gateway
Hermes Agent is Python-first. Its README centers the agent around a terminal interface, a messaging gateway, a closed learning loop, self-improving skills, agent-curated memory, session search, user modeling, cron scheduling, and cloud/serverless execution backends. Moltis has autonomous skill improvement too, so Hermes’ sharper distinction is its CLI/research loop and broad terminal backend set. It also carries research-oriented pieces such as trajectory generation and RL environments.
Moltis, Rust-native persistent agent server
Moltis prioritizes a smaller trusted runtime, durable agent workflows, and defense in depth. The Rust workspace is currently ~270K lines across 59 crates. The agent runner and model interface are ~7.5K lines, with provider implementations in ~19K more.
Key differences:
- Single Rust binary instead of a Node.js or Python application runtime
- Built-in web UI with streaming chat, settings, sessions, projects, and admin surfaces
- Docker/Podman, Apple Container, and WASM sandboxing
- Password, WebAuthn passkeys, scoped API keys, and vault-backed secret storage
- Cross-session recall without dumping raw history into every prompt
- Autonomous skill self-improvement with
enable_self_improvementon by default - Automatic checkpoints before built-in skill and memory mutations
- 15 lifecycle hook events with circuit breaker and dry-run mode
- Read-only OpenClaw import for identity, providers, skills, memory, sessions, channels, and MCP config
Moltis intentionally has a small unsafe surface, not a zero-unsafe entire workspace. Unsafe code is isolated to Swift FFI, local model wrappers, and precompiled WASM/runtime boundaries. The core agent and gateway paths stay in safe Rust.
Security Model
| Aspect | OpenClaw | Hermes Agent | Moltis |
|---|---|---|---|
| Code sandbox | App-level permissions and tool controls | Local/Docker/SSH/cloud terminal backends | Docker/Podman + Apple Container + WASM |
| Secret handling | Environment/config/plugin paths | Config and provider credentials | secrecy::Secret, encrypted vault, redaction |
| Auth/access | Pairing and gateway controls | CLI plus messaging gateway setup | Password + Passkey + scoped API keys |
| SSRF protection | Tool/plugin dependent | Tool/backend dependent | DNS-resolved, blocks loopback/private/link-local/CGNAT |
| WebSocket origin | Gateway dependent | Gateway dependent | Cross-origin rejection |
| Unsafe/native boundary | N/A for TS core, native apps exist | N/A for Python core, native deps possible | Isolated FFI/runtime unsafe islands |
| Hook gating | Plugin and runtime hooks | Hooks/plugins | BeforeToolCall inspect/modify/block |
| Rate limiting | Gateway dependent | Gateway dependent | Per-IP throttle, strict login limits |
Local Checkout Snapshot
| Metric | OpenClaw | Hermes Agent | Moltis |
|---|---|---|---|
| Main implementation LoC* | ~1.0M TypeScript, ~89K Swift, ~25K Kotlin | ~144K Python, ~8K TypeScript | ~270K Rust |
| Main install path | npm install -g openclaw | curl .../install.sh | bash, then hermes | Install script, Homebrew, Docker, or Cargo |
| Runtime dependency | Node.js | Python environment | Bundled binary |
| Workspace/package count | npm packages, extensions, apps | Python package, plugins, tools, UI packages | 59 Rust crates |
| Test surface signal | Large TS/app test tree | Python and TUI tests | 470+ Rust files containing tests |
* These counts are intentionally limited to app/source directories and exclude dependency folders and build output. They are useful for spotting scale, not for ranking projects.
Links
- OpenClaw and OpenClaw docs
- Hermes Agent and Hermes docs
- Moltis and Moltis docs
Configuration
Moltis uses a layered config model with two files:
| File | Owner | Purpose |
|---|---|---|
defaults.toml | Moltis | Shipped defaults, regenerated on every startup |
moltis.toml | You | Your overrides only |
On first run, both files are created in ~/.config/moltis/. Your moltis.toml
starts nearly empty — only the installation-specific port is set. All other
settings inherit from defaults.toml automatically.
Merge Order
Settings are resolved in this order (later wins):
- Built-in defaults — compiled into Moltis (
MoltisConfig::default()) defaults.toml— Moltis-managed, refreshed on every startupmoltis.toml— your overrides (additive deep merge)MOLTIS_*environment variables — highest precedence
This means you only need to put values in moltis.toml that you intentionally
want to differ from the shipped defaults. When Moltis upgrades and improves a
default, your installation picks it up automatically — unless you’ve overridden
that specific setting.
Copying a built-in default into moltis.toml “freezes” it — future built-in
improvements for that setting won’t apply. The Settings UI shows Built-in,
Overridden, and Custom badges so you can see which values are yours
and which are inherited.
Configuration File Location
| Platform | Default Path |
|---|---|
| macOS/Linux | ~/.config/moltis/moltis.toml |
| Custom | Set via --config-dir or MOLTIS_CONFIG_DIR |
The defaults.toml file lives in the same directory. Do not edit it — your
changes will be overwritten on the next startup.
Checking Config
moltis config check validates your override file (moltis.toml) against the
known config schema. It also checks that Moltis-managed defaults.toml exists
and can be parsed, but it does not treat defaults.toml as user-authored input.
New config fields should be added to the Rust config schema and its Default
implementation. Moltis regenerates defaults.toml from those built-in defaults
on startup, while moltis.toml should contain only values you intentionally
override.
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.
Remote Execution
Command execution can stay local, route to a paired node, or use SSH:
[tools.exec]
host = "local" # "local", "node", or "ssh"
# node = "mac-mini" # default paired node when host = "node"
# ssh_target = "deploy@box" # default SSH target when host = "ssh"
When host = "ssh", Moltis can work in two modes:
- System OpenSSH: reuse your existing host aliases, agent forwarding policy,
and
~/.ssh/config. - Managed targets: create or import a deploy key in Settings → SSH, then bind that key to a named target. Moltis stores the private key in its credential store and encrypts it with the vault whenever the vault is unsealed. Imported keys may be passphrase-protected, Moltis strips the passphrase during import so runtime execution can stay non-interactive.
For stricter SSH verification, managed targets also accept a pasted
known_hosts line from ssh-keyscan -H host. The SSH settings page can scan
that for you, and saved targets can refresh or clear their stored pin later.
When present, Moltis uses that pin instead of your global OpenSSH known-host
policy for that target.
Managed targets appear in the Nodes page and chat node picker, so users can see
where exec will run without digging through config. If multiple managed
targets exist, the default one is used when tools.exec.host = "ssh" and no
session-specific route is selected. moltis doctor also reports remote-exec
inventory, active backend mode, and obvious SSH setup problems from the CLI.
Settings -> Tools shows the effective tool inventory for the active session
and model, including tool-calling support, MCP server state, skills/plugins,
and available execution routes. It is session-aware by design, switching the
model or disabling MCP for a session changes what appears there.
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.
When you modify the packages list and restart, Moltis automatically rebuilds the sandbox image with a new tag.
Web Search
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 setBRAVE_API_KEYorPERPLEXITY_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_self_improvement = true # System prompt guidance for autonomous skill creation/update
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.
enable_self_improvement (default: true) injects system prompt guidance that
encourages the agent to proactively create and update skills after complex
tasks (5+ tool calls), tricky error fixes, or non-obvious workflows. The
patch_skill tool allows surgical find/replace updates without rewriting the
entire skill body.
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
prompt_memory_mode = "live-reload"
# Options:
# "followup" - Queue each message and run them sequentially
# "collect" - Merge queued text and run once after the active run
# "live-reload" - Re-read MEMORY.md before each turn
# "frozen-at-session-start" - Keep the first MEMORY.md snapshot for the session
Memory System
Long-term memory uses embeddings for semantic search:
[memory]
style = "hybrid" # Or "prompt-only", "search-only", "off"
agent_write_mode = "hybrid" # Or "prompt-only", "search-only", "off"
user_profile_write_mode = "explicit-and-auto" # Or "explicit-only", "off"
backend = "builtin" # Or "qmd"
provider = "openai" # Or "local", "ollama", "custom"
model = "text-embedding-3-small"
citations = "auto" # "on", "off", or "auto"
llm_reranking = false
search_merge_strategy = "rrf" # Or "linear"
session_export = "on-new-or-reset" # Or "off"
See Memory Surfaces for the boundary between
session_state, prompt memory, searchable memory, and sandbox persistence.
memory.style chooses the high-level behavior, while
chat.prompt_memory_mode only affects prompt-visible MEMORY.md.
memory.agent_write_mode controls where agent-authored writes are allowed to
land. memory.user_profile_write_mode controls whether Moltis writes the
managed USER.md surface, and whether browser/channel timezone or location
signals may update it silently. memory.session_export controls whether
session rollover exports are written at all.
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
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}" }
[mcp.servers.remote_http]
transport = "streamable-http"
url = "https://mcp.example.com/mcp"
headers = { Authorization = "Bearer ${API_KEY}" }
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 Settings → Environment 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.
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:
| Variable | Description |
|---|---|
MOLTIS_CONFIG_DIR | Configuration directory |
MOLTIS_DATA_DIR | Data directory |
MOLTIS_SERVER__PORT | Server port override |
MOLTIS_SERVER__BIND | Server bind address override |
MOLTIS_TOOLS__AGENT_TIMEOUT_SECS | Agent run timeout override |
MOLTIS_TOOLS__AGENT_MAX_ITERATIONS | Agent 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
Upstream Proxy
Moltis can route all outbound HTTP traffic through an upstream proxy. This is useful when running behind a corporate firewall, in a restricted network, or when you need to audit/filter outbound connections.
Configuration
Add upstream_proxy to the top level of your moltis.toml:
upstream_proxy = "http://proxy.corp.example.com:8080"
Supported schemes
| Scheme | Example | Notes |
|---|---|---|
http:// | http://proxy:8080 | HTTP CONNECT proxy (most common) |
https:// | https://proxy:8443 | TLS-encrypted proxy connection |
socks5:// | socks5://proxy:1080 | SOCKS5 proxy (DNS resolved locally) |
socks5h:// | socks5h://proxy:1080 | SOCKS5 proxy (DNS resolved by proxy) |
Proxy authentication
Include credentials in the URL:
upstream_proxy = "http://user:password@proxy.corp.example.com:8080"
What is proxied
When upstream_proxy is set, the following traffic routes through the proxy:
- LLM provider API calls (Anthropic, OpenAI, Gemini, etc.)
- Tool HTTP requests (web fetch, web search, Firecrawl)
- OAuth flows (device auth, token exchange)
- MCP server auth (OAuth for remote MCP servers)
- Channel outbound (Slack streaming, MS Teams API calls)
Localhost and loopback addresses (127.0.0.1, ::1, localhost) are
automatically excluded from the proxy (no_proxy).
Slack caveat
Slack streaming messages (progressive edits) are proxied via reqwest.
However, regular chat.postMessage calls go through the slack-morphism
library’s built-in hyper connector, which does not use the upstream
proxy. If you need full Slack proxy coverage, also set the HTTPS_PROXY
environment variable.
Telegram caveat
Telegram uses teloxide which bundles
its own HTTP client (reqwest 0.11). The upstream_proxy config does not apply
to Telegram traffic directly. To proxy Telegram, set the standard
HTTPS_PROXY environment variable, which teloxide’s reqwest honours:
export HTTPS_PROXY=http://proxy.corp.example.com:8080
moltis
Or use the env section in moltis.toml:
[env]
HTTPS_PROXY = "http://proxy.corp.example.com:8080"
Environment variable fallback
When upstream_proxy is not set in moltis.toml, reqwest automatically
honours the standard proxy environment variables:
HTTP_PROXY/http_proxyHTTPS_PROXY/https_proxyALL_PROXY/all_proxyNO_PROXY/no_proxy
Setting upstream_proxy in the config takes precedence over these variables
for all traffic except Telegram (see caveat above).
Interaction with Trusted Network
If you use both upstream_proxy and
trusted network mode (network = "trusted"), they
serve different purposes:
- Trusted network proxy is a local domain-filtering proxy for sandbox tool execution. It controls which domains tools can reach.
- Upstream proxy routes traffic through your corporate/network proxy to reach the internet.
When both are active, tool traffic routes through the trusted-network proxy (which enforces domain allowlists), while provider and channel traffic routes through the upstream proxy.
Configuration Reference
Manually authored from source:
crates/config/src/schema/+crates/config/src/validate/schema_map.rs
Every valid
moltis.tomloption, organized by domain.
Types:
string,bool,integer,float,array,map,optional,enum(...).
Defaults shown as TOML values.
—means the field has no explicit default (uses RustDefault).
Contents
- Server & Networking
- Observability
- Identity & User
- Chat & Agents
- Tools — Execution
- Tools — Web & Data
- Tools — Policy & Agent Limits
- Channels & Integrations
- Memory
- Scheduling & Webhooks
- LLM Providers
- Voice — Text-to-Speech
- Voice — Speech-to-Text
- Environment
- Server & Networking
- Observability
- Identity & User
- Chat & Agents
- Tools — Execution
- Tools — Web & Data
- Tools — Policy & Agent Limits
- Channels & Integrations
- Memory
- Scheduling & Webhooks
- LLM Providers
- Voice — Text-to-Speech
- Voice — Speech-to-Text
- Environment
Server & Networking
server — ServerConfig
Gateway server configuration.
| Key | Type | Default | Description |
|---|---|---|---|
bind | string | "127.0.0.1" | Address to bind to. |
port | integer | 0 | Port to listen on. 0 is replaced with a random available port when config is created. |
http_request_logs | bool | false | Enable verbose Axum/Tower HTTP request logs (http_request spans). Useful for debugging redirects and request flow. |
ws_request_logs | bool | false | Enable WebSocket request/response logs (ws: entries). Useful for debugging RPC calls from the web UI. |
log_buffer_size | integer | 1000 | Maximum number of log entries kept in the in-memory ring buffer. Older entries are persisted to disk. Increase for busy servers, decrease for memory-constrained devices. |
update_releases_url | optional string | — | URL of the releases manifest (releases.json) used by the update checker. Defaults to https://www.moltis.org/releases.json when unset. |
db_pool_max_connections | integer | 5 | Maximum number of SQLite pool connections. Lower values reduce memory usage for personal gateways. |
shiki_cdn_url | optional string | — | Base URL for the Shiki syntax-highlighting library loaded by the web UI. Defaults to https://esm.sh/shiki@3.2.1?bundle when unset. |
terminal_enabled | bool | true | Enable or disable the host terminal in the web UI. Set to false to prevent an unsandboxed shell. The MOLTIS_TERMINAL_DISABLED env var (1 or true) takes precedence. |
auth — AuthConfig
Authentication configuration.
| Key | Type | Default | Description |
|---|---|---|---|
disabled | bool | false | When true, authentication is explicitly disabled (no login required). |
tls — TlsConfig
TLS configuration for the gateway HTTPS server.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Enable HTTPS with auto-generated certificates. |
auto_generate | bool | true | Auto-generate a local CA and server certificate on first run. |
cert_path | optional string | — | Path to a custom server certificate (PEM). Overrides auto-generation. |
key_path | optional string | — | Path to a custom server private key (PEM). Overrides auto-generation. |
ca_cert_path | optional string | — | Path to the CA certificate (PEM) used for trust instructions. |
http_redirect_port | optional integer | — | Port for the plain-HTTP redirect/CA-download server. Defaults to the gateway port + 1 when not set. |
graphql — GraphqlConfig
Runtime GraphQL server configuration.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Whether GraphQL HTTP/WS handlers accept requests. |
ngrok — NgrokConfig
ngrok public HTTPS tunnel configuration.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Whether the ngrok tunnel is enabled. |
authtoken | optional secret string | — | ngrok authtoken. If unset, NGROK_AUTHTOKEN env var is used. |
domain | optional string | — | Optional reserved/static domain to request from ngrok. |
tailscale — TailscaleConfig
Tailscale Serve/Funnel configuration.
| Key | Type | Default | Description |
|---|---|---|---|
mode | string | "off" | Tailscale mode: "off", "serve", or "funnel". |
reset_on_exit | bool | true | Reset tailscale serve/funnel when the gateway shuts down. |
upstream_proxy (top-level scalar)
| Key | Type | Default | Description |
|---|---|---|---|
upstream_proxy | optional secret string | — | Upstream HTTP/SOCKS proxy for all outbound requests. Supports http://, https://, socks5://, and socks5h:// schemes. Proxy auth via URL: http://user:pass@host:port. Overrides HTTP_PROXY/HTTPS_PROXY/ALL_PROXY env vars. Localhost/loopback addresses are automatically excluded (no_proxy). |
failover — FailoverConfig
Automatic model/provider failover configuration.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Whether failover is enabled. |
fallback_models | array | [] | Ordered list of fallback model IDs to try when the primary fails. If empty, the chain is built from all registered models. |
Observability
metrics — MetricsConfig
Metrics and observability configuration.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Whether metrics collection is enabled. |
prometheus_endpoint | bool | true | Whether to expose the /metrics Prometheus endpoint. |
history_points | integer | 360 | Maximum number of in-memory history points for time-series charts (sampled every 30 s; 360 ≈ 3 hours). Historical data is persisted to SQLite regardless. |
labels | map | {} | Additional labels to add to all metrics. |
Identity & User
identity — AgentIdentity
Agent identity (name, emoji, theme).
| Key | Type | Default | Description |
|---|---|---|---|
name | optional string | — | Agent display name. Falls back to "moltis" when unset. |
emoji | optional string | — | Agent emoji icon. |
theme | optional string | — | Agent theme identifier. |
user — UserProfile
User profile collected during onboarding.
| Key | Type | Default | Description |
|---|---|---|---|
name | optional string | — | User’s display name. |
timezone | optional string | — | IANA timezone (e.g. "Europe/Paris"). |
Chat & Agents
chat — ChatConfig
| Key | Type | Default | Description |
|---|---|---|---|
message_queue_mode | enum: followup, collect | "followup" | How to handle messages that arrive while an agent run is active. followup queues each message and replays them one-by-one; collect concatenates and processes as a single message. |
prompt_memory_mode | enum: live-reload, frozen-at-session-start | "live-reload" | How MEMORY.md is loaded into the prompt for an ongoing session. live-reload reloads from disk before each turn; frozen-at-session-start freezes the initial content for the session lifetime. |
workspace_file_max_chars | integer | 32000 | Maximum characters from each workspace prompt file (AGENTS.md, TOOLS.md). |
priority_models | array | [] | Preferred model IDs to show first in selectors (full or raw model IDs). |
allowed_models | array | [] | ⚠️ Deprecated. Legacy model allowlist kept for backward compatibility; currently ignored (model visibility is provider-driven). Will be removed in a future release. |
chat.compaction — CompactionConfig
| Key | Type | Default | Description |
|---|---|---|---|
mode | enum: deterministic, recency-preserving, structured, llm-replace | "deterministic" | Compaction strategy. deterministic extracts a summary from message structure (zero LLM calls). recency-preserving keeps head + tail verbatim, collapses middle. structured keeps head + tail and LLM-summarises the middle. llm-replace replaces entire history with an LLM summary. |
threshold_percent | float | 0.95 | Fraction of the session model’s context window at which automatic compaction fires. Clamped to 0.1–0.95. |
protect_head | integer | 3 | Number of head messages preserved verbatim by recency-preserving and structured modes. |
protect_tail_min | integer | 20 | Minimum number of tail messages preserved verbatim (floor under token-budget cut). |
tail_budget_ratio | float | 0.20 | Tail protection window as a fraction of threshold_percent × context_window. |
tool_prune_char_threshold | integer | 200 | Tool-result content longer than this is replaced with a placeholder in the collapsed middle region. |
summary_model | optional string | null | Provider-qualified model for LLM summary calls (e.g. "openrouter/google/gemini-2.5-flash"). ⚠️ Not yet implemented — setting this field triggers a warning. |
max_summary_tokens | integer | 4096 | Maximum output tokens for LLM summary calls. 0 accepts provider default. ⚠️ Not yet implemented — has no effect. |
show_settings_hint | bool | true | Whether the “Change chat.compaction.mode in moltis.toml…” hint is included in compaction notifications. |
agents — AgentsConfig
| Key | Type | Default | Description |
|---|---|---|---|
default_preset | optional string | "research" | Default preset name used when spawn_agent.preset is omitted. Applies only to sub-agents. |
presets | map of AgentPreset | built-in presets | Named spawn presets, keyed by name. Built-ins: research, coder, reviewer, qa, ux, docs, coordinator. |
agents.presets.<name> — AgentPreset
| Key | Type | Default | Description |
|---|---|---|---|
model | optional string | null | Optional model override for this preset. |
tools.allow | array | [] | Tools to allow (whitelist). If empty, all tools are allowed. |
tools.deny | array | [] | Tools to deny (blacklist). Applied after allow. |
delegate_only | bool | false | Restrict sub-agent to delegation/session/task tools only. |
system_prompt_suffix | optional string | null | Extra instructions appended to the sub-agent system prompt. |
max_iterations | optional integer | null | Maximum iterations for the agent loop. |
timeout_secs | optional integer | null | Timeout in seconds for the sub-agent. |
reasoning_effort | optional enum: low, medium, high | null | Reasoning/thinking effort level for models that support extended thinking (e.g. Claude Opus, OpenAI o-series). |
sessions | optional SessionAccessPolicyConfig | null | Session access policy for inter-agent communication. |
memory | optional PresetMemoryConfig | null | Persistent per-agent memory configuration. |
agents.presets.<name>.identity (AgentIdentity)
| Key | Type | Default | Description |
|---|---|---|---|
name | optional string | null | Agent display name. |
emoji | optional string | null | Agent emoji identifier. |
theme | optional string | null | Agent theme identifier. |
agents.presets.<name>.sessions (SessionAccessPolicyConfig)
| Key | Type | Default | Description |
|---|---|---|---|
key_prefix | optional string | null | Only see sessions with keys matching this prefix. |
allowed_keys | array | [] | Explicit session keys the agent can access (in addition to prefix). |
can_send | bool | true | Whether the agent can send messages to sessions. |
cross_agent | bool | false | Whether the agent can access sessions from other agents. |
agents.presets.<name>.memory (PresetMemoryConfig)
| Key | Type | Default | Description |
|---|---|---|---|
scope | enum: user, project, local | "user" | Memory scope: user stores in ~/.moltis/agent-memory/<preset>/, project in .moltis/agent-memory/<preset>/, local in .moltis/agent-memory-local/<preset>/. |
max_lines | integer | 200 | Maximum lines to load from MEMORY.md. |
modes — ModesConfig
Modes are temporary per-session prompt overlays selected with /mode. They do
not create chat agents, change memory, or affect spawn_agent presets.
| Key | Type | Default | Description |
|---|---|---|---|
presets | map of ModePreset | built-in presets | Named mode presets. Built-ins: concise, technical, creative, teacher, plan, build, review, research, elevated. |
modes.presets.<name> — ModePreset
| Key | Type | Default | Description |
|---|---|---|---|
name | optional string | null | Display name shown in the UI and /mode list. |
description | optional string | null | Short user-facing summary. |
prompt | string | "" | Prompt overlay injected into the active session while this mode is selected. |
skills — SkillsConfig
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Whether the skills system is enabled. |
search_paths | array | [] | Extra directories to search for skills. |
auto_load | array | [] | Skills to always load (by name) without explicit activation. |
enable_agent_sidecar_files | bool | false | Whether agents may write supplementary files inside personal skill directories. |
enable_self_improvement | bool | true | Include system prompt guidance encouraging the agent to autonomously create and update skills after complex tasks. |
Tools — Execution
tools.exec — ExecConfig
| Key | Type | Default | Description |
|---|---|---|---|
default_timeout_secs | integer | 30 | Default wall-clock timeout in seconds for command execution. |
max_output_bytes | integer | 204800 | Maximum bytes of stdout/stderr captured per command. |
approval_mode | string | "on-miss" | When operator approval is required ("always", "on-miss", "never"). |
security_level | string | "allowlist" | Security enforcement level ("allowlist", "sandbox", etc.). |
allowlist | array | [] | List of command globs permitted without sandboxing. |
host | string | "local" | Where to run commands: "local", "node", or "ssh". |
node | optional string | null | Default node id or display name for remote execution (when host = "node"). |
ssh_target | optional string | null | Default SSH target for remote execution (when host = "ssh"). |
tools.exec.sandbox — SandboxConfig
| Key | Type | Default | Description |
|---|---|---|---|
mode | string | "all" | When sandboxing is active ("all", "auto", "off"). |
scope | string | "session" | Container lifetime ("session" or "per-command"). |
workspace_mount | string | "ro" | Workspace mount mode ("ro", "rw", "none"). |
host_data_dir | optional string | null | Host-visible path for Moltis data_dir() when creating sandbox containers from inside another container. |
home_persistence | enum: "off", "session", "shared" | "shared" | Persistence strategy for /home/sandbox in sandbox containers. |
shared_home_dir | optional string | null | Host directory for shared /home/sandbox persistence. Relative paths resolved against data_dir(). |
image | optional string | null | Docker/Podman image for sandbox containers. |
container_prefix | optional string | null | Name prefix for created containers. |
no_network | bool | false | Disable all network access in sandbox containers. |
network | string | "trusted" | Network policy: "blocked", "trusted" (proxy-filtered), or "bypass" (unrestricted). |
trusted_domains | array | [] | Domains allowed through the proxy in "trusted" network mode. |
backend | string | "auto" | Sandbox backend: "auto", "docker", "podman", "apple-container", "restricted-host", or "wasm". |
packages | array | (~130 packages) | Packages to install via apt-get in the sandbox image. Empty list to skip. |
wasm_fuel_limit | optional integer | null | Fuel limit for WASM sandbox execution (instructions). |
wasm_epoch_interval_ms | optional integer | null | Epoch interruption interval in milliseconds for WASM sandbox. |
tools.exec.sandbox.resource_limits — ResourceLimitsConfig
| Key | Type | Default | Description |
|---|---|---|---|
memory_limit | optional string | null | Memory limit for sandbox containers (e.g. "512M", "1G"). |
cpu_quota | optional float | null | CPU quota as a fraction (e.g. 0.5 = half a core, 2.0 = two cores). |
pids_max | optional integer | null | Maximum number of PIDs allowed in the sandbox. |
tools.exec.sandbox.tools_policy — ToolPolicyConfig
| Key | Type | Default | Description |
|---|---|---|---|
allow | array | [] | Tool names explicitly allowed inside the sandbox. |
deny | array | [] | Tool names explicitly denied inside the sandbox. |
profile | optional string | null | Named policy profile to apply (e.g. "restricted"). |
tools.exec.sandbox.wasm_tool_limits — WasmToolLimitsConfig
| Key | Type | Default | Description |
|---|---|---|---|
default_memory | integer | 16777216 | Default WASM memory limit in bytes (16 MB). |
default_fuel | integer | 1000000 | Default WASM fuel limit (instructions). |
tool_overrides | map | (see below) | Per-tool overrides for WASM fuel and memory. |
tools.exec.sandbox.wasm_tool_limits.tool_overrides. (ToolLimitOverrideConfig)
| Key | Type | Default | Description |
|---|---|---|---|
fuel | optional integer | null | Per-tool WASM fuel override (instructions). |
memory | optional integer | null | Per-tool WASM memory override (bytes). |
Default tool_overrides entries:
| Tool | fuel | memory |
|---|---|---|
calc | 100000 | 2097152 (2 MB) |
web_fetch | 10000000 | 33554432 (32 MB) |
web_search | 10000000 | 33554432 (32 MB) |
show_map | 10000000 | 67108864 (64 MB) |
location | 5000000 | 16777216 (16 MB) |
tools.browser — BrowserConfig
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Whether browser support is enabled. |
chrome_path | optional string | null | Path to Chrome/Chromium binary (auto-detected if not set). |
obscura_path | optional string | null | Path to the Obscura binary for browser = "obscura" requests (auto-detected from OBSCURA or PATH if not set). |
lightpanda_path | optional string | null | Path to the Lightpanda binary for browser = "lightpanda" requests (auto-detected from LIGHTPANDA or PATH if not set). |
headless | bool | true | Whether to run in headless mode. |
viewport_width | integer | 2560 | Default viewport width in pixels. |
viewport_height | integer | 1440 | Default viewport height in pixels. |
device_scale_factor | float | 2.0 | Device scale factor for HiDPI/Retina displays (1.0, 2.0, 3.0). |
max_instances | integer | 0 | Maximum concurrent browser instances (0 = unlimited, limited by memory). |
memory_limit_percent | integer | 90 | System memory usage threshold (0–100) above which new instances are blocked. |
idle_timeout_secs | integer | 300 | Instance idle timeout in seconds before closing. |
navigation_timeout_ms | integer | 30000 | Default navigation timeout in milliseconds. |
user_agent | optional string | null | Custom user agent string (uses Chrome default if not set). |
chrome_args | array | [] | Additional Chrome command-line arguments. |
sandbox_image | string | "docker.io/browserless/chrome" | Docker image for sandboxed browser instances. |
allowed_domains | array | [] | Allowed navigation domains (empty = all allowed). Supports wildcards ("*.example.com"). |
low_memory_threshold_mb | integer | 2048 | System RAM threshold (MB) below which memory-saving Chrome flags are injected (0 to disable). |
persist_profile | bool | true | Persist Chrome user profile (cookies, auth, local storage) across sessions. |
profile_dir | optional string | null | Custom path for persistent Chrome profile directory. Implies persist_profile = true. |
container_host | string | "127.0.0.1" | Hostname/IP to connect to the browser container from the host. Use "host.docker.internal" when Moltis runs inside Docker. |
browserless_api_version | enum: "v1", "v2" | "v1" | Browserless API compatibility mode for websocket endpoints. |
Tools — Web & Data
tools.web.search — WebSearchConfig
| Key | Type | Default | Description |
|---|---|---|---|
| enabled | bool | true | Enable the web search tool. |
| provider | string (enum) | "brave" | Search provider. One of: brave, perplexity, firecrawl. |
| api_key | optional string | — | API key (overrides BRAVE_API_KEY env var). |
| max_results | integer | 5 | Maximum number of results to return (1–10). |
| timeout_seconds | integer | 30 | HTTP request timeout in seconds. |
| cache_ttl_minutes | integer | 15 | In-memory cache TTL in minutes (0 to disable). |
| duckduckgo_fallback | bool | false | Enable DuckDuckGo HTML fallback when no provider API key is configured. Disabled by default because it may trigger CAPTCHA challenges. |
tools.web.search.perplexity — PerplexityConfig
| Key | Type | Default | Description |
|---|---|---|---|
| api_key | optional string | — | API key (overrides PERPLEXITY_API_KEY / OPENROUTER_API_KEY env vars). |
| base_url | optional string | — | Base URL override. Auto-detected from key prefix if empty. |
| model | optional string | — | Model to use. |
tools.web.fetch — WebFetchConfig
| Key | Type | Default | Description |
|---|---|---|---|
| enabled | bool | true | Enable the web fetch tool. |
| max_chars | integer | 50000 | Maximum characters to return from fetched content. |
| timeout_seconds | integer | 30 | HTTP request timeout in seconds. |
| cache_ttl_minutes | integer | 15 | In-memory cache TTL in minutes (0 to disable). |
| max_redirects | integer | 3 | Maximum number of HTTP redirects to follow. |
| readability | bool | true | Use readability extraction for HTML pages. |
| ssrf_allowlist | array | [] | CIDR ranges exempt from SSRF blocking (e.g. ["172.22.0.0/16"]). Default: empty (all private IPs blocked). ⚠️ Security: ranges added here bypass SSRF protection. Only add specific, trusted CIDR ranges (e.g. a known sidecar subnet), never broad private ranges like 10.0.0.0/8. |
tools.web.firecrawl — FirecrawlConfig
| Key | Type | Default | Description |
|---|---|---|---|
| enabled | bool | false | Enable Firecrawl integration. |
| api_key | optional string | — | Firecrawl API key (overrides FIRECRAWL_API_KEY env var). |
| base_url | string | "https://api.firecrawl.dev" | Firecrawl API base URL (for self-hosted instances). |
| only_main_content | bool | true | Only extract main content (skip navs, footers, etc.). |
| timeout_seconds | integer | 30 | HTTP request timeout in seconds. |
| cache_ttl_minutes | integer | 15 | In-memory cache TTL in minutes (0 to disable). |
| web_fetch_fallback | bool | true | Use Firecrawl as fallback in web_fetch when readability extraction produces poor results. |
tools.fs — FsToolsConfig
| Key | Type | Default | Description |
|---|---|---|---|
| workspace_root | optional string | — | Default search root used by Glob and Grep when the LLM call omits the path argument. Must be an absolute path. When unset, calls without an explicit path are rejected. |
| allow_paths | array | [] | Absolute path globs the tools are allowed to access. Empty list means all paths allowed. Evaluated after canonicalization. |
| deny_paths | array | [] | Absolute path globs the tools must refuse. Deny wins over allow. Evaluated after canonicalization. |
| track_reads | bool | false | Whether to track per-session read history (files read, re-read loop detection). Required for must_read_before_write. |
| must_read_before_write | bool | false | Reject Write/Edit/MultiEdit calls targeting files the session has not previously Read. Requires track_reads = true. |
| require_approval | bool | false | Whether Write/Edit/MultiEdit must pause for explicit operator approval before mutating a file. |
| max_read_bytes | integer | 10485760 (10 MB) | Maximum bytes a single Read call can return before the file is rejected with a typed too_large payload. |
| binary_policy | string (enum) | "reject" | What to do with binary files encountered by Read. One of: reject (return typed marker without content), base64 (return base64-encoded content). |
| respect_gitignore | bool | true | Whether Glob and Grep respect .gitignore / .ignore files and .git/info/exclude while walking. |
| checkpoint_before_mutation | bool | false | When true, Write/Edit/MultiEdit create a per-file backup before mutating, so the LLM can restore the pre-edit state via checkpoint_restore. |
| context_window_tokens | optional integer | — | Model context window in tokens. When set, Read’s per-call byte cap scales adaptively so a single Read call can’t consume more than ~20% of the model’s working set. Clamped to [50 KB, 512 KB]. When unset, Read uses a fixed 256 KB cap. Typical values: 200000 (Claude 3.5/4 Sonnet), 1000000 (Claude Opus 4.6), 128000 (GPT-4 Turbo). |
tools.maps — MapsConfig
| Key | Type | Default | Description |
|---|---|---|---|
| provider | string (enum) | "google_maps" | Preferred map provider used by show_map. One of: google_maps, apple_maps, openstreetmap. |
Tools — Policy & Agent Limits
tools.policy — ToolPolicyConfig
| Key | Type | Default | Description |
|---|---|---|---|
| allow | array | [] | Tool names or glob patterns that are explicitly allowed. |
| deny | array | [] | Tool names or glob patterns that are explicitly denied. |
| profile | optional string | — | Named policy profile to apply. |
tools — Agent-level scalars
| Key | Type | Default | Description |
|---|---|---|---|
| agent_timeout_secs | integer | 600 | Maximum wall-clock seconds for an agent run (0 = no timeout). |
| agent_max_iterations | integer | 25 | Maximum number of agent loop iterations before aborting. |
| agent_max_auto_continues | integer | 2 | Maximum auto-continue nudges when the model stops mid-task (0 = disabled). |
| agent_auto_continue_min_tool_calls | integer | 3 | Minimum tool calls in the current run before auto-continue can trigger. |
| max_tool_result_bytes | integer | 50000 (50 KB) | Maximum bytes for a single tool result before truncation. |
| registry_mode | string (enum) | "full" | How tool schemas are presented to the model. One of: full (all schemas sent every turn), lazy (only tool_search sent; model discovers tools on demand). |
| agent_loop_detector_window | integer | 2 | Window size for the tool-call reflex-loop detector. When this many consecutive tool calls share the same tool + (args or error), the runner injects a directive intervention message. Set to 0 to disable. |
| agent_loop_detector_strip_tools_on_second_fire | bool | true | When the loop detector fires a second time (stage 2), strip the tool schema list for a single LLM turn so the model is forced to respond in text. |
Channels & Integrations
channels — ChannelsConfig
| Key | Type | Default | Description |
|---|---|---|---|
offered | array of string | ["telegram", "whatsapp", "msteams", "discord", "slack", "matrix", "nostr", "signal"] | Which channel types are offered in the web UI (onboarding + channels page). |
<channel_type> | map of serde_json::Value | {} | Account configs keyed by account name. Known types: telegram, whatsapp, msteams, discord, slack, matrix, nostr, signal. Additional types accepted via flatten. |
Each channel account (channels.<channel_type>.<account_name>) is an arbitrary JSON object that may contain provider-specific keys plus a tools sub-block (see below).
channels.*.<account>.tools — ChannelToolPolicyOverride
| Key | Type | Default | Description |
|---|---|---|---|
groups | map of GroupToolPolicy | {} | Per-chat-type policies, keyed by chat type ("private", "group", etc.). |
channels.*.<account>.tools.groups.<group_id> — GroupToolPolicy
| Key | Type | Default | Description |
|---|---|---|---|
allow | array of string | [] | Tool names/patterns to allow. |
deny | array of string | [] | Tool names/patterns to deny. |
by_sender | map of ToolPolicyConfig | {} | Per-sender overrides within this group, keyed by sender/peer ID. |
channels.*.<account>.tools.groups.<group_id>.by_sender.<sender_id> — ToolPolicyConfig
| Key | Type | Default | Description |
|---|---|---|---|
allow | array of string | [] | Tool names/patterns to allow for this sender. |
deny | array of string | [] | Tool names/patterns to deny for this sender. |
profile | optional string | None | Agent profile to use for this sender. |
hooks — HooksConfig
| Key | Type | Default | Description |
|---|---|---|---|
hooks | array of ShellHookConfigEntry | [] | Shell hooks defined in the config file. |
hooks.hooks[] — ShellHookConfigEntry
| Key | Type | Default | Description |
|---|---|---|---|
name | string | (required) | Human-readable hook name. |
command | string | (required) | Shell command to execute. |
events | array of string | (required) | Event names that trigger this hook. |
timeout | integer | 10 | Timeout in seconds for the hook process. |
env | map of string | {} | Environment variables to set for the hook process. |
mcp — McpConfig
| Key | Type | Default | Description |
|---|---|---|---|
request_timeout_secs | integer | 30 | Default timeout for MCP requests in seconds. |
servers | map of McpServerEntry | {} | Configured MCP servers, keyed by server name. |
mcp.servers.<name> — McpServerEntry
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "" | Command to spawn the server process (stdio transport). |
args | array of string | [] | Arguments to the command. |
env | map of string | {} | Environment variables to set for the process. |
enabled | bool | true | Whether this server is enabled. |
request_timeout_secs | optional integer | None | Optional per-server MCP request timeout override in seconds. |
transport | string | "" | Transport type: "stdio" (default), "sse", or "streamable-http". |
url | optional string | None | URL for SSE/Streamable HTTP transport. Required when transport is "sse" or "streamable-http". |
headers | map of string | {} | Custom headers for remote HTTP/SSE transport. |
oauth | optional McpOAuthOverrideEntry | None | Manual OAuth override for servers that don’t support standard discovery. |
display_name | optional string | None | Custom display name for the server (shown in UI instead of technical ID). |
mcp.servers.<name>.oauth — McpOAuthOverrideEntry
| Key | Type | Default | Description |
|---|---|---|---|
client_id | string | (required) | The OAuth client ID. |
auth_url | string | (required) | The authorization endpoint URL. |
token_url | string | (required) | The token endpoint URL. |
scopes | array of string | [] | OAuth scopes to request. |
Memory
memory
Struct: MemoryEmbeddingConfig
| Key | Type | Default | Description |
|---|---|---|---|
style | enum (hybrid, prompt-only, search-only, off) | "hybrid" | High-level memory orchestration style. |
agent_write_mode | enum (hybrid, prompt-only, search-only, off) | "hybrid" | Where agent-authored memory writes are allowed to land. |
user_profile_write_mode | enum (explicit-and-auto, explicit-only, off) | "explicit-and-auto" | How Moltis writes the managed USER.md profile surface. |
backend | enum (builtin, qmd) | "builtin" | Memory backend used for search, retrieval, and indexing. |
provider | optional enum (local, ollama, openai, custom) | auto-detect | Embedding provider. Alias: embedding_provider. |
disable_rag | bool | false | Disable RAG embeddings and force keyword-only memory search. |
base_url | optional string | — | Base URL for the embedding API. Alias: embedding_base_url. |
model | optional string | — | Model name for embeddings. Alias: embedding_model. |
api_key | optional string (secret) | — | API key for the embedding endpoint. Alias: embedding_api_key. |
citations | enum (on, off, auto) | "auto" | Citation mode for memory search results. |
llm_reranking | bool | false | Enable LLM reranking for hybrid search results. |
search_merge_strategy | enum (rrf, linear) | "rrf" | Merge strategy for hybrid search results. |
session_export | enum (off, on-new-or-reset) | "on-new-or-reset" | How session transcripts are exported into searchable memory. |
qmd | map (see memory.qmd) | {} | QMD-specific configuration (only used when backend = "qmd"). |
memory.qmd
Struct: QmdConfig
| Key | Type | Default | Description |
|---|---|---|---|
command | optional string | "qmd" | Path to the qmd binary. |
collections | map of name → QmdCollection | {} | Named collections with paths and glob patterns. |
max_results | optional integer | — | Maximum results to retrieve. |
timeout_ms | optional integer | — | Search timeout in milliseconds. |
memory.qmd.collections.<name>
Struct: QmdCollection
| Key | Type | Default | Description |
|---|---|---|---|
paths | array of string | [] | Paths to include in this collection. |
globs | array of string | [] | Glob patterns to filter files. |
Scheduling & Webhooks
heartbeat
Struct: HeartbeatConfig
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Whether the heartbeat is enabled. |
every | string | "30m" | Interval between heartbeats (e.g. "30m", "1h"). |
model | optional string | — | Provider/model override for heartbeat turns. |
prompt | optional string | — | Custom prompt override. Empty uses the built-in default. |
ack_max_chars | integer | 300 | Max characters for an acknowledgment reply before truncation. |
active_hours | map (see heartbeat.active_hours) | — | Active hours window — heartbeats only run during this window. |
deliver | bool | false | Whether heartbeat replies should be delivered to a channel account. |
channel | optional string | — | Channel account identifier for heartbeat delivery. |
to | optional string | — | Destination chat/recipient id for heartbeat delivery. |
sandbox_enabled | bool | true | Whether heartbeat runs inside a sandbox. |
sandbox_image | optional string | — | Override sandbox image for heartbeat. |
wake_cooldown | string | "5m" | Minimum duration between exec-triggered heartbeat wakes. Use "0" to disable the guard. |
heartbeat.active_hours
Struct: ActiveHoursConfig
| Key | Type | Default | Description |
|---|---|---|---|
start | string | "08:00" | Start time in HH:MM format. |
end | string | "24:00" | End time in HH:MM format. |
timezone | string | "local" | IANA timezone (e.g. "Europe/Paris") or "local". |
cron
Struct: CronConfig
| Key | Type | Default | Description |
|---|---|---|---|
rate_limit_max | integer | 10 | Maximum number of jobs within the rate limit window. |
rate_limit_window_secs | integer | 60 | Rate limit window in seconds. |
session_retention_days | optional integer | 7 | Days to retain cron session data before auto-cleanup. None disables pruning. |
auto_prune_cron_containers | bool | true | Whether to auto-prune sandbox containers after cron job completion. |
caldav
Struct: CalDavConfig
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Whether CalDAV integration is enabled. |
default_account | optional string | — | Default account name to use when none is specified. |
accounts | map of name → CalDavAccountConfig | {} | Named CalDAV accounts. |
caldav.accounts.<name>
Struct: CalDavAccountConfig
| Key | Type | Default | Description |
|---|---|---|---|
url | optional string | — | CalDAV server URL. |
username | optional string | — | Username for authentication. |
password | optional string (secret) | — | Password or app-specific password. |
provider | optional string | — | Provider hint: "fastmail", "icloud", or "generic". |
timeout_seconds | integer | 30 | HTTP request timeout in seconds. |
webhooks
Struct: WebhooksConfig
| Key | Type | Default | Description |
|---|---|---|---|
rate_limit | map (see webhooks.rate_limit) | — | Per-account rate limiting settings. |
webhooks.rate_limit
Struct: WebhookRateLimitConfig
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Whether rate limiting is enabled. |
requests_per_minute | optional integer | — | Max requests per minute per account. None uses per-channel defaults. |
burst | optional integer | — | Burst allowance per account. |
cleanup_interval_secs | integer | 300 | Interval in seconds between stale bucket cleanup. |
LLM Providers
providers
Struct: ProvidersConfig
| Key | Type | Default | Description |
|---|---|---|---|
offered | array of string | [] | Allowlist of enabled providers (also controls web UI pickers). Empty = all enabled. |
show_legacy_models | bool | false | Show models older than one year in the chat model selector. |
<name> | ProviderEntry (see below) | — | Provider-specific settings keyed by provider name. |
providers.<name> — ProviderEntry
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Whether this provider is enabled. |
api_key | optional string (secret) | — | Override the API key. Env var takes precedence if set. |
base_url | optional string | — | Override the base URL. Alias: url. |
models | array of string | [] | Preferred model IDs shown first in model pickers. |
fetch_models | bool | true | Whether to fetch provider model catalogs dynamically. |
stream_transport | enum (sse, websocket, auto) | "sse" | Streaming transport for this provider. |
wire_api | enum (chat-completions, responses) | "chat-completions" | Wire format for this provider’s HTTP API. |
alias | optional string | — | Alias used in metrics labels instead of the provider name. |
tool_mode | enum (auto, native, text, off) | "auto" | How tool calling is handled for this provider. |
cache_retention | enum (none, short, long) | "short" | Prompt cache retention policy. |
policy | optional ToolPolicyConfig (see below) | — | Tool policy override merged on top of global [tools.policy]. |
model_overrides | map of ModelOverride | {} | Per-model context window overrides. Keys are model IDs. |
providers.<name>.model_overrides.<model_id> — ModelOverride
| Key | Type | Default | Description |
|---|---|---|---|
context_window | optional integer | — | Override the context window size (in tokens) for this model. Must be ≥ 1. Values > 10,000,000 produce a warning. |
models — Global Model Overrides
Struct: HashMap<String, ModelOverride>
Per-model overrides that apply across all providers. Provider-scoped overrides (providers.<name>.model_overrides.<id>) take precedence over these.
[models.claude-opus-4-6]
context_window = 1_000_000
providers.<name>.policy
Struct: ToolPolicyConfig
| Key | Type | Default | Description |
|---|---|---|---|
allow | array of string | [] | Tool names to allow. |
deny | array of string | [] | Tool names to deny. |
profile | optional string | — | Named policy profile to apply. |
voice
Struct: VoiceConfig
| Key | Type | Default | Description |
|---|---|---|---|
tts | VoiceTtsConfig | (see below) | Text-to-speech settings |
stt | VoiceSttConfig | (see below) | Speech-to-text settings |
Voice — Text-to-Speech
voice.tts
Struct: VoiceTtsConfig
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Enable TTS globally |
provider | string | "" | Active provider ("openai", "elevenlabs", "google", "piper", "coqui"). Empty string auto-selects the first configured provider |
providers | array of string | [] | Provider IDs to list in the UI. Empty means list all |
elevenlabs | VoiceElevenLabsConfig | (see below) | ElevenLabs-specific settings |
openai | VoiceOpenAiConfig | (see below) | OpenAI TTS settings |
google | VoiceGoogleTtsConfig | (see below) | Google Cloud TTS settings |
piper | VoicePiperTtsConfig | (see below) | Piper (local) settings |
coqui | VoiceCoquiTtsConfig | (see below) | Coqui TTS (local server) settings |
voice.tts.elevenlabs
Struct: VoiceElevenLabsConfig
| Key | Type | Default | Description |
|---|---|---|---|
api_key | optional secret string | null | API key (from ELEVENLABS_API_KEY env or config) |
voice_id | optional string | null | Default voice ID |
model | optional string | null | Model to use (e.g. "eleven_flash_v2_5" for lowest latency) |
voice.tts.openai
Struct: VoiceOpenAiConfig
| Key | Type | Default | Description |
|---|---|---|---|
api_key | optional secret string | null | API key (from OPENAI_API_KEY env or config) |
base_url | optional string | null | Override the OpenAI TTS endpoint for compatible local servers |
voice | optional string | null | Voice to use for TTS (alloy, echo, fable, onyx, nova, shimmer) |
model | optional string | null | Model to use for TTS (tts-1, tts-1-hd) |
voice.tts.google
Struct: VoiceGoogleTtsConfig
| Key | Type | Default | Description |
|---|---|---|---|
api_key | optional secret string | null | API key for Google Cloud Text-to-Speech |
voice | optional string | null | Voice name (e.g. "en-US-Neural2-A", "en-US-Wavenet-D") |
language_code | optional string | null | Language code (e.g. "en-US", "fr-FR") |
speaking_rate | optional float | null | Speaking rate (0.25–4.0, default 1.0) |
pitch | optional float | null | Pitch (-20.0–20.0, default 0.0) |
voice.tts.piper
Struct: VoicePiperTtsConfig
| Key | Type | Default | Description |
|---|---|---|---|
binary_path | optional string | null | Path to piper binary. If not set, looks in PATH |
model_path | optional string | null | Path to the voice model file (.onnx) |
config_path | optional string | null | Path to the model config file (.onnx.json). Defaults to model_path + ".json" |
speaker_id | optional integer | null | Speaker ID for multi-speaker models |
length_scale | optional float | null | Speaking rate multiplier (default 1.0) |
voice.tts.coqui
Struct: VoiceCoquiTtsConfig
| Key | Type | Default | Description |
|---|---|---|---|
endpoint | string | "http://localhost:5002" | Coqui TTS server endpoint |
model | optional string | null | Model name to use (if server supports multiple models) |
speaker | optional string | null | Speaker name or ID for multi-speaker models |
language | optional string | null | Language code for multilingual models |
Voice — Speech-to-Text
voice.stt
Struct: VoiceSttConfig
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Enable STT globally |
provider | optional enum: whisper, groq, deepgram, google, mistral, elevenlabs-stt, voxtral-local, whisper-cli, sherpa-onnx | null | Active provider. null auto-selects the first configured provider. Values: "whisper", "groq", "deepgram", "google", "mistral", "elevenlabs-stt", "voxtral-local", "whisper-cli", "sherpa-onnx" |
providers | array of string | [] | Provider IDs to list in the UI. Empty means list all |
whisper | VoiceWhisperConfig | (see below) | OpenAI Whisper settings |
groq | VoiceGroqSttConfig | (see below) | Groq (Whisper-compatible) settings |
deepgram | VoiceDeepgramConfig | (see below) | Deepgram settings |
google | VoiceGoogleSttConfig | (see below) | Google Cloud Speech-to-Text settings |
mistral | VoiceMistralSttConfig | (see below) | Mistral AI (Voxtral Transcribe) settings |
elevenlabs | VoiceElevenLabsSttConfig | (see below) | ElevenLabs Scribe settings |
voxtral_local | VoiceVoxtralLocalConfig | (see below) | Voxtral local (vLLM server) settings |
whisper_cli | VoiceWhisperCliConfig | (see below) | whisper-cli (whisper.cpp) settings |
sherpa_onnx | VoiceSherpaOnnxConfig | (see below) | sherpa-onnx offline settings |
voice.stt.whisper
Struct: VoiceWhisperConfig
| Key | Type | Default | Description |
|---|---|---|---|
api_key | optional secret string | null | API key (from OPENAI_API_KEY env or config) |
base_url | optional string | null | Override the Whisper endpoint for compatible local servers |
model | optional string | null | Model to use (whisper-1) |
language | optional string | null | Language hint (ISO 639-1 code) |
voice.stt.groq
Struct: VoiceGroqSttConfig
| Key | Type | Default | Description |
|---|---|---|---|
api_key | optional secret string | null | API key (from GROQ_API_KEY env or config) |
model | optional string | null | Model to use (e.g. "whisper-large-v3-turbo") |
language | optional string | null | Language hint (ISO 639-1 code) |
voice.stt.deepgram
Struct: VoiceDeepgramConfig
| Key | Type | Default | Description |
|---|---|---|---|
api_key | optional secret string | null | API key (from DEEPGRAM_API_KEY env or config) |
model | optional string | null | Model to use (e.g. "nova-3") |
language | optional string | null | Language hint (e.g. "en-US") |
smart_format | bool | false | Enable smart formatting (punctuation, capitalization) |
voice.stt.google
Struct: VoiceGoogleSttConfig
| Key | Type | Default | Description |
|---|---|---|---|
api_key | optional secret string | null | API key for Google Cloud Speech-to-Text |
service_account_json | optional string | null | Path to service account JSON file (alternative to API key) |
language | optional string | null | Language code (e.g. "en-US") |
model | optional string | null | Model variant (e.g. "latest_long", "latest_short") |
voice.stt.mistral
Struct: VoiceMistralSttConfig
| Key | Type | Default | Description |
|---|---|---|---|
api_key | optional secret string | null | API key (from MISTRAL_API_KEY env or config) |
model | optional string | null | Model to use (e.g. "voxtral-mini-latest") |
language | optional string | null | Language hint (ISO 639-1 code) |
voice.stt.elevenlabs
Struct: VoiceElevenLabsSttConfig
| Key | Type | Default | Description |
|---|---|---|---|
api_key | optional secret string | null | API key (from ELEVENLABS_API_KEY env or config). Shared with TTS if not specified separately |
model | optional string | null | Model to use (scribe_v1 or scribe_v2) |
language | optional string | null | Language hint (ISO 639-1 code) |
voice.stt.voxtral_local
Struct: VoiceVoxtralLocalConfig
| Key | Type | Default | Description |
|---|---|---|---|
endpoint | string | "http://localhost:8000" | vLLM server endpoint |
model | optional string | null | Model to use (optional, server default if not set) |
language | optional string | null | Language hint (ISO 639-1 code) |
voice.stt.whisper_cli
Struct: VoiceWhisperCliConfig
| Key | Type | Default | Description |
|---|---|---|---|
binary_path | optional string | null | Path to whisper-cli binary. If not set, looks in PATH |
model_path | optional string | null | Path to the GGML model file (e.g. "~/.moltis/models/ggml-base.en.bin") |
language | optional string | null | Language hint (ISO 639-1 code) |
voice.stt.sherpa_onnx
Struct: VoiceSherpaOnnxConfig
| Key | Type | Default | Description |
|---|---|---|---|
binary_path | optional string | null | Path to sherpa-onnx-offline binary. If not set, looks in PATH |
model_dir | optional string | null | Path to the ONNX model directory |
language | optional string | null | Language hint (ISO 639-1 code) |
Environment
env
Struct: top-level HashMap<String, String> on MoltisConfig
| Key | Type | Default | Description |
|---|---|---|---|
* | string | — | Dynamic map of environment variable names to values. |
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/fmtlocal/biomelocal/zizmorlocal/lockfile— verifiesCargo.lockis in sync (cargo fetch --locked)local/lintlocal/testlocal/macos-app— validates the native Swift macOS app build (Darwinonly)local/e2e— runs gateway UI Playwright coveragelocal/e2e-ollama— opt-in live Ollama/Qwen Playwright regression check
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/lintandlocal/testuse--all-features. If you want the opt-in Vulkan path covered locally, install the Vulkan development packages first, for examplelibvulkan-devandglslang-toolson Debian/Ubuntu (on Ubuntu 22.04, installglslang-toolsfrom the LunarG Vulkan SDK). local/lintuses the same clippy flags as CI and release:cargo clippy -Z unstable-options --workspace --all-features --all-targets --timings -- -D warnings(uses the nightly pinned inrust-toolchain.toml).zizmoris installed automatically (Homebrew on macOS, apt on Linux) when not already available.zizmoris advisory in local runs and does not block lint/test execution.- Test output is suppressed unless tests fail.
local/macos-appruns only on macOS; on Linux it is marked skipped.- Override or disable macOS app validation with:
LOCAL_VALIDATE_MACOS_APP_CMDandLOCAL_VALIDATE_SKIP_MACOS_APP=1. local/e2eauto-runsnpm cionly whencrates/web/ui/node_modulesis missing, then runsnpm run e2e:installandnpm run e2e. Override withLOCAL_VALIDATE_E2E_CMD.- Enable the live Ollama/Qwen regression check with
LOCAL_VALIDATE_OLLAMA_QWEN_E2E=1. It starts a local Ollama server onMOLTIS_E2E_OLLAMA_QWEN_API_PORT(default11435), pulls the configured Qwen model if missing, and runs the dedicated Playwright project. Override the command withLOCAL_VALIDATE_OLLAMA_QWEN_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.jsconfigures Playwright and web server startup.e2e/start-gateway.shboots the gateway in deterministic test mode.e2e/start-gateway-ollama-qwen-live.shis the opt-in live-provider harness for the custom OpenAI-compatible Qwen regression path.e2e/specs/smoke.spec.jscontains smoke coverage for critical routes.
How Startup Works
e2e/start-gateway.sh:
- Creates isolated runtime directories under
target/e2e-runtime. - Seeds
IDENTITY.mdandUSER.mdso onboarding does not block tests. - Exports
MOLTIS_CONFIG_DIR,MOLTIS_DATA_DIR, and test port env. - 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.
For the Qwen multiple-system-message regression path, there is also an opt-in Playwright project:
cd crates/web/ui
MOLTIS_E2E_OLLAMA_QWEN_LIVE=1 npx playwright test --project=ollama-qwen-live e2e/specs/ollama-qwen-live.spec.js
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
| Provider | Config Name | Env Variable | Features |
|---|---|---|---|
| Anthropic | anthropic | ANTHROPIC_API_KEY | Streaming, tools, vision |
| OpenAI | openai | OPENAI_API_KEY | Streaming, tools, vision, model discovery |
| Google Gemini | gemini | GEMINI_API_KEY | Streaming, tools, vision, model discovery |
| DeepSeek | deepseek | DEEPSEEK_API_KEY | Streaming, tools, model discovery |
| Mistral | mistral | MISTRAL_API_KEY | Streaming, tools, model discovery |
| Groq | groq | GROQ_API_KEY | Streaming |
| xAI (Grok) | xai | XAI_API_KEY | Streaming |
| OpenRouter | openrouter | OPENROUTER_API_KEY | Streaming, tools, model discovery |
| Cerebras | cerebras | CEREBRAS_API_KEY | Streaming, tools, model discovery |
| MiniMax | minimax | MINIMAX_API_KEY | Streaming, tools |
| Moonshot (Kimi) | moonshot | MOONSHOT_API_KEY | Streaming, tools, model discovery |
| Venice | venice | VENICE_API_KEY | Streaming, tools, model discovery |
| Z.AI (Zhipu) | zai | Z_API_KEY | Streaming, tools, model discovery |
| Z.AI Coding Plan | zai-code | Z_CODE_API_KEY | Streaming, tools, model discovery (Coding plan billing endpoint) |
OAuth Providers
| Provider | Config Name | Notes |
|---|---|---|
| OpenAI Codex | openai-codex | OAuth flow via web UI |
| GitHub Copilot | github-copilot | Requires active Copilot subscription |
Local
| Provider | Config Name | Notes |
|---|---|---|
| Ollama | ollama | Local or remote Ollama instance |
| LM Studio | lmstudio | Local LM Studio or any OpenAI-compatible server |
| Local LLM | local-llm | Runs 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
Via Web UI (Recommended)
- Open Moltis in your browser.
- Go to Settings → Providers.
- Choose a provider card.
- Complete OAuth or enter your API key.
- 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:
| Option | Default | Description |
|---|---|---|
enabled | true | Enable or disable the provider |
api_key | — | API key (overrides env var) |
base_url | — | Override API endpoint URL |
models | [] | Preferred models shown first in the picker |
fetch_models | true | Discover available models from the API |
stream_transport | "sse" | "sse", "websocket", or "auto" |
alias | — | Custom 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.
- Get an API key from Google AI Studio.
- Set
GEMINI_API_KEYin your environment (or useGOOGLE_API_KEY). - 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
- Get an API key from console.anthropic.com.
- Set
ANTHROPIC_API_KEYin your environment.
OpenAI
- Get an API key from platform.openai.com.
- Set
OPENAI_API_KEYin your environment.
OpenAI Codex
OpenAI Codex uses OAuth-based access.
- Go to Settings → Providers → OpenAI Codex.
- Click Connect and complete the auth flow.
- 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.
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.
Once OpenAI Codex OAuth is connected, agents can use the built-in
generate_image tool to create gpt-image-2 images without an OPENAI_API_KEY.
Generated images are delivered through the same channel media path as
screenshots and send_image, so supported chat channels receive the image as a
native attachment.
GitHub Copilot
GitHub Copilot uses OAuth authentication.
- Go to Settings → Providers → GitHub Copilot.
- Click Connect.
- Complete the GitHub OAuth flow.
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
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.
- Go to Settings → Providers → Local LLM.
- Choose a model from the local registry or download one.
- 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, providermodels = [...], and[chat].priority_modelsinmoltis.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.
Choosing a Provider
Not sure which LLM provider to use? This page compares the providers supported by Moltis so you can pick the best fit for your use case.
Quick Recommendations
| Goal | Provider | Why |
|---|---|---|
| Best overall quality | Anthropic | Claude Sonnet 4 and Opus 4 excel at tool use, long context, and instruction following |
| Widest model range | OpenAI | GPT-4.1, o3/o4-mini reasoning models, image generation |
| Largest context window | Google Gemini | Up to 1M tokens with Gemini 2.5 Pro |
| Best value | DeepSeek | DeepSeek V3 and R1 offer strong performance at low cost |
| Fast inference | Groq | Hardware-accelerated inference, very low latency |
| Free / offline | Ollama | Run open models locally, no API key needed |
| Rising stars | MiniMax, Z.AI | MiniMax and GLM-4 models are gaining traction for quality and price |
Provider Comparison
| Provider | Top Models | Tool Use | Streaming | Context | Price Tier | Speed | Notes |
|---|---|---|---|---|---|---|---|
| Anthropic | Claude Sonnet 4, Opus 4 | Full | Yes | 200K | $$ | Fast | Best tool-use reliability |
| OpenAI | GPT-4.1, o3, o4-mini | Full | Yes | 128K-1M | $$ | Fast | Widest ecosystem, reasoning models |
| Google Gemini | Gemini 2.5 Pro, 2.5 Flash | Full | Yes | 1M | $ | Fast | Largest context, competitive pricing |
| DeepSeek | V3, R1 | Full | Yes | 128K | $ | Medium | Excellent quality-to-price ratio |
| Groq | Llama 3, Mixtral, Gemma | Partial | Yes | 128K | $ | Very fast | Speed-optimized hardware inference |
| xAI | Grok 3, Grok 3 Mini | Yes | Yes | 128K | $$ | Fast | Strong reasoning capabilities |
| Mistral | Mistral Large, Medium | Full | Yes | 128K | $$ | Fast | European provider, multilingual |
| OpenRouter | Any (aggregator) | Varies | Yes | Varies | Varies | Varies | Access 100+ models with one key |
| Cerebras | Llama 3 | Partial | Yes | 128K | $ | Very fast | Wafer-scale inference hardware |
| MiniMax | MiniMax-Text-01, abab7 | Full | Yes | 1M | $ | Fast | Strong multilingual, long context |
| Z.AI (Zhipu) | GLM-4, GLM-4 Air | Full | Yes | 128K | $ | Fast | GLM-4 series, competitive quality |
| Z.AI Coding | CodeGeeX, GLM-4 Code | Full | Yes | 128K | $ | Fast | Optimized for code tasks |
| Moonshot | Kimi | Full | Yes | 200K | $ | Medium | Long context, Chinese/English |
| Venice | Various | Varies | Yes | Varies | $ | Medium | Privacy-focused, uncensored models |
| Ollama | Any GGUF model | Varies | Yes | Varies | Free | Varies | Local inference, no API key |
| Local LLM | Any GGUF model | Varies | Yes | Varies | Free | Varies | Built-in GGUF runner, no server needed |
| GitHub Copilot | GPT-4o, Claude (via Copilot) | Full | Yes | Varies | Subscription | Fast | Uses existing Copilot subscription |
| OpenAI Codex | Codex models | Full | Yes | Varies | $$ | Fast | OAuth-based, code-focused |
Price Tier Legend
| Symbol | Meaning |
|---|---|
| Free | No cost (local inference) |
| $ | Budget-friendly (< $1/M input tokens) |
| $$ | Standard pricing ($1-15/M input tokens) |
| $$$ | Premium pricing (> $15/M input tokens) |
| Subscription | Flat monthly fee |
How to Choose
For personal projects or experimentation
Start with Google Gemini (generous free tier, large context) or Ollama (completely free, runs locally). Both are easy to set up and let you explore without cost pressure.
For production agent workflows
Anthropic and OpenAI are the most battle-tested for tool use and complex multi-step tasks. Anthropic’s Claude models tend to follow instructions more precisely; OpenAI offers a broader model range including reasoning models (o3, o4-mini).
For cost-sensitive workloads
DeepSeek offers the best quality-to-price ratio for most tasks. Groq and Cerebras provide extremely fast inference at low cost, though model selection is more limited.
For local / offline use
Ollama is the easiest path — install it, pull a model, and Moltis auto-detects it. Local LLM runs GGUF models directly without a separate server. Both require sufficient RAM (8GB+ for small models, 16GB+ recommended).
For access to many models
OpenRouter aggregates 100+ models behind a single API key. Useful if you want to experiment across providers without managing multiple accounts.
Setting Up a Provider
See the LLM Providers page for step-by-step setup instructions for each provider, including configuration file options and environment variables.
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
- Go to console.anthropic.com and create an account (or sign in).
- Navigate to Settings → API Keys and create a new key.
- 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
- Anthropic API — Getting Started — official auth docs (API key only)
- The Register — Anthropic clarifies ban on third-party tool access
- HN — Anthropic officially bans subscription auth for third-party use
- Claude Code #28091 — Anthropic disabled OAuth tokens for third-party apps
- Auto-Claude #1871 — OAuth policy violation
- Goose #3647 — Anthropic OAuth for third-party users
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
| Transport | Description | Use Case |
|---|---|---|
| stdio | Local process via stdin/stdout | npm packages, local scripts |
| Streamable HTTP | Remote server via HTTP | Cloud services, shared servers |
Adding an MCP Server
Via Web UI
- Go to Settings → MCP Servers
- Click Add Server
- For remote Streamable HTTP servers, enter the server URL and any optional request headers
- 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}" }
[mcp.servers.remote_http]
transport = "streamable-http"
url = "https://mcp.example.com/mcp"
headers = { Authorization = "Bearer ${API_KEY}" }
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 Settings → Environment Variables.
Popular MCP Servers
Official Servers
| Server | Description | Install |
|---|---|---|
| filesystem | Read/write local files | npx @modelcontextprotocol/server-filesystem |
| github | GitHub API access | npx @modelcontextprotocol/server-github |
| postgres | PostgreSQL queries | npx @modelcontextprotocol/server-postgres |
| sqlite | SQLite database | npx @modelcontextprotocol/server-sqlite |
| puppeteer | Browser automation | npx @modelcontextprotocol/server-puppeteer |
| brave-search | Web search | npx @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), "sse", or "streamable-http"
url = "https://mcp.example.com/mcp" # Required when transport = "sse" or "streamable-http"
headers = { "x-api-key" = "$REMOTE_MCP_KEY" } # Optional request headers
Request Timeouts
Moltis applies MCP request timeouts in two layers:
mcp.request_timeout_secssets the global default for every MCP servermcp.servers.<name>.request_timeout_secsoptionally overrides that default for a specific server
This is useful when most local MCP servers respond quickly, but one remote 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 Server Secrets and Placeholders
Remote MCP servers (SSE or Streamable HTTP) 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
$NAMEor${NAME}placeholders in remoteurlandheaders - Placeholder values resolve from Moltis-managed env overrides, either
[env]in config or Settings → Environment 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 Settings → MCP 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
- Moltis connects to the remote MCP server
- The server returns
401 Unauthorizedwith aWWW-Authenticateheader - Moltis discovers the authorization server via RFC 9728 (Protected Resource Metadata)
- Moltis performs dynamic client registration (RFC 7591)
- A PKCE authorization code flow opens your browser for login
- 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.
Running MCP Servers in Docker
When running Moltis in Docker, you have two options for stdio-based MCP servers:
Using the built-in Node.js
The Moltis Docker image includes Node.js and npm, so most MCP servers work out of the box:
[mcp.servers.filesystem]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/data"]
Tip: Mount
/home/moltis/.npmas a named volume so packages are only downloaded once, and bind-mount any directories the MCP server needs to access:docker run \ -v moltis-npm-cache:/home/moltis/.npm \ -v /host/path/to/data:/data \ ...
Using Docker containers
Since the Moltis image ships the Docker CLI (docker-ce-cli), and given a Docker daemon is reachable via the mounted socket, you can also run MCP servers as isolated containers. This is useful when you need a specific Node version, want stronger isolation, or prefer official MCP Docker images:
# Run an npm-based MCP server in a container
[mcp.servers.filesystem]
command = "docker"
args = [
"run", "--rm", "-i",
# NOTE: bind-mount paths resolve against the HOST filesystem, not the
# Moltis container. Use the same host path you mounted into Moltis.
"-v", "/data:/data",
# Cache npm downloads across container restarts
"-v", "moltis-npx-cache:/root/.npm",
"--entrypoint", "npx",
"node:22-alpine",
"-y", "@modelcontextprotocol/server-filesystem", "/data",
]
# Use an official MCP Docker image
[mcp.servers.memory]
command = "docker"
args = ["run", "--rm", "-i", "mcp/memory"]
The named volume moltis-npx-cache persists the npm cache across container restarts, avoiding re-downloads on every MCP server restart. For air-gapped environments, consider pre-building a custom image with the MCP package installed.
When using containerized MCP servers, remember to mount any directories the server needs access to with -v. Because Moltis talks to the Docker daemon via the mounted socket, bind-mount paths (-v) always reference the host filesystem — not the Moltis container’s filesystem.
Security Considerations
- 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_attemptsfor 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.
If you are trying to understand the difference between short-term session state, long-term memory files, and sandbox persistence, start with Memory Surfaces.
Backends
Moltis supports two memory backends:
| Feature | Built-in | QMD |
|---|---|---|
| Search Type | Hybrid (vector + FTS5 keyword) | Hybrid (BM25 + vector + LLM reranking) |
| Local Embeddings | GGUF models via llama-cpp-2 | GGUF models |
| Remote Embeddings | OpenAI, Ollama, custom endpoints | Built-in |
| Embedding Cache | SQLite with LRU eviction | Built-in |
| Batch API | OpenAI batch (50% cost saving) | No |
| Circuit Breaker | Fallback chain with auto-recovery | No |
| LLM Reranking | Optional (configurable) | Built-in with query command |
| File Watching | Real-time sync via notify | Built-in |
| External Dependency | None (pure Rust) | Requires QMD binary (Node.js/Bun) |
| Offline Support | Yes (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:
- Install the QMD CLI from github.com/tobi/qmd:
npm install -g @tobilu/qmdorbun install -g @tobilu/qmd - Verify the binary is on your
PATH:qmd --version - 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 fileson: Always include citationsoff: Never include citations
Session Export
Session transcripts can be exported into searchable memory on /new and
/reset. 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:
- Initial search returns candidate results
- LLM evaluates each result’s relevance (0.0-1.0 score)
- Results are reordered by combined score (70% LLM, 30% original)
Configuration
Memory settings can be configured in moltis.toml:
[memory]
# Orchestration style: "hybrid", "prompt-only", "search-only", or "off"
style = "hybrid"
# Agent-authored write target policy: "hybrid", "prompt-only", "search-only", or "off"
agent_write_mode = "hybrid"
# Managed USER.md write policy: "explicit-and-auto", "explicit-only", or "off"
user_profile_write_mode = "explicit-and-auto"
# Backend: "builtin" (default) or "qmd"
backend = "builtin"
# Embedding provider for the built-in backend: "local", "ollama", "openai", "custom", or auto-detect
# Ignored while backend = "qmd", but preserved for switching back later
# Omit this field for the real default, which is auto-detect
provider = "auto"
# 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
# Merge vector and keyword results with "rrf" or "linear"
search_merge_strategy = "rrf"
# Export sessions to memory for cross-run recall: "on-new-or-reset" or "off"
session_export = "on-new-or-reset"
# QMD-specific settings (only used when backend = "qmd")
[memory.qmd]
command = "qmd"
max_results = 10
timeout_ms = 30000
Real defaults, if you leave the fields unset:
style = "hybrid"agent_write_mode = "hybrid"user_profile_write_mode = "explicit-and-auto"backend = "builtin"provider = auto-detect(unset, not hardcodedlocal)disable_rag = falsecitations = "auto"llm_reranking = falsesearch_merge_strategy = "rrf"session_export = "on-new-or-reset"[chat].prompt_memory_mode = "live-reload"
style is separate from [chat].prompt_memory_mode. Style controls whether
MEMORY.md is injected and whether memory tools are exposed. Prompt memory
mode controls whether prompt-visible MEMORY.md is live-reloaded or frozen
per session.
The web settings page exposes both knobs in the Memory section so you can
experiment without hand-editing moltis.toml.
agent_write_mode is a separate axis again. It controls where agent-authored
memory writes may land:
hybridallows bothMEMORY.mdandmemory/*.mdprompt-onlyallows onlyMEMORY.mdsearch-onlyallows onlymemory/*.mdoffdisables agent-authored memory mutations, includingmemory_save,memory_forget,memory_delete, and the silent pre-compaction memory flush
user_profile_write_mode is about the managed USER.md surface, not agent
memory files:
explicit-and-automirrors explicit settings saves toUSER.mdand also allows silent browser/channel timezone or location captureexplicit-onlymirrors explicit settings saves toUSER.md, but disables silent browser/channel captureoffstops Moltis from writingUSER.md; the canonical user profile remains inmoltis.toml [user]
citations and search_merge_strategy are typed config enums too:
citations = "auto" | "on" | "off"search_merge_strategy = "rrf" | "linear"
Interaction rules that matter in practice:
provider,base_url,model, andapi_keyonly apply tobackend = "builtin". QMD ignores them.[chat].prompt_memory_modeonly matters whenstylestill allows prompt memory,hybridorprompt-only.llm_rerankingis only meaningful when RAG is enabled. Ifdisable_rag = true, memory falls back to keyword search.session_exportexports transcripts into searchable memory files. It does not inject those transcripts into the prompt directly.
Or via the web UI: Settings > Memory
Recipes
Common combinations:
| Goal | Settings |
|---|---|
| Default everyday setup | style = "hybrid", backend = "builtin", prompt_memory_mode = "live-reload" |
| Deterministic prompt memory | style = "hybrid", prompt_memory_mode = "frozen-at-session-start" |
| Search-only long-term memory | style = "search-only" |
| Prompt-only memory, no recall tools | style = "prompt-only" |
| Disable agent memory writes | agent_write_mode = "off" |
Keep USER.md from silent enrichment | user_profile_write_mode = "explicit-only" |
| Keep user profile only in config | user_profile_write_mode = "off" |
| QMD backend experiment | backend = "qmd" |
Embedding Providers
The built-in backend supports multiple embedding providers:
| Provider | Model | Dimensions | Notes |
|---|---|---|---|
| Local (GGUF) | EmbeddingGemma-300M | 768 | Offline, ~300MB download |
| Ollama | nomic-embed-text | 768 | Requires Ollama running |
| OpenAI | text-embedding-3-small | 1536 | Requires API key |
| Custom | Configurable | Varies | OpenAI-compatible endpoint |
The system auto-detects available providers and creates a fallback chain:
- Try configured provider first
- Fall back to other available providers if it fails
- 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
Prompt injection from MEMORY.md is controlled separately via
[chat].prompt_memory_mode. Use live-reload to reread MEMORY.md before
each turn, or frozen-at-session-start to keep a stable prompt-memory
snapshot for the lifetime of a session.
If sandboxing is enabled with the default workspace_mount = "ro", sandboxed
commands may still read mounted memory files, but they cannot modify them
directly. Durable memory mutations should use memory_save,
memory_forget, or memory_delete rather than shell redirection or direct
file editing inside the sandbox.
Tools
The memory system exposes five agent tools:
memory_search
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. This is the preferred long-term write path even when memory files are visible through a read-only sandbox mount.
{
"content": "User prefers dark mode and Vim keybindings.",
"file": "MEMORY.md",
"append": true
}
Successful writes also return a checkpointId, so the change can be rolled
back with checkpoint_restore.
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
content | string | (required) | The content to save |
file | string | MEMORY.md | Target file: MEMORY.md, memory.md, or memory/<name>.md |
append | boolean | true | Append to existing file (true) or overwrite (false) |
If memory.agent_write_mode = "search-only" and file is omitted,
memory_save defaults to memory/notes.md. The write mode can also reject
targets that are otherwise valid paths.
Path validation: The tool enforces a strict allowlist of write targets to prevent path traversal attacks. Only these patterns are accepted:
MEMORY.mdormemory.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.
memory_forget
Forget saved memory using natural language. This tool searches memory, asks the
configured LLM to choose which chunk or chunks match the forget request, then
deletes the exact stored text through the same deterministic file mutation path
used by memory_delete.
{
"request": "Forget that I prefer dark mode",
"dry_run": true
}
If the request is ambiguous, stale, or the exact text is not uniquely
removable, memory_forget returns a preview with needs_confirmation = true
instead of mutating files.
Successful mutations return checkpointIds, because forgetting multiple chunks
may touch more than one file.
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
request | string | (required) | Natural-language description of what saved memory to forget |
dry_run | boolean | false | Preview planned deletions without mutating files |
limit | integer | 6 | Maximum number of candidate chunks inspected before planning |
Use memory_forget for normal “forget X” requests. Use memory_delete only
when you already know the exact file and exact snippet to remove.
memory_delete
Forget saved memory by removing an exact snippet from a memory file or deleting the whole file. This mutates the backing Markdown file, not just the index.
{
"file": "MEMORY.md",
"text": "User prefers dark mode and Vim keybindings.\n"
}
To delete an entire memory note instead:
{
"file": "memory/obsolete-note.md",
"delete_file": true
}
Successful deletes also return a checkpointId, so the previous file state can
be restored with checkpoint_restore.
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
file | string | (required) | Target file: MEMORY.md, memory.md, or memory/<name>.md |
text | string | (none) | Exact text snippet to remove. Required unless delete_file = true |
delete_file | boolean | false | Delete the whole file instead of removing exact text |
all_matches | boolean | false | Remove every exact match of text instead of only the first |
delete_if_empty | boolean | true | Delete the file if removing text leaves only whitespace |
memory_delete uses the same path validation rules as memory_save, updates
the search index immediately, and can clean up stale index entries when a file
is removed. It is the low-level exact-delete primitive that powers
memory_forget.
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:
- When a session approaches the model’s context window limit, the gateway triggers compaction
- Before summarizing, a hidden LLM turn runs with a special system prompt asking the agent to save noteworthy information
- The agent writes to
MEMORY.mdand/ormemory/YYYY-MM-DD.mdusing an internalwrite_filetool backed by the sameMemoryWriterasmemory_save - The LLM’s response text is discarded (the user sees nothing)
This pre-compaction flush obeys memory.agent_write_mode. In off mode, the
flush is skipped entirely.
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 │ │
│ │ memory_delete │ │ (pre-compact) │ │ Validation │ │
│ │ (agent tools) │ │ │ │ │ │
│ └─────────────────┘ └──────────────────┘ └────────────────┘ │
├──────────────────────────────────────────────────────────────────┤
│ 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
- Check status in Settings > Memory
- Ensure at least one embedding provider is available:
- Local: Requires
local-embeddingsfeature enabled at build - Ollama: Must be running at
localhost:11434 - OpenAI: Requires
OPENAI_API_KEYenvironment variable
- Local: Requires
Search returns no results
- Check that memory files exist in the expected directories
- Trigger a manual sync by restarting moltis
- Check logs for sync errors
QMD not available
- Install QMD if needed:
npm install -g @tobilu/qmdorbun install -g @tobilu/qmd - Verify QMD is installed:
qmd --version - Check that the path is correct in settings
- Ensure QMD can see its index and collections:
qmd status
Memory Surfaces
Moltis has several different places where information can persist. They solve different problems, and confusing them leads to very weird debugging sessions.
Quick Table
| Surface | Purpose | Lifetime | Scope | Backing store |
|---|---|---|---|---|
session_state | Short-term structured state for a session | Until the session is deleted | One session key | SQLite session_state table |
Managed user profile (USER.md) | User name, timezone, and optional location hints | Persistent | Whole workspace | USER.md in data_dir() plus [user] in moltis.toml |
Prompt memory (MEMORY.md) | High-signal facts injected into the system prompt | Persistent | Agent workspace | MEMORY.md in data_dir() or agents/<id>/MEMORY.md |
Searchable memory (memory_search) | Long-term recall without spending prompt tokens | Persistent | Agent workspace | MEMORY.md, memory/*.md, optional exported session files |
| Sandbox workspace mount | Lets sandboxed commands see Moltis files | Only while mounted | Depends on sandbox session/container | Mounted data_dir() path |
Sandbox home (/home/sandbox) | Command-side scratch files and tool caches | Depends on home_persistence | Shared, per-session, or none | Sandbox home volume/dir |
Short-Term Memory
session_state is Moltis’s short-term memory layer. It is:
- keyed by
(session_key, namespace, key) - isolated between sessions
- available to tools and runtime features that need structured, mutable state
- not automatically injected into the prompt
This is the right place for session-scoped control data, counters, cursors, or snapshots that should not become part of long-term memory.
Long-Term Memory
Long-term memory lives in Markdown files inside the Moltis data directory.
- Main agent prompt memory:
~/.moltis/MEMORY.md - Main agent searchable memory:
~/.moltis/memory/*.md - Non-main agent prompt memory:
~/.moltis/agents/<agent_id>/MEMORY.md - Non-main agent searchable memory:
~/.moltis/agents/<agent_id>/memory/*.md
USER.md is not part of agent long-term memory. It is a managed user profile
surface used for things like name, timezone, and cached location. Moltis also
stores the canonical user profile in moltis.toml [user], then overlays
USER.md when that file exists.
For main, Moltis currently prefers agents/main/MEMORY.md if it exists and
falls back to the root MEMORY.md. Non-main agents do not fall back to the
root file.
Prompt Memory Loading
MEMORY.md can reach the prompt in two modes:
[memory]
style = "hybrid"
[chat]
prompt_memory_mode = "live-reload"
hybridinjectsMEMORY.mdinto the prompt and keepsmemory_search,memory_get,memory_save,memory_forget, andmemory_deleteavailableprompt-onlyinjectsMEMORY.mdinto the prompt, but hides memory toolssearch-onlyskips prompt injection and relies on memory tools for recalloffdisables both prompt memory injection and memory toolslive-reloadreadsMEMORY.mdagain before each turnfrozen-at-session-startcaptures the firstMEMORY.mdsnapshot seen by a session and reuses it for later turns in that same session
The style and the mode control different things. Style decides whether prompt
memory exists at all, and whether memory tools are exposed. Mode only matters
when prompt memory is enabled. Frozen snapshots are stored in session_state,
so they are session-scoped rather than a process-global cache.
Defaults today:
memory.style = "hybrid"memory.agent_write_mode = "hybrid"memory.user_profile_write_mode = "explicit-and-auto"memory.backend = "builtin"memory.session_export = "on-new-or-reset"chat.prompt_memory_mode = "live-reload"
memory.agent_write_mode is a third axis:
hybridallows agent-authored writes to bothMEMORY.mdandmemory/*.mdprompt-onlyrestricts agent-authored writes toMEMORY.mdsearch-onlyrestricts agent-authored writes tomemory/*.mdoffdisables agent-authored memory mutations, includingmemory_save,memory_forget,memory_delete, and the silent pre-compaction memory flush
memory.session_export is separate again:
on-new-or-resetexports session transcripts intomemory/sessions/*.mdoffdisables that export hook entirely
memory.user_profile_write_mode is a fourth axis for the managed USER.md
surface:
explicit-and-autoallows settings saves and silent timezone/location captureexplicit-onlyallows settings saves, but disables silent timezone/location captureoffstops Moltis from writingUSER.md; user profile data stays inmoltis.toml [user]
memory.citations and memory.search_merge_strategy are typed retrieval
knobs:
citations:auto,on, oroffsearch_merge_strategy:rrforlinear
Two easy-to-miss interaction rules:
- builtin embedding knobs such as
memory.provider,memory.base_url,memory.model, andmemory.api_keydo nothing whilememory.backend = "qmd" memory.session_exportaffects searchable transcript files undermemory/sessions/*.md, not prompt memory injection
The chat UI exposes the active prompt-memory mode in the toolbar and full context view. Frozen sessions can also refresh their snapshot manually without restarting the session.
Sandboxes
Sandboxes have two separate persistence surfaces:
- Workspace mount, this is how commands can read or write Moltis memory files
when
workspace_mountis notnone - Sandbox home, this is
/home/sandboxand is controlled bytools.exec.sandbox.home_persistence
Those are not the same thing.
If a file exists only in /home/sandbox, memory_search will not index it.
If a file exists in the mounted Moltis workspace, it is part of Moltis’s
normal memory surface, regardless of whether the command that wrote it ran in a
sandbox or not.
With the default workspace_mount = "ro", sandboxed commands may still read
mounted files such as MEMORY.md, but they cannot modify them directly.
Durable long-term memory mutations should still happen through Moltis memory
tools such as memory_save, memory_forget, and memory_delete, not via
shell redirection inside the sandbox.
Between Sandboxes
Sandbox-to-sandbox sharing depends on home_persistence:
off: nothing in sandbox home persistssession: sandbox home persists only for that sessionshared: sandbox home is reused across sessions/containers that share the configured home
This affects /home/sandbox, not MEMORY.md semantics. Long-term memory is
still governed by the mounted Moltis workspace and agent-scoped memory files.
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
| Feature | Moltis | OpenClaw |
|---|---|---|
| Storage format | Markdown files on disk | Markdown files on disk |
| Index storage | SQLite (per data dir) | SQLite (per agent) |
| Default backend | Built-in (SQLite + FTS5 + vector) | Built-in (SQLite + BM25 + vector) |
| Alternative backend | QMD (sidecar, BM25 + vector + reranking) | QMD (sidecar, BM25 + vector + reranking) |
| Keyword search | FTS5 | BM25 |
| Vector search | Cosine similarity | Cosine similarity |
| Hybrid scoring | Configurable vector/keyword weights | Configurable vector/text weights |
| Chunking | Markdown-aware (~400 tokens, configurable) | Markdown-aware (~400 tokens, 80-token overlap) |
| Embedding cache | SQLite with LRU eviction | SQLite, chunk-level |
| File watching | Real-time sync via notify | File watcher with 1.5s debounce |
| Auto-reindex on provider change | No (manual) | Yes (fingerprint-based) |
Embedding Providers
| Provider | Moltis | OpenClaw |
|---|---|---|
| Local GGUF | EmbeddingGemma-300M via llama-cpp-2 | Auto-download GGUF (~0.6 GB) |
| Ollama | nomic-embed-text | Not listed |
| OpenAI | text-embedding-3-small | Via API key |
| Gemini | Not available | Via API key |
| Voyage | Not available | Via API key |
| Custom endpoint | OpenAI-compatible | Not listed |
| Batch embedding | OpenAI batch API (50% cost saving) | OpenAI, Gemini, Voyage batch |
| Fallback chain | Auto-detect + circuit breaker | Auto-select in priority order |
| Offline support | Yes (local embeddings) | Yes (local embeddings) |
Memory Files
| Aspect | Moltis | OpenClaw |
|---|---|---|
| Data directory | ~/.moltis/ (configurable) | ~/.openclaw/workspace/ |
| Long-term memory | MEMORY.md | MEMORY.md |
| Daily logs | memory/YYYY-MM-DD.md | memory/YYYY-MM-DD.md |
| Session transcripts | memory/sessions/*.md | Session JSONL files (separate) |
| Extra paths | Via memory_dirs config | Via memorySearch.extraPaths |
| MEMORY.md loading | Available in system prompt, with configurable live reload or frozen-per-session mode | Only in private sessions (not group chats) |
Agent Tools
This is where the two systems differ most significantly in approach.
| Tool | Moltis | OpenClaw |
|---|---|---|
| memory_search | Dedicated tool, hybrid search | Dedicated tool, hybrid search |
| memory_get | Dedicated tool, by chunk ID | Dedicated tool, by path + optional line range |
| memory_save | Dedicated tool with path validation | No dedicated tool |
| memory_forget | LLM-guided forget flow on top of exact deletes | No dedicated tool |
| memory_delete | Dedicated tool for safe forget/delete flows | No dedicated tool |
| General file writing | exec tool (shell commands) | Generic write_file tool |
| Silent memory turn | Pre-compaction flush via MemoryWriter | Pre-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 purpose-built memory_save,
memory_forget, and memory_delete tools with built-in path validation
(only MEMORY.md and memory/*.md are mutable) 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
| Feature | Moltis | OpenClaw |
|---|---|---|
| Session storage | SQLite database | JSONL files (append-only, tree structure) |
| Auto-compaction | Yes, near context window limit | Yes, near context window limit |
| Manual compaction | /compact (uses configured compaction strategy) | /compact command with optional instructions |
| Pre-compaction memory flush | Silent turn via MemoryWriter trait | Silent turn via write_file tool |
| Flush visibility | Completely hidden from user | Hidden via NO_REPLY convention |
| Session export to memory | Markdown files in memory/sessions/ | Optional (sessionMemory experimental flag) |
| Session pruning | Not yet | Cache-TTL based, trims old tool results |
| Session transcript indexing | Via session export | Experimental, 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 awrite_filetool backed byMemoryWriter - The
MemoryWritertrait is implemented byMemoryManager, 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_REPLYprefix to suppress user-facing output - The agent writes memory files via the same
write_filetool 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
| Aspect | Moltis | OpenClaw |
|---|---|---|
| Path validation | Strict allowlist (MEMORY.md, memory.md, memory/*.md) | No special memory path restrictions |
| Traversal prevention | Rejects .., absolute paths, non-.md extensions | Relies on workspace sandboxing |
| Size limit | 50 KB per write | No documented limit |
| Write scope | Only memory files | Any file in workspace |
| Mechanism | validate_memory_path() in MemoryWriter | Workspace access mode (rw/ro/none) |
Search Features
| Feature | Moltis | OpenClaw |
|---|---|---|
| LLM reranking | Optional (configurable) | Built-in with QMD |
| Citations | Configurable (auto/on/off) | Configurable (auto/on/off) |
| Result format | Chunk ID, path, source, line range, score, text | Path, line range, score, snippet (~700 chars) |
| Fallback | Keyword-only if no embeddings | BM25-only if no embeddings |
Configuration
| Setting | Moltis (moltis.toml) | OpenClaw (openclaw.json) |
|---|---|---|
| Backend | memory.backend = "builtin" | memory.backend = "builtin" |
| Provider | memory.provider = "local" | Auto-detect from available keys |
| Citations | memory.citations = "auto" | memory.citations = "auto" |
| LLM reranking | memory.llm_reranking = false | Via QMD config |
| Session export | memory.session_export = true | memorySearch.experimental.sessionMemory |
| UI configuration | Settings > Memory page | Config file only |
| QMD settings | [memory.qmd] section | memory.backend = "qmd" |
CLI Commands
| Command | Moltis | OpenClaw |
|---|---|---|
| Status | Settings > Memory (web UI) | openclaw memory status [--deep] |
| Index/reindex | Automatic on startup | openclaw memory index [--verbose] |
| Search | Via agent tool only | openclaw memory search "query" |
| Per-agent scoping | Single agent | --agent <id> flag |
Architecture
| Aspect | Moltis | OpenClaw |
|---|---|---|
| Language | Rust | TypeScript/Node.js |
| Memory crate/module | moltis-memory crate | memory-core plugin |
| Write abstraction | MemoryWriter trait (shared by tools and silent turn) | Direct file I/O via write_file tool |
| Plugin system | Memory is a core crate | Memory is a swappable plugin slot |
| Multi-agent | Single agent | Per-agent memory isolation |
What Moltis Has That OpenClaw Does Not
- Dedicated
memory_save,memory_forget, andmemory_deletetools with path validation and immediate re-indexing, reducing reliance on the system prompt for memory mutations - 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)
- 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:
-
Tool approach: Moltis provides purpose-built
memory_save,memory_forget, andmemory_deletetools with security validation; OpenClaw uses a general-purposewrite_filetool guided by the system prompt. -
Write safety: Moltis validates write paths at the tool level (allowlist
- traversal checks); OpenClaw relies on workspace-level access control.
-
Implementation: Moltis is pure Rust with a
MemoryWritertrait abstraction; OpenClaw is TypeScript with direct file I/O through a plugin system. -
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.
| Event | Description | Can Modify | Can Block |
|---|---|---|---|
BeforeAgentStart | Before agent loop starts | yes | yes |
BeforeLLMCall | Before prompt is sent to the LLM provider | yes | yes |
AfterLLMCall | After LLM response, before tool execution | yes | yes |
BeforeToolCall | Before a tool executes | yes | yes |
BeforeCompaction | Before context compaction | yes | yes |
MessageReceived | When an inbound channel/UI message arrives | yes | yes |
MessageSending | Before sending a response | yes | yes |
ToolResultPersist | When a tool result is persisted | yes | yes |
For MessageReceived, Block(reason) aborts the turn — the user message is
not persisted, no run starts, and the reason is delivered back to the sender
via the originating channel (or broadcast as a chat rejection event for web
clients). ModifyPayload must return an object of shape {"content": "..."};
the content string replaces the inbound text before it reaches the model or
the session store.
Read-Only Events (Parallel)
These events run hooks in parallel for performance. They cannot modify or block.
| Event | Description |
|---|---|
AfterToolCall | After a tool completes |
AfterCompaction | After context is compacted |
AgentEnd | When agent loop completes |
MessageSent | After response is delivered |
SessionStart | When a new session begins |
SessionEnd | When a session ends |
GatewayStart | When Moltis starts |
GatewayStop | When Moltis shuts down |
Command | When 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:
| Field | Type | Description |
|---|---|---|
session_key | string | Session identifier |
provider | string | Provider name (e.g. “openai”, “anthropic”) |
model | string | Model ID (e.g. “gpt-5.2-codex”, “qwen2.5-coder-7b-q4_k_m”) |
messages | array | Serialized message array (OpenAI format) |
tool_count | number | Number of tool schemas sent to the LLM |
iteration | number | 1-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:
| Field | Type | Description |
|---|---|---|
session_key | string | Session identifier |
provider | string | Provider name |
model | string | Model ID |
text | string/null | LLM response text |
tool_calls | array | Tool calls requested by the LLM |
input_tokens | number | Tokens consumed by the prompt |
output_tokens | number | Tokens in the response |
iteration | number | 1-based loop iteration |
Channel Provenance
BeforeToolCall, AfterToolCall, SessionStart, and MessageReceived currently include channel provenance. The fields are optional so hooks keep working for sessions that do not originate from a channel integration.
MessageReceived keeps its legacy channel string field and adds the richer object as channel_binding. BeforeToolCall, AfterToolCall, and SessionStart expose the same richer object as channel. ToolResultPersist has a schema field reserved for the same shape, but that event is not currently dispatched.
| Field | Type | Description |
|---|---|---|
surface | string/null | Runtime surface, for example telegram, discord, web, cron, heartbeat |
session_kind | string/null | High-level source kind, usually channel, web, or cron |
channel_type | string/null | Channel plugin type when channel-bound |
account_id | string/null | Channel account identifier |
chat_id | string/null | Channel chat, room, or peer identifier |
chat_type | string/null | Best-effort chat classification, currently most useful for Telegram |
sender_id | string/null | Reserved for future sender provenance, currently omitted |
Example BeforeToolCall payload excerpt:
{
"event": "BeforeToolCall",
"session_key": "telegram:bot-main:-100123",
"tool_name": "exec",
"arguments": {
"command": "pwd"
},
"channel": {
"surface": "telegram",
"session_kind": "channel",
"channel_type": "telegram",
"account_id": "bot-main",
"chat_id": "-100123",
"chat_type": "channel_or_supergroup"
}
}
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",
"session_key": "abc123",
"tool_name": "exec",
"arguments": {
"command": "ls -la"
},
"channel": {
"surface": "telegram",
"session_kind": "channel",
"channel_type": "telegram",
"account_id": "bot-main",
"chat_id": "-100123",
"chat_type": "channel_or_supergroup"
}
}
For modifying events, stdin is the full tagged HookPayload. If your hook returns
{"action":"modify","data":...}, the data value replaces the event-specific
mutable portion of the payload. For BeforeToolCall, that means the replacement
value becomes the new arguments object.
Output
| Exit Code | Stdout | Result |
|---|---|---|
0 | (empty) | Continue normally |
0 | {"action":"modify","data":{...}} | Replace payload data |
1 | — | Block (stderr = reason) |
Example: Modify Tool Arguments
#!/bin/bash
payload=$(cat)
tool=$(echo "$payload" | jq -r '.tool_name')
if [ "$tool" = "exec" ]; then
# Add safety flag to shell commands executed by the exec tool
modified_args=$(echo "$payload" | jq '.arguments.command = "set -e; " + .arguments.command | .arguments')
echo "{\"action\":\"modify\",\"data\":$modified_args}"
fi
exit 0
Example: Block Dangerous Commands
#!/bin/bash
payload=$(cat)
command=$(echo "$payload" | jq -r '.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):
- Project-local:
<workspace>/.moltis/hooks/<name>/HOOK.md - 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: 3 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:
Workspace Context Files
Moltis supports several workspace markdown files in data_dir.
BOOT.md
BOOT.md is loaded per session and injected into the system prompt as startup context.
Best use is for short, explicit startup tasks (health checks, reminders, “send one startup message”, etc.). If the file is missing or empty, nothing is injected.
Agent-specific overrides are supported: place BOOT.md in agents/<id>/BOOT.md.
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
Recommended: Destructive Command Guard (dcg)
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:
Pin to a released tag and verify the script’s SHA-256 before executing it —
never pipe an unpinned curl | bash from main. Check the project’s
releases page
for the latest tag and expected checksum.
DCG_VERSION="v0.4.0"
DCG_SHA256="2cd1287c30cc7bfca3ec6e45a3a474e9bb8f8586dfe83d78db0d6c3a25f3b55c"
curl -fsSL "https://raw.githubusercontent.com/Dicklesworthstone/destructive_command_guard/${DCG_VERSION}/install.sh" -o /tmp/dcg-install.sh
echo "${DCG_SHA256} /tmp/dcg-install.sh" | shasum -a 256 -c - && bash /tmp/dcg-install.sh
rm /tmp/dcg-install.sh
Alternatively, review the script first and only then execute it:
curl -fsSL "https://raw.githubusercontent.com/Dicklesworthstone/destructive_command_guard/v0.4.0/install.sh" -o /tmp/dcg-install.sh
less /tmp/dcg-install.sh # review before running
bash /tmp/dcg-install.sh && rm /tmp/dcg-install.sh
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_key=$(echo "$payload" | jq -r '.session_key')
curl -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d "{\"text\":\"Session $session_key ended\"}"
exit 0
Redact Secrets from Tool Arguments
#!/bin/bash
# redact-secrets.sh
payload=$(cat)
# Redact secrets from exec-tool command arguments before execution
command=$(echo "$payload" | jq -r '.arguments.command // ""')
redacted=$(printf '%s' "$command" | sed -E '
s/sk-[a-zA-Z0-9]{32,}/[REDACTED]/g
s/ghp_[a-zA-Z0-9]{36}/[REDACTED]/g
s/password=[^&[:space:]]+/password=[REDACTED]/g
')
modified_args=$(echo "$payload" | jq --arg command "$redacted" '.arguments.command = $command | .arguments')
echo "{\"action\":\"modify\",\"data\":$modified_args}"
exit 0
Block File Writes Outside Project
#!/bin/bash
# sandbox-writes.sh
payload=$(cat)
tool=$(echo "$payload" | jq -r '.tool_name')
if [ "$tool" = "write_file" ]; then
path=$(echo "$payload" | jq -r '.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
- Keep hooks fast — Set appropriate timeouts (default: 5s)
- Handle errors gracefully — Use
exit 0unless you want to block - Log for debugging — Write to a log file, not stdout
- Test locally first — Pipe sample JSON through your script
- Use jq for JSON — It’s reliable and fast for parsing
- Layer defenses — Use
BeforeLLMCallfor input filtering andAfterLLMCallfor 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:
| Backend | Format | Platform | GPU Acceleration |
|---|---|---|---|
| GGUF (llama.cpp) | .gguf files | macOS, Linux, Windows | Metal (macOS), CUDA (NVIDIA), Vulkan (opt-in) |
| MLX | MLX model repos | macOS (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:
| Tier | RAM | Recommended Models |
|---|---|---|
| Tiny | 4GB | Qwen 2.5 Coder 1.5B, Llama 3.2 1B |
| Small | 8GB | Qwen 2.5 Coder 3B, Llama 3.2 3B |
| Medium | 16GB | Qwen 2.5 Coder 7B, Llama 3.1 8B |
| Large | 32GB+ | Qwen 2.5 Coder 14B, DeepSeek Coder V2 Lite |
Moltis automatically detects your system memory and suggests appropriate models in the UI.
Configuration
Via Web UI (Recommended)
- Navigate to Providers in the sidebar
- Click Add Provider
- Select Local LLM
- Choose a model from the registry or search HuggingFace
- 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:
- In the Add Provider dialog, click “Search HuggingFace”
- Enter a search term (e.g., “qwen coder”)
- Select GGUF or MLX backend
- Choose a model from the results
- 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
-4bitor-8bitfor 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 aglslcpackage; on 22.04 install it from the LunarG Vulkan SDK if the build requires theglslcbinary) - Windows: install the LunarG Vulkan SDK and set the
VULKAN_SDKenvironment 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:
-
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.
-
Slower inference — Depending on your hardware, local inference may be significantly slower than cloud APIs.
-
Quality varies — Smaller quantized models may produce lower quality responses than larger cloud models.
-
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:
| Priority | Backend | Platform | Isolation |
|---|---|---|---|
| 1 | Apple Container | macOS | VM (Virtualization.framework) |
| 2 | Podman | any | Linux namespaces / cgroups (daemonless) |
| 3 | Docker | any | Linux namespaces / cgroups |
| 4 | Restricted Host | any | env clearing, rlimits (no filesystem isolation) |
| 5 | none (host) | any | no 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 (recommended on macOS)
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:
| Flag | Effect |
|---|---|
--cap-drop ALL | Drops all Linux capabilities |
--security-opt no-new-privileges | Prevents privilege escalation via setuid/setgid binaries |
--tmpfs /tmp:rw,nosuid,size=256m | Writable tmpfs for temp files (noexec on real root) |
--tmpfs /run:rw,nosuid,size=64m | Writable tmpfs for runtime files |
--read-only | Read-only root filesystem (prebuilt images only) |
--hostname sandbox | Prevents host hostname leakage |
--tmpfs /sys/firmware:ro,nosuid | Masks BIOS/UEFI firmware data (Docker only) |
--tmpfs /sys/class/dmi:ro,nosuid | Masks system serial numbers and identifiers (Docker only) |
--tmpfs /sys/devices/virtual/dmi:ro,nosuid | Masks DMI attributes (Docker only) |
--tmpfs /sys/class/block:ro,nosuid | Masks block device info (Docker 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.
The /sys tmpfs overlays prevent host hardware metadata (serial numbers, disk
models, LUKS UUIDs) from being visible inside the container. Note that
tools.fs.deny_paths only restricts Moltis file-access tools — these kernel
filesystem masks prevent leakage via shell commands as well.
Podman note: The sysfs tmpfs overlays are applied on Docker only. Podman’s OCI runtime performs “tmpcopyup” when mounting tmpfs over sysfs paths, which fails under
--cap-drop ALLbecause some sysfs files are permission-denied even for root. Podman masks/sys/firmwarevia its built-in OCIMaskedPaths;/sys/class/dmi,/sys/devices/virtual/dmi, and/sys/class/blockremain readable inside the container on Podman.
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: usesdata_dir()/sandbox/home/shared/wasm/session: usesdata_dir()/sandbox/wasm/<session-id>/off: per-session, cleaned up oncleanup()
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
.wasmmodules 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
.wasmmodules 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:
- Clears the environment — all inherited environment variables are removed
- Sets a restricted PATH — only
/usr/local/bin:/usr/bin:/bin - Sets HOME to
/tmp— prevents access to the user’s home directory - Applies resource limits via shell
ulimit:ulimit -u(max processes) frompids_maxconfig (default: 256)ulimit -n 1024(max open files)ulimit -t(CPU seconds) fromcpu_quotaconfig (default: 300s)ulimit -v(virtual memory) frommemory_limitconfig (default: 512M)
- 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
ulimitenforcement is best-effort- No image building —
moltis sandbox buildreturns 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:
| Primary | Triggers |
|---|---|
| Apple Container | config.json missing, VM never booted, NSPOSIXErrorDomain Code=22, service errors |
| Docker | cannot 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 ephemeralsession: mount a per-session host folder to/home/sandboxshared: mount one shared host folder to/home/sandboxfor all sessions (defaults todata_dir()/sandbox/home/shared, orshared_home_dirif 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=/tmpand 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:
| Limit | Docker | Apple Container | WASM | Restricted Host | cgroup (Linux) |
|---|---|---|---|---|---|
memory_limit | --memory | --memory | Wasmtime reservation | ulimit -v | MemoryMax= |
cpu_quota | --cpus | --cpus | epoch timeout | ulimit -t (seconds) | CPUQuota= |
pids_max | --pids-limit | --pids-limit | n/a | ulimit -u | TasksMax= |
Comparison
| Feature | Apple Container | Docker | WASM | Restricted Host | none |
|---|---|---|---|---|---|
| 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 |
| Platform | macOS 26+ | any | any | any | any |
| Overhead | low | medium | minimal | minimal | none |
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
| Policy | Behavior | Use case |
|---|---|---|
| (empty / default) | Uses legacy no_network flag | Backward compatible |
blocked | No network at all | Maximum isolation |
trusted | Proxy-filtered by domain allowlist | Development tasks |
open | Unrestricted network | Fully trusted workloads |
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:
| Pattern | Example | Matches |
|---|---|---|
| Exact | github.com | Only github.com |
| Wildcard subdomain | *.npmjs.org | registry.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:
- Extracts the target domain from the
CONNECTrequest (orHostheader for plain HTTP). - Checks the domain against the allowlist in
DomainApprovalManager. - If allowed — opens a TCP tunnel to the target and relays data bidirectionally.
- If denied — returns
403 Forbiddenand 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.entrystreams entries in real time
Audit entry fields
| Field | Description |
|---|---|
timestamp | ISO 8601 timestamp (RFC 3339) |
domain | Target domain name |
port | Target port number |
protocol | https, http, or connect |
action | allowed or denied |
source | Why 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, orconnect - Action — show only
allowedordeniedentries
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 }
]
}
Recommended domain lists
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",
]
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 tonetwork = "blocked". Whennetworkis set, it takes precedence overno_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: falseis 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.
| Category | Notes |
|---|---|
| Cloud TTS providers | Hosted neural voices with low-latency streaming |
| Local TTS providers | Offline/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-..."
# base_url = "http://10.1.2.30:8003" # Override for OpenAI-compatible servers (e.g. Chatterbox)
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.
-
Install Piper:
# Via pip pip install piper-tts # Or download pre-built binaries from: # https://github.com/OHF-Voice/piper1-gpl/releases -
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/v1.0.0/en/en_US/lessac/medium/en_US-lessac-medium.onnx curl -L -o ~/.moltis/models/en_US-lessac-medium.onnx.json \ https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_US/lessac/medium/en_US-lessac-medium.onnx.json -
Configure in
moltis.toml:[voice.tts] provider = "piper" [voice.tts.piper] model_path = "~/.moltis/models/en_US-lessac-medium.onnx" config_path = "~/.moltis/models/en_US-lessac-medium.onnx.json"
Coqui TTS
Coqui TTS is a high-quality neural TTS with voice cloning capabilities. Use the
maintained Coqui TTS fork, published as
the coqui-tts PyPI package.
-
Install and start the server:
# Via uv uv pip install torch torchaudio torchcodec --torch-backend=auto uv pip install 'coqui-tts[server]' tts-server --model_name tts_models/en/ljspeech/tacotron2-DDC # Or via Docker docker run --rm -p 5002:5002 --entrypoint /bin/bash ghcr.io/idiap/coqui-tts-cpu \ -lc 'python3 TTS/server/server.py --model_name tts_models/en/ljspeech/tacotron2-DDC' -
Configure in
moltis.toml:[voice.tts] provider = "coqui" [voice.tts.coqui] endpoint = "http://localhost:5002"
Browse available models in the maintained fork’s standard model list.
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 compatibleopus/ogg- Good for Telegram voice notesaac- Apple devicespcm- Raw audio
tts.setProvider
Change the active TTS provider.
Request:
{ "provider": "openai" }
Voice Personas
Voice personas are named, reusable voice identities that get injected deterministically into every TTS call. Instead of the agent improvising voice “flair” per-message, a persona defines a stable spoken character.
Key concepts:
| Concept | Description |
|---|---|
| Persona prompt | Provider-neutral fields: profile, style, accent, pacing, scene, constraints |
| Provider bindings | Per-provider overrides: voice_id, model, speed, stability |
| Fallback policy | What happens when the active provider has no binding: preserve-persona, provider-defaults, fail |
| Active persona | One persona active at a time, applied to all TTS calls automatically |
Manage personas via the web UI (Settings > Voice > Voice Personas) or the RPC API.
RPC Methods
| Method | Description |
|---|---|
voice.personas.list | List all personas with active indicator |
voice.personas.get | Get a single persona by ID |
voice.personas.create | Create a new persona |
voice.personas.update | Update persona fields/bindings |
voice.personas.delete | Delete a persona |
voice.personas.set_active | Set the active persona (or "none" to deactivate) |
voice.personas.create
Request:
{
"id": "alfred",
"label": "Alfred",
"description": "A wise British butler",
"provider": "openai",
"prompt": {
"profile": "A wise British butler with dry wit",
"style": "Measured, deliberate, slightly amused",
"accent": "Received Pronunciation",
"pacing": "Unhurried, with dramatic pauses"
},
"providerBindings": [
{
"provider": "openai",
"voice_id": "cedar",
"model": "gpt-4o-mini-tts"
},
{
"provider": "elevenlabs",
"voice_id": "21m00Tcm4TlvDq8ikWAM",
"stability": 0.65,
"similarity_boost": 0.8
}
]
}
Provider Support
| Provider | Instructions support | Notes |
|---|---|---|
OpenAI (gpt-4o-mini-tts) | Full | Persona prompt rendered as instructions field |
Google Gemini TTS (gemini-*) | Full | Persona prompt as system_instruction; set model = "gemini-2.5-flash-preview-tts" |
| ElevenLabs | Partial | Uses provider binding overrides (voice_id, stability) |
| Google Cloud TTS v1 | Partial | Uses provider binding overrides (voice, speaking_rate, pitch) |
| Piper / Coqui | None | Local providers ignore instructions |
Agent Tool Integration
The speak() agent tool accepts an optional persona parameter:
{
"text": "Good evening, sir.",
"persona": "alfred"
}
When omitted, the active persona is used automatically.
Agent ↔ Persona Link
Each agent persona can optionally reference a voice persona via the
voice_persona_id field. Set it when creating or updating an agent:
{
"id": "butler",
"name": "Butler Agent",
"voice_persona_id": "alfred"
}
This links the agent’s identity to its voice — the UI can use this to auto-switch the active voice persona when switching agents.
Auto-Speak Modes
| Mode | Description |
|---|---|
always | Speak all AI responses |
off | Never auto-speak (default) |
inbound | Only when user sent voice input |
tagged | Only with explicit [[tts]] markup |
Speech-to-Text (STT)
Supported Providers
Moltis supports multiple STT providers across cloud and local backends.
| Category | Notes |
|---|---|
| Cloud STT providers | Managed transcription APIs with language/model options |
| Local STT providers | Offline 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-..."
# base_url = "http://10.1.2.30:8001" # Override for OpenAI-compatible servers (e.g. faster-whisper-server)
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:
-
Install vLLM with audio support:
pip install "vllm[audio]" -
Start the vLLM server:
vllm serve mistralai/Voxtral-Mini-3B-2507 \ --tokenizer_mode mistral \ --config_format mistral \ --load_format mistralThe server exposes an OpenAI-compatible endpoint at
http://localhost:8000. -
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
-
Install the binary:
# macOS brew install whisper-cpp # From source: https://github.com/ggerganov/whisper.cpp -
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 -
Configure in
moltis.toml:[voice.stt] provider = "whisper-cli" [voice.stt.whisper_cli] model_path = "~/.moltis/models/ggml-base.en.bin"
sherpa-onnx
-
Install following the official docs
-
Download a model from the model list
-
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 dataformat: Audio format (mp3,opus,ogg,aac,pcm)language: ISO 639-1 code to improve accuracyprompt: 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, implementsTtsServicetraitLiveSttService: Wraps STT providers, implementsSttServicetraitNoopSttService: No-op for when STT is not configured
Voice Personas (crates/gateway/src/voice_persona.rs)
VoicePersonaStore: SQLite-backed CRUD for named voice identitiesapply_persona_to_request(): Merges persona bindings and instructions intoSynthesizeRequest- Types:
VoicePersona,VoicePersonaPrompt,VoicePersonaProviderBinding,FallbackPolicy(inmoltis-voice)
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
- Create
crates/voice/src/tts/newprovider.rs - Implement
TtsProvidertrait - Re-export from
crates/voice/src/tts/mod.rs - Add to
LiveTtsServicein gateway
STT Provider
- Create
crates/voice/src/stt/newprovider.rs - Implement
SttProvidertrait - Re-export from
crates/voice/src/stt/mod.rs - Add to
LiveSttServicein 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
- Automatic Persona Switching: Auto-activate the linked voice persona when the active agent changes
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
| Channel | Inbound Mode | Public URL Required | Key Capabilities |
|---|---|---|---|
| Telegram | Polling | No | Streaming, voice ingest, reactions, OTP, location |
| Discord | Gateway (WebSocket) | No | Streaming, interactive messages, threads, voice ingest, reactions |
| Matrix | Gateway (sync loop) | No | Streaming, voice ingest, interactive polls, threads, reactions, OTP, location, encrypted chats, device verification, ownership bootstrap |
| Microsoft Teams | Webhook | Yes | Streaming, interactive messages, threads, reactions |
| Gateway (WebSocket) | No | Streaming, voice ingest, OTP, pairing, location | |
| Slack | Socket Mode | No | Streaming, interactive messages, threads, reactions |
| Nostr | Gateway (relay subscription) | No | OTP, encrypted DMs (NIP-04) |
| Signal | Gateway (signal-cli SSE) | No | OTP, DMs, groups, outbound text |
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, or uses a persistent sync loop over outbound HTTP. No public URL needed. Used by Discord, Matrix, 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
| Capability | Description |
|---|---|
supports_outbound | Can send messages to users |
supports_streaming | Can stream partial responses (typing/editing) |
supports_interactive | Can send interactive components (buttons, menus) |
supports_threads | Can reply in threads |
supports_voice_ingest | Can receive and transcribe voice messages |
supports_pairing | Requires device pairing (QR code) |
supports_otp | Supports OTP-based sender approval |
supports_reactions | Can add/remove emoji reactions |
supports_location | Can receive and process location data |
Setup
Channels can be configured in two places:
- In
moltis.tomlunder[channels], for file-managed setups - In the web UI under Settings -> Channels, which stores channel accounts in the internal
channelstable insidedata_dir()/moltis.db
The web UI does not write channel settings back into moltis.toml. It includes an advanced JSON config editor so channel-specific settings remain reachable even when a dedicated form field has not been added yet.
The channel picker itself is controlled by [channels].offered in
moltis.toml. If you edit that list by hand, reload the page so the web UI
re-reads the current picker options.
Channel configs stored through the web UI currently live as JSON records in the
internal channels table in data_dir()/moltis.db. They are not currently
wrapped by the Moltis vault, so treat local access to that database as access
to the configured channel credentials.
Some channel integrations also have platform-specific limits. For Matrix, encrypted chats require password auth. Access-token auth is only suitable for plain Matrix traffic because Moltis cannot import an existing device’s private E2EE keys from an access token alone. See Matrix for the full setup, ownership, verification, and troubleshooting flow.
moltis.toml and the web UI are both loaded at startup. If the same (channel_type, account_id) exists in both, the moltis.toml entry wins.
Manual file configuration looks like this:
[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.matrix.my_matrix_bot]
homeserver = "https://matrix.example.com"
access_token = "syt_..."
user_id = "@bot:example.com"
[channels.whatsapp.my_wa]
dm_policy = "open"
[channels.signal.my_signal]
account = "+15551234567"
http_url = "http://127.0.0.1:8080"
For detailed configuration, see the per-channel pages: Telegram, Microsoft Teams, Discord, Slack, Matrix, WhatsApp, Nostr, Signal.
You can also use the web UI’s Channels tab for guided setup with each platform. Web-added channels do not get written back into moltis.toml.
For Matrix specifically, the web UI now supports the full normal setup flow:
- password auth is the default because it unlocks encrypted chats
- dedicated bot accounts default to
moltis_ownedso Moltis can bootstrap cross-signing and recovery - older Matrix accounts that need one external approval expose that approval flow in the channel card instead of failing silently
Proactive Outbound Messaging
Agents are not limited to replying in the current chat. Moltis supports three main outbound patterns:
send_messagetool for direct proactive messages to any configured channel account/chatupdate_channel_settingstool for safe in-chat edits to channel access rules, allowlists, and model routing- 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, either from moltis.toml or from a channel account stored through the web UI, and to is the destination chat, peer, or room identifier for that platform.
Example update_channel_settings tool call:
{
"account_id": "my-telegram-bot",
"settings": {
"dm_policy": "allowlist",
"allowlist_add": ["alice"],
"model": "openai/gpt-5"
}
}
update_channel_settings intentionally supports a narrow patch surface. It is
for non-secret channel settings only, not raw moltis.toml editing, token
rotation, or arbitrary config mutation.
Access Control
All channels share the same access control model with three settings:
DM Policy
Controls who can send direct messages to the bot.
| Value | Behavior |
|---|---|
"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 |
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.
| Value | Behavior |
|---|---|
"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),
room_allowlist (Matrix).
Mention Mode
Controls when the bot responds in groups (does not apply to DMs).
| Value | Behavior |
|---|---|
"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"not123456789 - 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, Matrix, 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.
| Field | Default | Description |
|---|---|---|
otp_self_approval | true | Enable OTP challenges for non-allowlisted DM users |
otp_cooldown_secs | 300 | Lockout 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:
- Open Telegram and message @BotFather
- Send
/newbotand follow the prompts to choose a name and username - Copy the bot token (e.g.
123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11)
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
| Field | Required | Default | Description |
|---|---|---|---|
token | yes | — | Bot token from @BotFather |
dm_policy | no | "allowlist" | Who can DM the bot: "open", "allowlist", or "disabled" |
group_policy | no | "open" | Who can talk to the bot in groups: "open", "allowlist", or "disabled" |
mention_mode | no | "mention" | When the bot responds in groups: "always", "mention" (only when @mentioned), or "none" |
allowlist | no | [] | User IDs or usernames allowed to DM the bot (when dm_policy = "allowlist") |
group_allowlist | no | [] | Group/chat IDs allowed to interact with the bot |
model | no | — | Override the default model for this channel |
model_provider | no | — | Provider for the overridden model |
agent_id | no | — | Default agent ID for this bot’s sessions |
reply_to_message | no | false | Send bot responses as Telegram replies to the user’s message |
otp_self_approval | no | true | Enable OTP self-approval for non-allowlisted DM users |
otp_cooldown_secs | no | 300 | Cooldown in seconds after 3 failed OTP attempts |
stream_mode | no | "edit_in_place" | Streaming mode: "edit_in_place" or "off" |
edit_throttle_ms | no | 300 | Minimum milliseconds between streaming edit updates |
stream_notify_on_complete | no | false | Send a completion notification after streaming finishes |
stream_min_initial_chars | no | 30 | Minimum characters before sending the first streamed message |
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"
agent_id = "research"
otp_self_approval = true
stream_mode = "edit_in_place"
edit_throttle_ms = 300
Per-User and Per-Channel Model and Agent Overrides
You can override the model or agent 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"
agent_id = "triage"
[channels.telegram.my-bot.user_overrides."123456789"]
model = "claude-opus-4-20250514"
model_provider = "anthropic"
agent_id = "research"
User overrides take priority over channel overrides, which take priority over the account default, for both model selection and agent selection.
Access Control
Telegram uses the same gating system as Discord and other channels.
DM Policy
Controls who can send direct messages to the bot.
| Value | Behavior |
|---|---|
"allowlist" | Only users listed in allowlist can DM (default) |
"open" | Anyone who can find the bot can DM it |
"disabled" | DMs are silently ignored |
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.
| Value | Behavior |
|---|---|
"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).
| Value | Behavior |
|---|---|
"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:
- User sends a DM to the bot
- Bot responds with a challenge prompt (the 6-digit code is not shown to the user)
- The code appears in the Moltis web UI under Channels > Senders
- The bot owner shares the code with the user out-of-band
- User replies with the 6-digit code
- 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).
Session Commands
Telegram supports the standard channel session commands:
| Command | Description |
|---|---|
/new | Start a fresh session for the current chat |
/sessions | List or switch among sessions already bound to the current chat |
/attach | List existing non-cron sessions and rebind one to the current chat |
/agent | List or switch chat agents |
/mode | List or switch temporary session modes |
/model | List or switch models |
/approvals | List pending exec approvals for the current session |
/approve N | Approve the numbered exec request from /approvals |
/deny N | Deny the numbered exec request from /approvals |
/sessions is intentionally scoped to the current chat. If you want to bring a
different existing session into the chat, use /attach instead. Reattaching a
session moves that session’s channel binding to the current chat, it is not a
copy.
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):
- Message @userinfobot on Telegram
- It replies with your user ID, first name, and username
- 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:
- Open Settings > Channels
- Click Connect Telegram
- Enter an account ID (any alias) and your bot token
- Adjust DM policy, mention mode, and allowlist as needed
- 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
/tokenif unsure) - Check
dm_policy— if set to"allowlist", make sure your user ID or username is listed inallowlist - An empty
allowlistwithdm_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 withdm_policy = "allowlist"blocks everyone, butdm_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
Microsoft Teams
Moltis can connect to Microsoft Teams as a bot, letting you chat with your agent from any Teams workspace, group chat, or direct message. The integration uses the Bot Framework with an inbound webhook — your Moltis instance must be reachable from the internet over HTTPS.
How It Works
Microsoft Bot Service
(bot.botframework.com / Teams)
│
HTTP POST (webhook)
▼
┌──────────────────────────────────────────────────────────────┐
│ moltis-msteams crate │
│ ┌──────────────┐ ┌────────────┐ ┌──────────────────────┐ │
│ │ JWT + Secret │ │ Outbound │ │ Plugin │ │
│ │ Verification │ │ (replies, │ │ (lifecycle, cards, │ │
│ │ (auth) │ │ streaming)│ │ attachments) │ │
│ └──────────────┘ └────────────┘ └──────────────────────┘ │
└──────────────────────────┬───────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Moltis Gateway │
│ (chat dispatch, tools, memory) │
└──────────────────────────────────────────────────────────────┘
Teams sends each user message as an HTTP POST to your webhook endpoint. Moltis verifies the request (JWT signature and/or shared secret), processes it, and replies via the Bot Framework REST API.
Streaming uses edit-in-place — an initial message is posted once enough tokens arrive, then updated at a throttled interval until the response is complete.
Prerequisites
Before configuring Moltis you need to register a bot in Azure. There are two approaches; the Teams Developer Portal route is quickest for most users.
Option A: Teams Developer Portal (recommended)
- Open the Teams Developer Portal — Tools — Bot Management
- Click + New Bot
- Give the bot a name and click Add
- On the bot’s page, go to Configure and note the Bot ID (this is your
app_id) - Under Client secrets, click Add a client secret for your bot and copy the generated value (this is your
app_password) - Set the Endpoint address to your Moltis webhook URL (see Webhook Endpoint below)
Option B: Azure Portal
- Go to the Azure Portal — Create a resource
- Search for Azure Bot and click Create
- Fill in a bot handle, subscription, and resource group
- Under Microsoft App ID, select Create new (single-tenant is fine)
- Click Create and wait for deployment
- Go to the new bot resource, then Configuration:
- Copy the Microsoft App ID (
app_id) - Click Manage Password to go to Certificates & secrets
- Click + New client secret, copy the value (
app_password)
- Copy the Microsoft App ID (
- Still in Configuration, set the Messaging endpoint to your Moltis webhook URL
Install the bot in Teams
After creating the bot, you need to install it in your Teams organization:
- In the Teams Developer Portal, go to Apps
- Click + New app, give it a name, and fill in the required fields
- Under App features, click Bot and select your existing bot
- Choose the scopes: Personal (DMs), Team (channels), Group Chat
- Click Publish → Publish to your org (or use Preview in Teams for testing)
- In Teams, go to Apps → Built for your org and install the app
The App Password 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.
Webhook Endpoint
Teams sends messages to a webhook URL on your server. The URL pattern is:
https://<your-domain>/api/channels/msteams/<account-id>/webhook?secret=<webhook-secret>
<your-domain>— your public HTTPS domain (e.g.bot.example.com)<account-id>— the account identifier in yourmoltis.toml(e.g.my-bot)<webhook-secret>— an optional shared secret for additional verification
The Moltis web UI and CLI both generate this URL for you. Paste it into your bot’s Messaging endpoint field in the Azure Portal or Teams Developer Portal.
Teams requires HTTPS. For local development, use a tunnel like
ngrok (ngrok http 8080) or
Cloudflare Tunnel.
Configuration
Add a [channels.msteams.<account-id>] section to your moltis.toml:
[channels.msteams.my-bot]
app_id = "12345678-abcd-efgh-ijkl-000000000000"
app_password = "your-client-secret-here"
Configuration Fields
| Field | Required | Default | Description |
|---|---|---|---|
app_id | yes | — | Azure App ID (Bot ID) from the bot registration |
app_password | yes | — | Azure client secret for the bot |
tenant_id | no | "botframework.com" | Azure AD tenant for JWT validation. Set to your tenant ID for single-tenant bots |
webhook_secret | no | — | Shared secret appended as ?secret=... to the webhook URL |
dm_policy | no | "allowlist" | Who can DM the bot: "open", "allowlist", or "disabled" |
group_policy | no | "open" | Who can talk to the bot in group chats / channels: "open", "allowlist", or "disabled" |
mention_mode | no | "mention" | When the bot responds in groups: "always", "mention", or "none" |
allowlist | no | [] | AAD object IDs or user IDs allowed to DM the bot |
group_allowlist | no | [] | Conversation/team IDs allowed for group messages |
model | no | — | Override the default model for this channel |
model_provider | no | — | Provider for the overridden model |
stream_mode | no | "edit_in_place" | "edit_in_place" (live updates) or "off" (send once complete) |
edit_throttle_ms | no | 1500 | Minimum milliseconds between streaming edits |
text_chunk_limit | no | 4000 | Maximum characters per message before splitting |
reply_style | no | "top_level" | "top_level" or "thread" (reply in thread) |
welcome_card | no | true | Send an Adaptive Card welcome message in DMs |
group_welcome_card | no | false | Send a welcome message when the bot is added to a group |
bot_name | no | "Moltis" | Display name shown on welcome cards |
prompt_starters | no | [] | Prompt starter buttons on the welcome card |
max_retries | no | 3 | Max retry attempts for failed sends |
retry_base_delay_ms | no | 250 | Base delay (ms) for exponential backoff |
retry_max_delay_ms | no | 10000 | Maximum retry delay (ms) |
history_limit | no | 50 | Max messages fetched for thread context (requires Graph API permissions) |
graph_tenant_id | no | — | Tenant ID for Graph API operations (thread history, reactions, search) |
Full Example
[channels]
offered = ["telegram", "msteams", "discord", "slack"]
[channels.msteams.my-bot]
app_id = "12345678-abcd-efgh-ijkl-000000000000"
app_password = "your-client-secret-here"
tenant_id = "your-azure-tenant-id"
webhook_secret = "a-long-random-secret"
dm_policy = "allowlist"
group_policy = "open"
mention_mode = "mention"
allowlist = ["00000000-0000-0000-0000-000000000001"]
stream_mode = "edit_in_place"
edit_throttle_ms = 1500
welcome_card = true
bot_name = "My Assistant"
prompt_starters = ["What can you do?", "Help me write an email"]
reply_style = "top_level"
model = "claude-sonnet-4-20250514"
model_provider = "anthropic"
Per-Team / Per-Channel Overrides
You can override settings for specific Teams teams or channels:
[channels.msteams.my-bot.teams.team-id-here]
reply_style = "thread"
mention_mode = "always"
[channels.msteams.my-bot.teams.team-id-here.channels.general-channel-id]
model = "gpt-4o"
model_provider = "openai"
mention_mode = "mention"
Access Control
Teams uses the same gating system as other channels:
DM Policy
Controls who can send direct messages to the bot.
| Value | Behavior |
|---|---|
"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 group chats and team channels.
| Value | Behavior |
|---|---|
"open" | Bot responds in any group chat (default) |
"allowlist" | Only conversations listed in group_allowlist are allowed |
"disabled" | Group messages are silently ignored |
Mention Mode
Controls when the bot responds in group chats (does not apply to DMs).
| Value | Behavior |
|---|---|
"mention" | Bot only responds when @mentioned (default) |
"always" | Bot responds to every message in allowed groups |
"none" | Bot never responds in groups (DM-only) |
Finding User IDs
To add users to the allowlist, you need their AAD Object ID. You can find this in:
- Azure Portal → Azure Active Directory → Users → select user → Object ID
- Microsoft 365 Admin Center → Users → Active users → select user → Properties
- Teams Admin Center → Users → select user
Alternatively, set dm_policy = "open" initially and check the Moltis web UI
under Channels → Senders to see user IDs as messages arrive.
Streaming
By default, Teams uses edit-in-place streaming:
- After ~20 characters accumulate, an initial message is posted with “…”
- Every 1.5 seconds (configurable via
edit_throttle_ms), the message is edited with the latest accumulated text - When the response is complete, a final edit removes the “…” suffix
Set stream_mode = "off" to disable streaming and send the complete response
as a single message.
[channels.msteams.my-bot]
stream_mode = "edit_in_place"
edit_throttle_ms = 1500
Welcome Cards
When a user first messages the bot in a DM, Moltis sends an Adaptive Card with a greeting and optional prompt starter buttons:
[channels.msteams.my-bot]
welcome_card = true
bot_name = "My Assistant"
prompt_starters = ["What can you do?", "Help me write an email", "Summarize this document"]
Set welcome_card = false to disable. Set group_welcome_card = true to also
send a text welcome when the bot is added to a group chat.
Welcome card tracking is in-memory and resets when the gateway restarts. After a restart, the bot may re-send welcome cards to conversations that already received one. This is a known limitation.
Interactive Messages
The bot supports Adaptive Cards for interactive button menus. When an agent
returns an interactive message (buttons), Moltis renders it as an Adaptive
Card with Action.Submit buttons. Clicking a button sends the callback data
back to the bot as a regular message.
Attachments
Inbound
When users send images or files in Teams, Moltis downloads the attachments using the bot’s access token and passes them to the LLM as multimodal content (if the model supports it).
Outbound
- Images in DMs: sent inline as base64 data URLs
- External URLs: sent as Bot Framework URL attachments
- Large files: currently not supported (requires SharePoint/OneDrive integration)
Thread Context (Graph API)
With Graph API permissions, the bot can read conversation history for multi-turn context in group chats. This requires:
- An app registration with
Chat.Read.AllorChannelMessage.Read.Allpermissions (application-level, not delegated) - Admin consent for these permissions in your Azure AD tenant
- Set
graph_tenant_idin the config
[channels.msteams.my-bot]
graph_tenant_id = "your-azure-tenant-id"
history_limit = 50
Reactions
The bot supports adding and removing reactions (like, heart, laugh, surprised, sad, angry) via the Graph API beta endpoints. This requires the same Graph API permissions as thread context.
Web UI Setup
You can configure Teams through the web interface:
- Open Settings → Channels
- Click Connect Microsoft Teams
- Enter your App ID and App Password from the Azure bot registration
- Optionally enter a Webhook Secret (one is generated if left blank)
- Set the Public Base URL to your Moltis server’s HTTPS URL
- Click Bootstrap Teams to generate the messaging endpoint
- Click Copy Endpoint and paste it into your bot’s Messaging Endpoint in the Azure Portal or Teams Developer Portal
- Adjust DM policy, mention mode, and allowlist as needed
- Click Connect Microsoft Teams
The same form is available during onboarding when "msteams" is in
channels.offered.
CLI Setup
Use the CLI bootstrap command for a quick setup:
moltis channels teams bootstrap \
--account-id my-bot \
--app-id 12345678-abcd-efgh-ijkl-000000000000 \
--app-password your-client-secret \
--base-url https://bot.example.com
This generates the webhook endpoint URL and writes the configuration to
moltis.toml. Add --dry-run to preview without saving, or --open to
launch the Azure documentation in your browser.
Crate Structure
crates/msteams/
├── Cargo.toml
└── src/
├── lib.rs # Public exports
├── activity.rs # Teams activity model & parsing
├── attachments.rs # Inbound download + outbound media
├── auth.rs # OAuth2 token acquisition + caching
├── cards.rs # Adaptive Card builders (welcome, polls)
├── channel_webhook_verifier.rs # Shared-secret webhook verification
├── chunking.rs # Message text chunking
├── config.rs # MsTeamsAccountConfig + per-team overrides
├── errors.rs # Error classification + retry logic
├── graph.rs # Graph API (history, reactions, search, pins)
├── jwt.rs # Bot Framework JWT validation (JWKS)
├── outbound.rs # ChannelOutbound + streaming + reactions
├── plugin.rs # ChannelPlugin + ChannelThreadContext
├── state.rs # AccountState + JWT validator
└── streaming.rs # Edit-in-place streaming session
The crate implements the same trait set as other channel crates:
| Trait | Purpose |
|---|---|
ChannelPlugin | Start/stop accounts, lifecycle management |
ChannelOutbound | Send text, media, cards, typing indicators, reactions |
ChannelStreamOutbound | Handle streaming responses (edit-in-place) |
ChannelStatus | Health probes (connected / waiting for first activity) |
ChannelThreadContext | Fetch conversation history via Graph API |
Troubleshooting
Bot doesn’t respond
- Verify the Messaging endpoint in the Azure Portal matches your Moltis webhook URL
- Check that
app_idandapp_passwordare correct - Ensure your server is reachable from the internet over HTTPS
- Check
dm_policy— if set to"allowlist", make sure your user ID is listed - Check
mention_mode— in group chats, you may need to @mention the bot - Look at logs:
RUST_LOG=moltis_msteams=debug moltis
“Teams token acquisition failed”
- The
app_passwordmay have expired — rotate the client secret in the Azure Portal - Check that
oauth_tenantandoauth_scopeare correct (defaults should work for most setups) - Network issues: Moltis must be able to reach
login.microsoftonline.com
Webhook returns 401 / 403
- If using JWT validation: check that
tenant_idmatches your Azure AD tenant - If using shared secret: check that the
?secret=...in the webhook URL matcheswebhook_secretin the config - The JWKS endpoint (
login.botframework.com) must be reachable for JWT validation
Bot responds in DMs but not 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/team must be listed
Messages are duplicated
- Moltis returns
202 Acceptedimmediately to prevent Teams retry timeouts. If you see duplicates, check for multiple Moltis instances pointing at the same webhook URL, or verify the deduplication middleware is working (duplicate activity IDs are filtered automatically)
Streaming doesn’t work
- Set
stream_mode = "edit_in_place"(the default) - Streaming requires the bot to have permission to update activities
- Some Teams clients (older mobile versions) may not render edits in real time
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:
- Go to the Discord Developer Portal
- Click New Application and give it a name
- Navigate to Bot in the left sidebar
- Click Reset Token and copy the bot token
- Under Privileged Gateway Intents, enable Message Content Intent
- Navigate to OAuth2 → URL Generator
- Scopes:
bot - Bot Permissions:
Send Messages,Attach Files,Read Message History,Add Reactions
- Scopes:
- Copy the generated URL and open it to invite the bot to your server
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
| Field | Required | Default | Description |
|---|---|---|---|
token | yes | — | Discord bot token from the Developer Portal |
dm_policy | no | "allowlist" | Who can DM the bot: "open", "allowlist", or "disabled" |
group_policy | no | "open" | Who can talk to the bot in guild channels: "open", "allowlist", or "disabled" |
mention_mode | no | "mention" | When the bot responds in guilds: "always", "mention" (only when @mentioned), or "none" |
allowlist | no | [] | Discord usernames allowed to DM the bot (when dm_policy = "allowlist") |
guild_allowlist | no | [] | Guild (server) IDs allowed to interact with the bot |
model | no | — | Override the default model for this channel |
model_provider | no | — | Provider for the overridden model |
agent_id | no | — | Default agent ID for this Discord bot |
reply_to_message | no | false | Send bot responses as Discord replies to the user’s message |
ack_reaction | no | — | Emoji reaction added while processing (e.g. "👀"); omit to disable |
activity | no | — | Bot activity status text (e.g. "with AI") |
activity_type | no | "custom" | Activity type: "playing", "listening", "watching", "competing", or "custom" |
status | no | "online" | Bot online status: "online", "idle", "dnd", or "invisible" |
otp_self_approval | no | true | Enable OTP self-approval for non-allowlisted DM users |
otp_cooldown_secs | no | 300 | Cooldown 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"
agent_id = "research"
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.
| Value | Behavior |
|---|---|
"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.
| Value | Behavior |
|---|---|
"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).
| Value | Behavior |
|---|---|
"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:
- User sends a DM to the bot
- Bot responds with a challenge prompt (the 6-digit code is not shown to the user)
- The code appears in the Moltis web UI under Channels → Senders
- The bot owner shares the code with the user out-of-band
- User replies with the 6-digit code
- 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.
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:
| Command | Description |
|---|---|
/new | Start a new chat session |
/clear | Clear the current session history |
/compact | Summarize the current session |
/context | Show session info (model, tokens, plugins) |
/model | List or switch the AI model |
/sessions | List or switch chat sessions |
/agent | List or switch agents |
/mode | List or switch temporary session modes |
/help | Show 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.
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:
- Open Settings → Channels
- Click Connect Discord
- Enter an account ID (any alias) and your bot token
- Adjust DM policy, mention mode, and allowlist as needed
- 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:
- Go to the Discord Developer Portal
- Select your application → OAuth2 → URL Generator
- Scopes: check bot
- Bot Permissions: check Send Messages, Read Message History, and Add Reactions
- Copy the generated URL and open it in your browser
- Select the server you want to add the bot to and confirm
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:
- Open Discord and go to Direct Messages
- Click the New Message icon (or Find or start a conversation)
- Search for the bot’s username and select it
- Send a message
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:
- Bot’s own messages are ignored
- Guild allowlist is checked (if configured)
- DM/group policy is evaluated
- Mention mode is checked (guild messages only)
- Bot mention prefix (
@BotName) is stripped from the message text - The message is logged and dispatched to the chat engine
- 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:
| Trait | Purpose |
|---|---|
ChannelPlugin | Start/stop accounts, lifecycle management |
ChannelOutbound | Send text, media, typing indicators |
ChannelStreamOutbound | Handle streaming responses |
ChannelStatus | Health 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:
- Go to api.slack.com/apps and click Create New App
- Choose From scratch, name the app, and select your workspace
- Navigate to OAuth & Permissions and add these Bot Token Scopes:
app_mentions:read— read @mentionschat:write— send messagesim:history— read DM historyim:read— view DM metadatachannels:history— read channel messages (formention_mode = "always")
- Click Install to Workspace and copy the Bot User OAuth Token (
xoxb-...) - For Socket Mode (recommended):
- Go to Socket Mode and enable it
- Generate an App-Level Token (
xapp-...) with theconnections:writescope
- 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
- Under Event Subscriptions > Subscribe to bot events, add:
app_mention— when someone @mentions the botmessage.im— direct messages to the bot
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
| Field | Required | Default | Description |
|---|---|---|---|
bot_token | yes | — | Bot user OAuth token (xoxb-...) |
app_token | yes* | — | App-level token for Socket Mode (xapp-...). *Required for socket_mode. |
connection_mode | no | "socket_mode" | Connection method: "socket_mode" or "events_api" |
signing_secret | no* | — | Signing secret for Events API request verification. *Required for events_api. |
dm_policy | no | "allowlist" | Who can DM the bot: "open", "allowlist", or "disabled" |
group_policy | no | "open" | Who can talk to the bot in channels: "open", "allowlist", or "disabled" |
mention_mode | no | "mention" | When the bot responds in channels: "always", "mention", or "none" |
allowlist | no | [] | Slack user IDs allowed to DM the bot (when dm_policy = "allowlist") |
channel_allowlist | no | [] | Slack channel IDs allowed to interact with the bot |
model | no | — | Override the default model for this channel |
model_provider | no | — | Provider for the overridden model |
stream_mode | no | "edit_in_place" | Streaming mode: "edit_in_place", "native", or "off" |
edit_throttle_ms | no | 500 | Minimum milliseconds between streaming edit updates |
thread_replies | no | true | Reply in threads |
channel_overrides | no | {} | Per-channel model/provider overrides (see below) |
user_overrides | no | {} | Per-user model/provider overrides (see below) |
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
# Per-channel override: use a different model in a specific Slack channel
[channels.slack.my-bot.channel_overrides.C0123456789]
model = "gpt-4o"
# Per-user override: use a specific model/provider for a Slack user
[channels.slack.my-bot.user_overrides.U0123456789]
model = "claude-sonnet-4-20250514"
model_provider = "anthropic"
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
| Value | Behavior |
|---|---|
"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
| Value | Behavior |
|---|---|
"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
| Value | Behavior |
|---|---|
"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:
| Mode | Behavior |
|---|---|
"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 inallowlist - 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)
Matrix
Moltis can connect to Matrix as a bot account using a homeserver URL plus one of three authentication methods: OIDC, password, or access token. The integration runs as an outbound sync loop, so it does not require a public webhook URL, port forwarding, or TLS termination on your side.
Matrix encrypted chats require password auth.
Access-token auth can connect for plain Matrix traffic, but it reuses an existing Matrix session without that device’s local private encryption keys. That means Moltis cannot reliably decrypt encrypted rooms when you connect with an access token copied out of Element.
Use password auth so Moltis logs in as its own Matrix device and persists its own E2EE keys locally. After that:
- If Element starts verification, Moltis accepts it automatically
- Moltis posts emoji confirmation instructions in the Matrix chat
- Reply
verify yes,verify no,verify show, orverify cancelin Matrix - Older encrypted history may remain unreadable until keys are shared
For dedicated bot accounts, the web UI now defaults to Let Moltis own this Matrix account. In that mode Moltis bootstraps cross-signing and recovery for the bot account so its own device can be verified automatically. If you want to open the same bot account in Element yourself, switch the channel to user-managed mode instead.
Feature Set
Matrix is no longer a minimal transport. The current integration supports the full day-to-day bot flow, including encrypted chats when you connect with password auth.
| Area | Status | Notes |
|---|---|---|
| Web UI setup and editing | Supported | Add, edit, remove, and retry Matrix channels from Settings -> Channels |
| Direct messages and rooms | Supported | DM policy, room policy, allowlists, mention gating, and auto-join |
| End-to-end encrypted chats | Supported with password auth | Moltis creates and persists its own Matrix device and crypto state |
| Device verification | Supported | Moltis accepts Element verification and you confirm with verify yes, verify no, verify show, or verify cancel |
| Cross-signing / recovery ownership | Supported | Password auth defaults to moltis_owned; older accounts may require one browser approval before takeover |
| Streaming replies | Supported | Edit-in-place streaming for text responses |
| Thread-aware replies and context | Supported | Replies stay in threads and context fetch follows the thread root |
| Voice and audio messages | Supported | Matrix audio is downloaded and sent through the normal transcription pipeline |
| Interactive actions | Supported | Short action lists are sent as native Matrix polls |
| Reactions | Supported | Ack reactions and normal reaction flows work |
| Location | Supported | Inbound location shares update user location and outbound location sends are supported |
| OTP sender approval | Supported | Unknown DM senders can self-approve through the shared OTP flow |
| Model routing overrides | Supported | Per-room and per-user model/provider overrides |
The main remaining Matrix-specific limitations are:
- access-token auth is still plain-traffic-only, encrypted chats need password auth
- existing Matrix accounts with old crypto state may require one browser approval before Moltis can take over ownership
- Matrix interactive actions are poll-based, not arbitrary button/select UIs
- older encrypted history may remain unreadable until the missing room keys are shared with the Moltis device
- arbitrary remote-media fetch and reupload for outbound URLs is still limited
How It Works
┌──────────────────────────────────────────────────────┐
│ Matrix homeserver │
│ (/sync, send, relations, room APIs) │
└──────────────────┬───────────────────────────────────┘
│ outbound HTTPS requests
▼
┌──────────────────────────────────────────────────────┐
│ moltis-matrix crate │
│ ┌────────────┐ ┌────────────┐ ┌────────────────┐ │
│ │ Handler │ │ Outbound │ │ Plugin │ │
│ │ (inbound) │ │ (replies) │ │ (lifecycle) │ │
│ └────────────┘ └────────────┘ └────────────────┘ │
└──────────────────┬───────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ Moltis Gateway │
│ (chat dispatch, tools, memory) │
└──────────────────────────────────────────────────────┘
Authentication Modes
Moltis supports three ways to authenticate with a Matrix homeserver:
| Mode | When to use | Encrypted chats? |
|---|---|---|
| OIDC (recommended) | Modern homeservers using Matrix Authentication Service (e.g. matrix.org since April 2025) | Yes |
| Password | Older homeservers without OIDC support | Yes |
| Access token | Quick testing or plain-traffic-only bots | No |
OIDC Authentication
OIDC is the recommended authentication mode for homeservers that support it. Moltis uses the matrix-sdk built-in OAuth 2.0 flow (MSC3861):
- In the web UI, select OIDC (recommended) as the authentication mode
- Enter the homeserver URL and click Authenticate with OIDC
- A browser window opens for you to log in at the homeserver’s identity provider
- After successful authentication, the browser redirects back to Moltis
- Moltis automatically registers a device, exchanges tokens, and starts syncing
OIDC sessions are refreshed automatically. Moltis persists the session tokens to disk so the bot reconnects after restarts without re-authentication.
To configure OIDC via moltis.toml:
[channels.matrix.my-bot]
homeserver = "https://matrix.example.com"
auth_mode = "oidc"
Note: OIDC initial setup requires the web UI (browser-based flow). After the first login, the saved session is reused for restarts.
Prerequisites
Before configuring Moltis, you need a Matrix bot account:
- Create or choose a Matrix account for the bot on your homeserver
- For OIDC: just the homeserver URL (credentials are entered in the browser)
- For password: keep the account password available
- For access token: obtain a token from Element (plain traffic only)
- Optionally pick a stable
device_idfor session restore
Matrix credentials are secrets. Treat access tokens and passwords like
passwords, never commit them to version control. Moltis stores them with
secrecy::Secret and redacts them from logs and API responses.
Getting an Access Token
If you want to use access-token auth, Element can show the token for the currently logged-in account:
- Sign into the dedicated Matrix bot account in Element
- Open
Settings - Open
Help & About - Expand
Advanced - Click
Access Token
Use that token as access_token in the Matrix channel config.
Access-token auth does not support encrypted Matrix chats.
Why: the token authenticates an already-existing Matrix device/session, but it
does not transfer that device’s local private E2EE keys into Moltis. For
encrypted rooms, use user_id + password so Moltis creates and persists its
own Matrix device.
If you want encrypted Matrix chats, use password login instead. In that case,
set user_id and password.
Why Password Auth Is Required For Encryption
For encrypted Matrix chats, Moltis must behave like its own Matrix device with its own persistent crypto state.
Password auth works for that flow because Moltis logs in as a fresh Matrix device, generates its own E2EE identity and one-time keys, stores that device state locally, and can then complete normal Element verification.
Access-token auth is different. It authenticates an already-existing Matrix session, often one created by Element, but Moltis does not receive that existing device’s private encryption keys just by knowing the token. That is why access-token auth works for plain Matrix traffic but is not a reliable way to support encrypted rooms.
Two related Matrix recovery tools often cause confusion:
- The Element recovery key helps a Matrix client unlock server-backed secret storage and backups.
- Exported room keys let another Matrix client import some historical Megolm room keys from a file.
Moltis does not currently implement recovery-key entry or room-key import, and neither feature would make access-token auth equivalent to a proper dedicated Moltis device anyway. The supported path for encrypted Matrix chats is still:
- Add the Matrix account with
user_id+password - Let Moltis create its own Matrix device
- Complete Element verification with that Moltis device
- Send new encrypted messages after verification
Ownership Modes
When you add a password-based Matrix account, Moltis offers two ownership modes:
moltis_owned
This is the default for dedicated bot accounts. Moltis tries to become the owner of the account’s Matrix crypto state so the bot can verify its own device properly.
In this mode Moltis will:
- create or restore its own Matrix device
- bootstrap or recover secret storage and cross-signing when possible
- self-sign the Moltis device after takeover succeeds
- show encryption ownership status directly in Settings -> Channels
For fresh bot accounts, this is usually automatic.
For older accounts with pre-existing Matrix crypto state, Matrix may require one browser approval before Moltis is allowed to reset and take over cross-signing. When that happens, the Channels page shows:
- an Open approval page for @user:server button
- a retry button after you finish the reset in the browser
user_managed
Use this when you want to manage the same bot account yourself in Element or another Matrix client.
In this mode Moltis still connects and can chat, but it does not try to take ownership of cross-signing or recovery. The Channels page shows the homeserver, user ID, device ID, and device name you need if you want to log into that bot account in your own Matrix app.
Configuration
Matrix can be configured either:
- manually in
moltis.toml - through the web UI in Settings -> Channels
Web UI channel accounts are stored in the internal channels table in data_dir()/moltis.db. They are not written back into moltis.toml. If you need a Matrix setting that does not have a dedicated field yet, use the advanced JSON config editor in the channel form.
Web-managed Matrix credentials are not currently wrapped by the Moltis vault.
Today, channel configs are stored as JSON in the internal channels table in
data_dir()/moltis.db. Matrix secrets are still handled as secrets in code,
redacted from logs, and redacted from API responses, but they are not yet
encrypted-at-rest by the vault layer.
Manual file configuration uses a [channels.matrix.<account-id>] section in moltis.toml.
If you want encrypted Matrix chats, use password auth:
[channels.matrix.my-bot]
homeserver = "https://matrix.example.com"
user_id = "@bot:example.com"
password = "correct horse battery staple"
ownership_mode = "moltis_owned"
device_display_name = "Moltis Matrix Bot"
If you only need plain, unencrypted Matrix traffic, access-token auth still works:
[channels.matrix.my-bot]
homeserver = "https://matrix.example.com"
access_token = "syt_..."
user_id = "@bot:example.com"
To show Matrix in the channel picker, include "matrix" in channels.offered:
[channels]
offered = ["telegram", "discord", "slack", "matrix"]
After editing channels.offered, reload the web UI so it fetches the latest
picker list from moltis.toml.
Configuration Fields
| Field | Required | Default | Description |
|---|---|---|---|
homeserver | yes | — | Base URL of the Matrix homeserver |
access_token | no | — | Access token for the bot account, for plain and unencrypted Matrix traffic only |
password | no | — | Password for the bot account, required for encrypted Matrix chats |
user_id | no | — | Bot user ID, for example @bot:example.com, auto-detected via whoami when omitted |
device_id | no | — | Optional device ID used for session restore |
device_display_name | no | — | Optional device display name used for password-based logins |
ownership_mode | no | "user_managed" | Who manages cross-signing and recovery: "moltis_owned" or "user_managed" |
dm_policy | no | "allowlist" | Who can DM the bot: "open", "allowlist", or "disabled" |
room_policy | no | "allowlist" | Which rooms can talk to the bot: "open", "allowlist", or "disabled" |
mention_mode | no | "mention" | When the bot responds in rooms: "always", "mention", or "none" |
room_allowlist | no | [] | Matrix room IDs or aliases allowed to interact with the bot |
user_allowlist | no | [] | Matrix user IDs allowed to DM the bot |
auto_join | no | "always" | Invite handling: "always", "allowlist", or "off" |
model | no | — | Override the default model for this account |
model_provider | no | — | Provider for the overridden model |
stream_mode | no | "edit_in_place" | How streaming replies are sent: "edit_in_place" or "off" |
edit_throttle_ms | no | 500 | Minimum milliseconds between edit-in-place streaming updates |
stream_min_initial_chars | no | 30 | Minimum buffered characters before the first streamed send |
channel_overrides | no | {} | Per-room model/provider overrides |
user_overrides | no | {} | Per-user model/provider overrides |
reply_to_message | no | true | Send threaded/rich replies when possible |
ack_reaction | no | "👀" | Emoji reaction added while processing, omit to disable |
otp_self_approval | no | true | Enable OTP self-approval for non-allowlisted DM users |
otp_cooldown_secs | no | 300 | Cooldown in seconds after 3 failed OTP attempts |
Web UI Notes
When you add Matrix through the web UI:
- the homeserver field defaults to
https://matrix.org - Moltis auto-generates the internal
account_id - the saved account lives in
data_dir()/moltis.db, not inmoltis.toml - the web UI defaults to password auth because encrypted Matrix chats require it
- password-based channels default to Let Moltis own this Matrix account
- Matrix channels can be added, edited, removed, and retried entirely from Settings -> Channels
- access-token auth is for plain Matrix traffic only, because Moltis cannot import the existing device’s private E2EE keys from an access token
- if you switch a channel to user-managed mode, the Channels page shows the homeserver, user ID, device ID, and device name you need to open that bot account in Element
- if an older Matrix account needs one external approval before takeover, the channel card shows an approval link plus a retry action so you do not need to rebuild the channel config manually
- if Element starts device verification, Moltis accepts it and posts emoji confirmation instructions in the room
- send
verify yes,verify no,verify show, orverify cancelas normal messages in that same Matrix chat to finish or inspect the verification flow - older encrypted history may still be unreadable if this Moltis device joined after those keys were created
If you want to inspect web-added channels directly, query the SQLite database:
sqlite3 ~/.moltis/moltis.db 'select channel_type, account_id, config from channels;'
If you use MOLTIS_DATA_DIR or --data-dir, check that directory instead of ~/.moltis.
Full Example
[channels]
offered = ["matrix"]
[channels.matrix.my-bot]
homeserver = "https://matrix.example.com"
user_id = "@bot:example.com"
password = "correct horse battery staple"
ownership_mode = "moltis_owned"
device_id = "MOLTISBOT"
device_display_name = "Moltis Matrix Bot"
dm_policy = "allowlist"
room_policy = "allowlist"
mention_mode = "mention"
room_allowlist = ["!ops:example.com", "#support:example.com"]
user_allowlist = ["@alice:example.com", "@bob:example.com"]
auto_join = "allowlist"
model = "gpt-4.1"
model_provider = "openai"
stream_mode = "edit_in_place"
edit_throttle_ms = 500
stream_min_initial_chars = 30
reply_to_message = true
ack_reaction = "👀"
otp_self_approval = true
otp_cooldown_secs = 300
[channels.matrix.my-bot.channel_overrides."!ops:example.com"]
model = "claude-sonnet-4-20250514"
model_provider = "anthropic"
[channels.matrix.my-bot.user_overrides."@alice:example.com"]
model = "o3"
model_provider = "openai"
Access Control
Matrix uses the same gating model as the other channel integrations.
DM Policy
| Value | Behavior |
|---|---|
"allowlist" | Only users in user_allowlist can DM the bot (default) |
"open" | Anyone can DM the bot |
"disabled" | DMs are silently ignored |
Room Policy
| Value | Behavior |
|---|---|
"allowlist" | Only rooms in room_allowlist are allowed (default) |
"open" | Any joined room can interact with the bot |
"disabled" | Room messages are silently ignored |
Mention Mode
| Value | Behavior |
|---|---|
"mention" | Bot only responds when explicitly mentioned in a room (default) |
"always" | Bot responds to every message in allowed rooms |
"none" | Bot never responds in rooms |
When mention_mode = "mention", Moltis checks Matrix intentional mentions
(m.mentions) and also falls back to a literal MXID mention in the plain body.
Invite Handling
| Value | Behavior |
|---|---|
"always" | Auto-join every invite (default) |
"allowlist" | Auto-join only when the inviter is in user_allowlist or the room is already in room_allowlist |
"off" | Never auto-join invites |
Threads and Replies
Matrix replies now preserve thread context when the referenced event belongs to
an existing thread. When reply_to_message = true, Moltis sends a rich reply
and keeps the reply inside the thread when appropriate.
For thread context injection, Moltis resolves the inbound event to the thread
root and fetches prior m.thread relations so the LLM sees the room thread
history instead of just the last message.
Voice and Location Messages
Matrix audio messages are downloaded through the homeserver media API and transcribed with the same voice pipeline used by the other voice-enabled channels. If voice transcription is not configured, Moltis replies with setup guidance instead of silently dropping the message.
Inbound Matrix location shares now update the stored user location and also resolve pending tool-triggered location requests. If there is no pending location request, the coordinates are forwarded to the chat session so the LLM can acknowledge them naturally.
Interactive Actions
When Moltis needs to ask the user to choose from a short list of actions, the Matrix integration sends a native poll instead of a plain text fallback. The selected poll answer is fed back into the same interaction callback path used by the other channel integrations.
Matrix poll answers are single-choice and capped by the protocol at 20 options. If a generated interactive message exceeds that limit, Moltis falls back to a plain numbered text list.
OTP Self-Approval
When dm_policy = "allowlist" and otp_self_approval = true, unknown DM users
can self-approve:
- User sends a DM to the bot
- Moltis generates a 6-digit OTP challenge
- The code appears in the web UI under Channels > Senders
- The bot owner shares the code out-of-band
- User replies with the code in Matrix
- On success, the sender is approved
After 3 failed attempts, the sender is locked out for otp_cooldown_secs
seconds.
Troubleshooting
Bot does not connect
- Verify
homeserveris correct and reachable - Verify the access token or password is valid
- Set
user_idexplicitly if startup auto-detection is unreliable - Look at logs:
RUST_LOG=moltis_matrix=debug moltis
Element shows the room as encrypted
- That is fine, encrypted rooms are supported
- Make sure the Matrix account was added with password auth, not access-token auth
- If the Moltis device is new, start a fresh Element verification with the bot
- Moltis will accept the request and post emoji instructions in the chat
- Send
verify yesas a normal chat message if the emojis match,verify noif they do not - If older encrypted history still does not decrypt, resend the message after verification
Access-token auth connects but encrypted messages do not decrypt
- That is expected with the current implementation
- Access-token auth is for plain Matrix traffic only
- Remove the Matrix account from Moltis
- Re-add it with
user_id+password - Verify the new Moltis device in Element
- Resend a brand new encrypted message after verification
Element says it is waiting for Moltis to accept verification
- Moltis should accept Matrix verification requests automatically
- Watch the chat for the emoji confirmation prompt
- If the prompt scrolled away, send
verify showas a normal message in that same Matrix chat - If an older stale verification request was replayed from sync history, start a fresh verification in Element and then use the
verify ...commands - If nothing happens, check the Matrix logs for verification events and try starting verification again
Channels page says Ownership approval required
- This means Moltis connected, but Matrix wants one explicit browser approval before cross-signing can be reset for this older account
- Use the Open approval page for @user:server button in the Matrix channel card
- Make sure the browser page is signed into that exact Matrix account, not your personal one
- After approving the reset, use the retry button in the same channel card so Moltis can finish ownership bootstrap
- Until that finishes, the bot may still chat, but the device can remain
unverified
Bot can chat, but the Channels page still says Device not yet verified by owner
- Matrix encryption and Matrix cross-signing are related, but not identical
- A Matrix device can already send and receive messages before it is verified by the account owner
- If you are using
moltis_owned, let Moltis finish ownership bootstrap or complete the approval flow above - If you are using
user_managed, verify the Moltis device from your own Matrix client instead
Bot does not respond in rooms
- Check
room_policy - Check
room_allowlist - Check
mention_mode, especially if it is"mention"or"none" - Make sure the bot has joined the room, or enable
auto_join
Bot does not respond in DMs
- Check
dm_policy - Check
user_allowlist - If OTP is enabled, look in Channels > Senders for a pending challenge
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…) │
└─────────────────┘
- Moltis registers as a linked device on your WhatsApp account
- Messages sent to your WhatsApp number arrive at both your phone and Moltis
- Moltis processes inbound messages through the configured LLM
- The LLM reply is sent back through your WhatsApp account
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.
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:
- Start Moltis:
moltis serve - Open the web UI and navigate to Settings > Channels
- Click + Add Channel > WhatsApp
- Enter an Account ID (any name you like, e.g.
my-whatsapp) - Choose a DM Policy (Open, Allowlist, or Disabled)
- Optionally select a default Model
- Click Start Pairing — a QR code appears
- On your phone: WhatsApp > Settings > Linked Devices > Link a Device
- Scan the QR code
- The modal shows “Connected” with your phone’s display name
That’s it — messages to your WhatsApp account are now processed by Moltis.
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>"]
| Field | Type | Default | Description |
|---|---|---|---|
paired | bool | false | Whether QR code pairing is complete (auto-set) |
display_name | string | — | Phone name after pairing (auto-populated) |
phone_number | string | — | Phone number after pairing (auto-populated) |
store_path | string | — | Custom path to sled store; defaults to ~/.moltis/whatsapp/<account_id>/ |
model | string | — | Default LLM model ID for this account |
model_provider | string | — | Provider name for the model |
agent_id | string | — | Default agent ID for this account |
dm_policy | string | "open" | DM access policy: "open", "allowlist", or "disabled" |
group_policy | string | "open" | Group access policy: "open", "allowlist", or "disabled" |
mention_mode | string | "always" | Group reply mode: "always", "mention", or "none" |
allowlist | array | [] | Users allowed to DM (usernames or phone numbers) |
group_allowlist | array | [] | Group JIDs allowed for bot responses |
otp_self_approval | bool | true | Allow non-allowlisted users to self-approve via OTP |
otp_cooldown_secs | int | 300 | Cooldown 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"
agent_id = "personal"
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"
mention_mode = "mention"
Per-Chat and Per-User Overrides
WhatsApp also supports optional per-chat and per-user overrides for models and agents:
[channels.whatsapp."work-bot"]
paired = true
model = "openai/gpt-4.1"
agent_id = "support"
[channels.whatsapp."work-bot.channel_overrides"."120363456789@g.us"]
agent_id = "triage"
[channels.whatsapp."work-bot.user_overrides"."15551234567@s.whatsapp.net"]
model = "anthropic/claude-sonnet-4-20250514"
agent_id = "research"
Access Control
WhatsApp uses the same access control model as Telegram channels.
DM Policies
| Policy | Behavior |
|---|---|
open | Anyone who messages your WhatsApp can chat with the bot |
allowlist | Only users on the allowlist get responses; others get an OTP challenge |
disabled | All DMs are silently ignored |
Group Policies
| Policy | Behavior |
|---|---|
open | Bot responds in all groups it’s part of, subject to mention_mode |
allowlist | Bot only responds in groups on the group_allowlist, subject to mention_mode |
disabled | Bot ignores all group messages |
Mention Mode
| Mode | Behavior |
|---|---|
always | Bot may respond to allowed group messages without an @mention |
mention | Bot only responds in allowed groups when the account is @mentioned |
none | Bot never responds in groups |
OTP Self-Approval
When dm_policy = "allowlist" and otp_self_approval = true (the default),
users not on the allowlist can request access:
- User sends any message to the bot
- Bot replies: “Please reply with the 6-digit code to verify access”
- The OTP code appears in the Senders tab of the web UI
- User replies with the code
- 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.
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"
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_pathin config to use a different location (useful for Docker volumes or shared storage)
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:
-
Message ID tracking: Every message the bot sends is recorded in a bounded ring buffer (256 entries). Incoming
is_from_memessages whose ID matches a tracked send are recognized as bot echoes and skipped. -
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 Type | Handling |
|---|---|
| Text | Dispatched directly to the LLM |
| Image | Downloaded, optimized for LLM consumption (resized if needed), sent as attachment |
| Voice | Downloaded and transcribed via STT (if configured); falls back to text guidance |
| Audio | Same as voice, but classified separately (non-PTT audio files) |
| Video | Thumbnail extracted and sent as image attachment with caption |
| Document | Caption and filename/MIME metadata dispatched as text |
| Location | Resolves pending location tool requests, or dispatches coordinates to LLM |
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, ordisconnected - Display name: Phone name from WhatsApp (after pairing)
- Sender summary: List of recent senders with message counts
- Edit / Remove buttons
Adding a WhatsApp Channel
- Click + Add Channel > WhatsApp
- Fill in the account ID, DM policy, and optional model
- Click Start Pairing
- Scan the QR code on your phone
- 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 theofferedlist inmoltis.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
infoentry that lists which message fields were present - Supported types: text, images, audio, voice notes, video, documents, and locations
QR Code Not Appearing
- Ensure the
whatsappfeature 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 toallowlist, only listed users get responses - Check
group_policy— if set todisabled, 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
│ ├── error.rs # Error types
│ ├── 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)
Nostr
Moltis can receive and send encrypted direct messages over
Nostr, the decentralized social protocol. The integration
uses NIP-04
encrypted DMs (kind:4) and connects to relays via nostr-sdk — no public URL
or server infrastructure is required.
How It Works
┌──────────────────────────────────────────────────────┐
│ Nostr Relay Network │
│ (relay.damus.io, nos.lol, relay.nostr.band, ...) │
└──────────────────┬───────────────────────────────────┘
│ WebSocket subscription (kind:4)
▼
┌──────────────────────────────────────────────────────┐
│ moltis-nostr crate │
│ ┌────────────┐ ┌────────────┐ ┌────────────────┐ │
│ │ Bus │ │ Outbound │ │ Plugin │ │
│ │ (inbound) │ │ (replies) │ │ (lifecycle) │ │
│ └────────────┘ └────────────┘ └────────────────┘ │
└──────────────────┬───────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ Moltis Gateway │
│ (chat dispatch, tools, memory) │
└──────────────────────────────────────────────────────┘
The bot connects outward to Nostr relays via WebSocket. No port forwarding, public domain, or TLS certificate is needed. Messages are end-to-end encrypted between the sender and the bot using NIP-04.
Prerequisites
Before configuring Moltis, you need a Nostr secret key:
- Generate a new key pair using any Nostr client (e.g. Damus, Amethyst, or a key generation tool)
- Copy the secret key — either the
nsec1...bech32 format or the 64-character hex format - Note the corresponding public key (
npub1...) to share with users who want to message the bot
The secret key is highly sensitive — it controls the bot’s Nostr identity.
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.nostr.<account-id>] section to your moltis.toml:
[channels.nostr.my-bot]
secret_key = "nsec1..."
relays = ["wss://relay.damus.io", "wss://relay.nostr.band", "wss://nos.lol"]
dm_policy = "allowlist"
allowed_pubkeys = ["npub1abc...", "npub1def..."]
Make sure "nostr" is included in channels.offered (it is by default):
[channels]
offered = ["telegram", "discord", "slack", "matrix", "nostr"]
Configuration Fields
| Field | Required | Default | Description |
|---|---|---|---|
secret_key | yes | — | Nostr secret key (nsec1... bech32 or 64-char hex) |
relays | no | ["wss://relay.damus.io", "wss://relay.nostr.band", "wss://nos.lol"] | Relay WebSocket URLs to connect to |
dm_policy | no | "allowlist" | Who can DM the bot: "open", "allowlist", or "disabled" |
allowed_pubkeys | no | [] | Public keys allowed to DM (npub1... or hex, when dm_policy = "allowlist") |
enabled | no | true | Whether this account is active |
model | no | — | Override the default model for this channel |
model_provider | no | — | Provider for the overridden model |
otp_self_approval | no | true | Allow non-allowlisted senders to self-approve via OTP code |
otp_cooldown_secs | no | 300 | Cooldown after 3 failed OTP attempts |
profile.name | no | — | NIP-01 profile display name |
profile.display_name | no | — | NIP-01 longer display name |
profile.about | no | — | NIP-01 bio / about text |
profile.picture | no | — | NIP-01 avatar URL (HTTPS) |
profile.nip05 | no | — | NIP-05 identifier (e.g. bot@example.com) |
Full Example
[channels.nostr.my-bot]
secret_key = "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqspcgef"
relays = [
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://nos.lol",
]
dm_policy = "allowlist"
allowed_pubkeys = [
"npub1abc123...",
]
model = "anthropic/claude-sonnet-4-20250514"
model_provider = "anthropic"
otp_self_approval = true
otp_cooldown_secs = 300
[channels.nostr.my-bot.profile]
name = "Moltis Bot"
about = "AI assistant on Nostr"
nip05 = "bot@example.com"
Access Control
DM Policy
allowlist(default) — Only public keys inallowed_pubkeyscan message the bot. Unknown senders receive an OTP challenge ifotp_self_approvalis enabled, or are silently ignored.open— Anyone can DM the bot.disabled— All inbound DMs are ignored.
OTP Self-Approval
When otp_self_approval is enabled and a non-allowlisted sender messages the
bot, the sender appears in the Senders tab of the web UI where they can be
approved or denied. This works the same as OTP for Telegram and Matrix.
NIP-04 Encryption
All messages between the bot and users are encrypted using NIP-04 (kind:4 events). The bot can also decrypt inbound NIP-04 messages from any client that supports this standard.
NIP-44 and NIP-17 (gift-wrapped DMs) are planned for future releases.
Relay Health
The bot maintains persistent WebSocket connections to all configured relays.
The health probe reports the number of connected relays (e.g. “2/3 relays
connected”). If all relays disconnect, the bot will automatically attempt to
reconnect via nostr-sdk’s built-in reconnection logic.
Signal
Moltis can receive and send Signal messages through an external
signal-cli daemon. The Moltis process
talks to the daemon over local HTTP JSON-RPC for outbound messages and Server
Sent Events for inbound messages.
How It Works
Signal Network
│
▼
signal-cli daemon ── HTTP JSON-RPC + SSE ── moltis-signal
│ │
└──────── linked Signal account ──────┴── Moltis Gateway
Moltis does not embed libsignal directly. The Signal account, device link, and Signal protocol state stay inside signal-cli, which keeps the Moltis integration smaller and avoids coupling releases to Signal’s native protocol internals.
Prerequisites
Install and configure signal-cli first:
signal-cli -u +15551234567 register
signal-cli -u +15551234567 verify 123456
signal-cli --account +15551234567 daemon --http 127.0.0.1:8080
You can also use signal-cli’s linked-device flow instead of registering a new number. Keep the HTTP daemon reachable only from trusted local services.
Configuration
Add a [channels.signal.<account-id>] section to moltis.toml:
[channels.signal.personal]
account = "+15551234567"
http_url = "http://127.0.0.1:8080"
dm_policy = "allowlist"
allowlist = ["+15557654321", "550e8400-e29b-41d4-a716-446655440000"]
group_policy = "disabled"
mention_mode = "mention"
otp_self_approval = true
otp_cooldown_secs = 300
text_chunk_limit = 4000
Make sure "signal" is included in channels.offered if you customize that
list. It is included by default.
Fields
| Field | Required | Default | Description |
|---|---|---|---|
account | no | account ID | Signal account loaded in signal-cli, usually an E.164 phone number |
account_uuid | no | not set | Optional Signal account UUID for allowlist matching |
http_url | no | http://127.0.0.1:8080 | signal-cli daemon HTTP URL |
dm_policy | no | allowlist | open, allowlist, or disabled |
allowlist | no | [] | Allowed sender phone numbers, UUIDs, or normalized identifiers |
group_policy | no | disabled | open, allowlist, or disabled |
group_allowlist | no | [] | Allowed Signal group IDs |
mention_mode | no | mention | mention, always, or none |
ignore_stories | no | true | Ignore story events from signal-cli |
otp_self_approval | no | true | Let unknown DM senders self-approve with a PIN challenge |
otp_cooldown_secs | no | 300 | Cooldown after 3 failed OTP attempts |
text_chunk_limit | no | 4000 | Maximum UTF-8 bytes per outbound text chunk |
Current Limits
Signal support currently handles text DMs and group text messages. Outbound media uses the text fallback, and inbound attachments are surfaced as an attachment placeholder until attachment ingestion is added.
Telephony (Phone Calls)
Moltis can make and receive phone calls, enabling voice-based AI conversations over the public telephone network (PSTN).
Supported Providers
| Provider | Status | Features |
|---|---|---|
| Twilio | Supported | Outbound calls, inbound calls, TTS, speech recognition, DTMF |
| Telnyx | Supported | Outbound calls, inbound calls |
| Plivo | Supported | Outbound calls, inbound calls |
Quick Start
1. Get a Twilio Account
- Sign up at twilio.com
- Get your Account SID and Auth Token from the dashboard
- Buy or provision a phone number with Voice capability
2. Configure Phone Settings
Open the web UI and go to Settings > Phone. Choose a provider, save its credentials, set the caller phone number, and configure a public HTTPS webhook URL.
For TOML-managed settings, keep phone configuration under [phone]. Credentials are best stored
through Settings > Phone so they live in the credential store instead of plain TOML:
[phone]
enabled = true
provider = "twilio"
inbound_policy = "allowlist"
allowlist = ["+15559876543"]
[phone.twilio]
from_number = "+15551234567" # Your Twilio phone number (E.164)
webhook_url = "https://your-domain.com"
3. Start the Gateway
moltis gateway
The phone integration starts automatically with the gateway when [phone] is enabled and the active
provider has complete credentials.
Configuration Reference
[phone]
enabled = true # Enable phone calls globally
provider = "twilio" # twilio | telnyx | plivo
inbound_policy = "disabled" # disabled | allowlist | open
allowlist = ["+15559876543"] # Allowed inbound callers (E.164)
max_duration_secs = 3600 # Max call duration (default: 1 hour)
[phone.twilio]
from_number = "+15551234567" # Outbound caller ID (E.164)
webhook_url = "https://your-domain.com" # Public URL for provider callbacks
[phone.telnyx]
from_number = "+15551234567"
webhook_url = "https://your-domain.com"
[phone.plivo]
from_number = "+15551234567"
webhook_url = "https://your-domain.com"
The gateway still runs telephony through its internal channel plugin, but phone setup is deliberately separate from normal channel accounts in the UI.
Call Modes
Conversation Mode (default)
Full multi-turn interaction. The agent listens for speech, processes it through the LLM, and responds with TTS. The call continues until the user or agent hangs up, or the max duration is reached.
Notify Mode
One-way message delivery. The agent speaks a message and hangs up after a short delay. Useful for alerts, reminders, and notifications.
Agent Tool
Agents can make calls using the built-in voice_call tool:
{
"action": "initiate_call",
"to": "+15559876543",
"message": "Hello, this is a reminder about your appointment.",
"mode": "notify"
}
Available actions:
initiate_call- Start an outbound callend_call- Hang up an active callget_status- Check call state and transcriptsend_dtmf- Send touch-tone digits
CLI Commands
moltis voice-call call --to +15559876543 --message "Hello"
moltis voice-call status [call-id]
moltis voice-call end <call-id>
moltis voice-call setup
RPC Methods
| Method | Scope | Description |
|---|---|---|
voicecall.status | read | List telephony accounts and active calls |
voicecall.initiate | write | Start an outbound call |
voicecall.end | write | Hang up a call |
Webhook Endpoints
When configured with a public webhook_url, the gateway exposes:
| Endpoint | Purpose |
|---|---|
POST /api/channels/telephony/{account}/status | Call status callbacks |
POST /api/channels/telephony/{account}/answer | TwiML for answered calls |
POST /api/channels/telephony/{account}/gather | Speech/DTMF result handler |
Configure these in your Twilio phone number settings, or they are set automatically when initiating outbound calls.
Security
- Webhook verification: Twilio webhooks are verified using HMAC-SHA1 signature validation
- Inbound access control: Phone numbers can be restricted via allowlist
- Credential storage: Provider credentials are stored in the credential store when configured from Settings > Phone
- Max duration: Calls are automatically terminated after the configured max duration
Audio Pipeline
User Speech -> Twilio STT -> Text -> Agent (LLM) -> Text -> TTS -> mu-law 8kHz -> Caller
The telephony audio pipeline converts between PSTN-standard mu-law encoding (8 kHz, ITU-T G.711) and the PCM audio used by TTS providers.
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
# obscura_path = "/path/to/obscura" # Custom Obscura path
# lightpanda_path = "/path/to/lightpanda" # Custom Lightpanda 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 = "docker.io/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 exceedsmemory_limit_percentmemory_limit_percent = 90: New browsers blocked when system memory > 90%- Set
max_instances > 0for 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
| Action | Description | Required Params |
|---|---|---|
navigate | Go to a URL | url |
snapshot | Get DOM with element refs | - |
screenshot | Capture page image | full_page (optional) |
click | Click element by ref | ref_ |
type | Type into element | ref_, text |
scroll | Scroll page/element | x, y, ref_ (optional) |
evaluate | Run JavaScript | code |
wait | Wait for element | selector or ref_ |
get_url | Get current URL | - |
get_title | Get page title | - |
back | Go back in history | - |
forward | Go forward in history | - |
refresh | Reload the page | - |
close | Close 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, obscura, lightpanda.
auto (default) picks the first detected installed browser. If none are
installed, Moltis will attempt a best-effort auto-install, then retry
detection.
obscura launches the Obscura sidecar binary from obscura_path, the
OBSCURA environment variable, or PATH. It supports DOM-oriented browsing
through CDP, but not pixel screenshots.
lightpanda launches the Lightpanda sidecar binary from lightpanda_path, the
LIGHTPANDA environment variable, or PATH. It supports DOM-oriented browsing
through CDP, but not pixel screenshots.
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
| Feature | web_fetch | browser |
|---|---|---|
| Speed | Fast | Slower |
| Resources | Minimal | Chrome instance |
| JavaScript | No | Yes |
| Forms/clicks | No | Yes |
| Screenshots | No | Yes |
| Sessions | No | Yes |
| Use case | Static content | Interactive 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:
| Metric | Description |
|---|---|
moltis_browser_instances_active | Currently running browsers |
moltis_browser_instances_created_total | Total browsers launched |
moltis_browser_instances_destroyed_total | Total browsers closed |
moltis_browser_screenshots_total | Screenshots taken |
moltis_browser_navigation_duration_seconds | Page load time histogram |
moltis_browser_errors_total | Errors 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 = "docker.io/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:
-
Domain restrictions: Use
allowed_domainsto limit navigation to trusted sites only. This is the most effective mitigation. -
Review returned content: The snapshot action returns element text which could contain injected prompts. Be cautious with untrusted sites.
-
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
-
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-sandboxfor container compatibility. -
Resource limits: Browser instances are limited by memory usage (default: block when > 90% used). Set
max_instances > 0for a hard limit. -
Idle cleanup: Browsers are automatically closed after
idle_timeout_secsof inactivity. -
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.
-
Sandbox scripts: Browser scripts running in the exec sandbox (Puppeteer, Playwright) inherit sandbox network restrictions (
no_network: trueby default).
Browser Detection
Moltis automatically detects installed Chromium-based browsers in the following order:
- Custom path from
chrome_pathconfig - CHROME environment variable
- 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.
- macOS:
- 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
urlis provided butactionis missing, defaults tonavigate - 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_pathin config if using custom location - Set
CHROMEenvironment 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
snapshotto see available elements - Elements must be visible in the viewport
- Some elements may need scrolling first
Timeouts
- Increase
navigation_timeout_msfor slow pages - Use
waitaction 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 > 0for a hard limit if preferred - Lower
idle_timeout_secsto 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_percentthreshold - Use
closeaction 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
| Provider | provider value | Notes |
|---|---|---|
| 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
| Field | Required | Default | Description |
|---|---|---|---|
provider | no | "generic" | Provider hint ("fastmail", "icloud", "generic") |
url | depends | — | CalDAV base URL. Required for generic; optional for Fastmail/iCloud (well-known URL used). |
username | yes | — | Authentication username |
password | yes | — | Password or app-specific password |
timeout_seconds | no | 30 | HTTP request timeout |
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.
| Parameter | Required | Description |
|---|---|---|
calendar | yes | Calendar href (from list_calendars) |
start | no | ISO 8601 start date/time |
end | no | ISO 8601 end date/time |
Returns: href, etag, uid, summary, start, end, all_day,
location for each event.
create_event
Creates a new calendar event.
| Parameter | Required | Description |
|---|---|---|
calendar | yes | Calendar href |
summary | yes | Event title |
start | yes | ISO 8601 start (e.g. 2025-06-15T10:00:00 or 2025-06-15 for all-day) |
end | no | ISO 8601 end date/time |
all_day | no | Boolean, default false |
location | no | Event location |
description | no | Event 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.
| Parameter | Required | Description |
|---|---|---|
event_href | yes | Event href (from list_events) |
etag | yes | Current ETag (from list_events) |
summary | no | New title |
start | no | New start |
end | no | New end |
all_day | no | New all-day flag |
location | no | New location |
description | no | New description |
Returns: updated href and etag.
delete_event
Deletes an event. Also requires the current ETag.
| Parameter | Required | Description |
|---|---|---|
event_href | yes | Event href |
etag | yes | Current 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 callslist_eventswithstart/endspanning the current week.Agent: You have 3 events this week: …
You: Move the dentist appointment to Friday at 2pm.
The agent calls
update_eventwith the event’shrefandetag, setting the newstarttime.
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 > GraphQLtoggle
Changes apply immediately, without restart.
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET | /graphql | GraphiQL playground and WebSocket subscriptions |
POST | /graphql | Queries and mutations |
WebSocket subprotocols accepted:
graphql-transport-wsgraphql-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-uibuilds, GraphQL is behind the globalauth_gatemiddleware. - Without
web-ui, GraphQL is explicitly guarded bygraphql_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>
Schema Layout
The schema is organized by namespaces that map to gateway method groups.
Top-level query fields include:
healthstatussystem,node,chat,sessions,channelsconfig,cron,heartbeat,logstts,stt,voiceskills,models,providers,mcpusage,execApprovals,projects,memory,hooks,agentsvoicewake,device
Top-level mutation fields follow the same namespace pattern (for example:
chat.send, config.set, cron.add, providers.oauthStart, mcp.reauth).
Subscriptions include:
chatEventsessionChangedcronNotificationchannelEventnodeEventticklogEntrymcpStatusChangedapprovalEventconfigChangedpresenceChangedmetricsUpdateupdateAvailablevoiceConfigChangedskillsInstallProgressallEvents
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", sessionKey: "main") {
ok
sessionKey
}
}
}
Subscription (graphql-transport-ws)
- Connect to
ws://localhost:13131/graphqlwith subprotocolgraphql-transport-ws. - Send:
{ "type": "connection_init" }
- 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 five
operations: get, set, delete, list, and clear.
Agent Tool
The session_state tool is registered as a built-in tool and available in every
session.
Get a value
{
"operation": "get",
"namespace": "my-skill",
"key": "last_query"
}
Returns { "value": "<value or null>" }.
Set a value
{
"operation": "set",
"namespace": "my-skill",
"key": "last_query",
"value": "SELECT * FROM users"
}
Returns { "ok": true }. Insert-or-update semantics.
List all keys in a namespace
{
"operation": "list",
"namespace": "my-skill"
}
Returns { "entries": [{ "key": "...", "value": "..." }] }.
Delete a single key
{
"operation": "delete",
"namespace": "my-skill",
"key": "last_query"
}
Returns { "deleted": true } if the key existed, { "deleted": false } otherwise.
Clear all keys in a namespace
{
"operation": "clear",
"namespace": "my-skill"
}
Returns { "deleted": <count> } with the number of entries removed.
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.
State values are strings. To store structured data, serialize to JSON before writing and parse after reading.
Slash Commands
Slash commands are available in the web UI chat input, on all messaging channels (Telegram, Discord, Slack, Matrix, etc.), and where noted, via the CLI.
Type / in the chat input to see the autocomplete popup.
Session Management
| Command | Description |
|---|---|
/new | Start a new session |
/clear | Clear session history |
/compact | Summarize conversation to save tokens |
/context | Show session context and project info |
/sessions | List and switch sessions (channels only) |
/attach | Attach an existing session to this channel (channels only) |
/fork [label] | Fork the current session into a new branch |
/fork
Creates an independent copy of the current conversation. The new session inherits the parent’s model, project, mode, and agent. Messages up to the current point are copied.
/fork experiment-a
Available in web UI, all channels, and via the sessions.fork RPC. See
Session Branching for details.
Control
| Command | Description |
|---|---|
/agent [N] | Switch session agent |
/mode [N|name|none] | Switch session mode |
/model [N] | Switch provider/model |
/sandbox [on|off|image N] | Toggle sandbox and choose image |
/sh [on|off] | Enter command mode (passthrough to shell) |
/stop | Abort the current running agent |
/peek | Show current thinking/tool status |
/update [version] | Update moltis (owner-only) |
Quick Actions
| Command | Description |
|---|---|
/btw <question> | Quick side question (no tools, not persisted) |
/fast [on|off|status] | Toggle fast/priority mode |
/insights [days] | Show usage analytics (tokens, providers) |
/steer <text> | Inject guidance into the current agent run |
/queue <message> | Queue a message for the next agent turn |
/rollback [N|diff N] | List or restore file checkpoints |
/btw
Ask a quick side question without tools and without persisting the exchange to session history. Uses the session’s current model and recent context (last 20 messages) as read-only background.
/btw what's the default port for PostgreSQL?
The response appears inline and is discarded after display.
/fast
Toggle fast/priority mode for the current session. When enabled, uses provider-specific priority processing where supported (Anthropic prompt caching priority, OpenAI priority processing).
/fast # toggle
/fast on # enable
/fast off # disable
/fast status # check current state
Session-scoped — does not persist across gateway restarts.
/insights
Show usage analytics from the metrics store. Displays LLM completions, token counts (input/output), errors, tool executions, and per-provider breakdowns.
/insights # last 30 days (default)
/insights 7 # last 7 days
/insights 90 # last 90 days
In the web UI, /insights renders a formatted markdown table inline. The same
data is available as a dashboard in Monitoring > Insights tab, and via the
REST API at GET /api/metrics/insights?days=N.
/steer
Inject guidance into an active agent run without interrupting it. The text is seen by the LLM on its next iteration (after the current tool call completes).
/steer use the staging API, not production
/steer focus on security issues only
Only works while an agent run is active. If no run is active, returns an error.
/queue
Queue a message for the next agent turn without interrupting the current one. When the active run finishes, the queued message is automatically submitted.
/queue now write tests for what you just built
If no run is active, the message is sent immediately.
/rollback
List and restore file checkpoints created by the automatic checkpointing
system. Before every Write, Edit, or MultiEdit tool call, the original
file is snapshotted.
/rollback # list recent turns with file changes
/rollback 1 # restore all files from turn 1
/rollback diff 1 # preview which files were changed in turn 1
Checkpoints are grouped by turn (one user message = one turn). Restoring a turn reverts all files that were modified during that turn to their pre-turn state.
See Checkpoints for details on the automatic checkpointing system.
Approval Management
| Command | Description |
|---|---|
/approvals | List pending exec approvals |
/approve [N] | Approve a pending exec request |
/deny [N] | Deny a pending exec request |
Help
| Command | Description |
|---|---|
/help | Show available commands (handled locally by each channel) |
Modes
Modes are session-scoped prompt overlays. They change how the currently selected chat agent works right now without creating a new agent, changing memory, or changing sub-agent presets.
Use modes when you want a temporary working style:
| Mode | Use it for |
|---|---|
concise | Short, direct answers |
technical | Detailed technical analysis |
creative | Broad ideation and alternatives |
teacher | Step-by-step explanation |
plan | Scoping and sequencing before implementation |
build | Implementation-focused work |
review | Bug-focused code review |
research | Evidence-first investigation |
elevated | Extra caution for risky operations |
Switch the active session from the web chat input or any channel that supports regular slash commands:
/mode
/mode review
/mode 3
/mode none
/mode lists available modes. /mode none clears the overlay.
Configure Modes
Built-in modes are available on every install. Add or override modes in
moltis.toml:
[modes.presets.incident]
name = "Incident"
description = "production incident response"
prompt = "Prioritize impact, timeline, mitigation, rollback, logs, and clear status updates."
Mode presets are intentionally small. For durable identity, memory, and chat
history, create a chat agent. For delegated work through spawn_agent, use an
agent preset.
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.
/fork Command
The quickest way to fork — type in the chat input or on any channel:
/fork # fork with auto-generated label
/fork experiment-a # fork with a custom label
Available in the web UI, Telegram, Discord, Slack, Matrix, and all other channels. See Slash Commands for the full list.
Forking from the UI
There are three ways to fork a session:
/forkcommand — type/fork [label]in the chat input.- 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.
All three 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:
{
"fork_point": 5,
"label": "explore-alternative"
}
label— label for the new session (required).fork_point— the message index to fork at (0-based). Messages at indices 0 through N-1 are copied; the message at index N becomes the first new message in the forked session. If omitted, all messages are copied.
The tool returns { "key": "<session-key>", "forkPoint": N }.
RPC Method
The sessions.fork RPC method is the underlying mechanism:
{ "key": "main", "forkPoint": 5, "label": "my-fork" }
On success the response payload contains { "sessionKey": "session:<uuid>", "forkPoint": N, "label": "..." }.
What Gets Inherited
When forking, the new session inherits:
| Inherited | Not inherited |
|---|---|
| Messages (up to fork point) | Worktree branch |
| Model selection | Sandbox settings |
| Project assignment | Channel binding |
| Agent ID | |
| MCP disabled flag | |
| Node assignment |
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 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.
Navigation After Delete
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.
Archive in the UI
The web UI also lets you archive sessions when you want to keep them without leaving them in the main sidebar list.
- Open More controls for a session and click Archive.
- Archived sessions are hidden from the default sidebar list.
- Enable Show archived sessions in the sidebar to reveal and restore them.
Archive is available for any non-main session, including cron and
channel-bound chats, except when the session is the current active session for
its bound channel chat. That prevents hiding the live Telegram, Discord, or
similar chat out from under the channel router.
A forked session is fully independent after creation. Changes to the parent do not propagate to the fork, and vice versa.
Checkpoints
Moltis automatically snapshots files before the agent modifies them. If the
agent breaks something, use /rollback to restore files
to their pre-turn state.
Automatic Per-Turn Checkpointing
An AutoCheckpointHook runs before every file-mutating tool call (Write,
Edit, MultiEdit) and snapshots the target file. Checkpoints are grouped by
turn (one user message = one turn), so /rollback 1 undoes all file changes
from that turn at once.
The hook also fires on skill and memory mutations:
create_skill,update_skill,delete_skill,write_skill_filesmemory_save,memory_forget,memory_delete- the silent pre-compaction memory flush
Each mutation creates a manifest-backed snapshot in ~/.moltis/checkpoints/
before the write or delete happens.
/rollback Command
Available in the web UI, all channels (Telegram, Discord, Slack, etc.), and CLI.
/rollback # list recent turns with file changes
/rollback 1 # restore all files from turn 1
/rollback diff 1 # preview which files were changed in turn 1
Turns are session-scoped — you only see checkpoints from your current session.
Cleanup
When checkpoint count exceeds 500, the oldest 20% are automatically pruned. Cleanup runs lazily on each new checkpoint creation.
Tool Surface
checkpoints_list
List recent automatic checkpoints.
{
"limit": 20,
"path_contains": "skills/my-skill"
}
checkpoint_restore
Restore a checkpoint by ID.
{
"id": "3c7c6f2f8b7c4d8c8b8cdb91d9161f59"
}
Mutation Results
Checkpointed tools return a checkpointId field in their result payload. That
gives agents and users a direct restore handle without first listing every
checkpoint.
Behavior
- If the target existed, Moltis snapshots the file or directory first.
- If the target did not exist yet, restore removes the later-created path.
- Restore replaces the current target state with the checkpoint snapshot.
- Checkpoints are internal safety artifacts, they do not touch the user’s git history.
Compaction
When a chat session fills up the model’s context window, Moltis compacts the conversation so the agent can keep working. Compaction replaces (or rewrites) older messages with a summary so the retry fits inside the context budget.
The compaction mode you choose controls the cost/fidelity trade-off: faster and free, or slower and higher quality. Moltis supports four modes out of the box.
Why compaction matters
A long coding session can produce thousands of messages, tool calls, and tool results. Every turn has to re-send the full history, so eventually you hit the context window limit and the provider rejects the request. Without compaction the session is dead — you lose the thread of work. With compaction the agent keeps going.
The trade-off: any compaction strategy loses some information. The different modes choose different things to preserve.
The four modes
deterministic (default)
Zero LLM calls, instant, offline. Inspects the message history directly and builds a structured summary: message counts, tool names, file-path mentions, recent user requests, keyword-matched pending work, and a head-3 + tail-5 timeline. Replaces the entire history with that one summary as a single user message.
Strengths
- Free (no tokens, no network).
- Deterministic output — two runs on the same history produce the same summary, so debugging and testing is easy.
- Works offline and on air-gapped deployments.
- Zero prompt-injection surface (the LLM never sees the raw history).
Weaknesses
- Can’t preserve decisions or reasoning chains — it’s a navigation index, not a narrative.
- “Pending work” relies on keyword matches (
todo,next,pending…) which miss naturally-phrased follow-ups. - Middle history is dropped entirely; only the head and tail show up in the timeline preview.
- Tail context is not preserved verbatim, so the LLM retry doesn’t see the most-recent turns word-for-word.
Pick this when the session is a short chat channel, you’re cost-sensitive, or you’re running without network access. This is the default so new installs don’t silently spend tokens on compaction.
recency_preserving
Zero LLM calls, higher fidelity. Keeps the first few messages (system
prompt + first exchange) and the most recent ~20 % of the context window
verbatim. The middle region is collapsed into a short marker message
("N earlier messages were elided… Recent messages are preserved verbatim below.") and any bulky tool-result content in the retained slice is
replaced with a placeholder so a single multi-KB tool output can’t blow
through the context window on its own.
Strengths
- No LLM cost, still offline-safe.
- Tail context is preserved verbatim, so the retry sees the most-recent turns exactly as they happened.
- Keeps reasoning chains from the middle that don’t depend on tool output.
- Repairs orphaned tool-call / tool-result pairs so strict providers (Anthropic, OpenAI strict mode) don’t reject the retry.
Weaknesses
- Cannot merge redundant discussions (same topic raised five times survives five times).
- No semantic understanding of what’s important.
Pick this when you want a significant quality boost over deterministic
without paying any tokens — the best free option for most agentic coding
sessions.
structured
Head + LLM structured summary + tail. The highest-fidelity mode. Same
head and tail boundary logic as recency_preserving, but the middle is
summarised with a single LLM call using a structured template:
## Goal
## Constraints & Preferences
## Progress
### Done / ### In Progress / ### Blocked
## Key Decisions
## Relevant Files
## Next Steps
## Critical Context
This is the same convention used by hermes-agent’s ContextCompressor
and openclaw’s compaction safeguard. Iterative re-compaction preserves
and updates prior summary sections (work moves from In Progress to
Done as it completes).
Strengths
- Highest fidelity — preserves reasoning, decisions, and cross-session context.
- Supports a cheap auxiliary summary model via
summary_model, so you can run a big frontier model for coding and a small fast model for compaction. - Automatic fallback to
recency_preservingon LLM failure, so compaction never silently drops information.
Weaknesses
- Costs a summary LLM call per compaction.
- Quality depends on the summary model’s instruction-following.
Pick this when session quality matters more than per-compaction cost — e.g. long agentic coding sessions where losing a decision would mean re-doing hours of work.
llm_replace
Replace entire history with a single LLM-generated summary. The pre-PR-#653 behaviour: stream a plain-text summary from the session’s provider, replace the history with one user message containing the summary. No head/tail preservation.
Strengths
- Maximum token reduction — the retry sees one message, period.
- Works with any provider that supports streaming.
Weaknesses
- Loses recent turns verbatim (strictly worse than
structuredfor the same cost). - No structured template, so summary quality varies with the model.
- No automatic fallback — an LLM failure aborts compaction.
Pick this when you need the smallest possible post-compaction history
and structured isn’t available yet (or you explicitly want the old
behaviour).
Comparison at a glance
| Feature | deterministic | recency_preserving | structured | llm_replace |
|---|---|---|---|---|
| LLM calls | 0 | 0 | 1 | 1 |
| Token cost | none | none | medium | medium |
| Latency | ~0 ms | ~0 ms | 1–10 s | 1–10 s |
| Head preserved verbatim | ✗ | ✓ | ✓ | ✗ |
| Tail preserved verbatim | ✗ | ✓ (token-budget) | ✓ (token-budget) | ✗ |
| Middle strategy | drop | prune tool output | LLM summary | drop |
| Decisions / rationale | ✗ | partial | ✓ | partial |
| Iterative re-compaction | merge | N/A | template update | re-summarise |
| Tool-pair integrity | N/A | ✓ | ✓ | N/A |
| Fallback on LLM failure | N/A | N/A | → recency_preserving | abort |
| Works offline | ✓ | ✓ | ✗ | ✗ |
| Deterministic | ✓ | ✓ | ✗ | ✗ |
| Status | shipped | shipped | shipped | shipped |
Configuration
All compaction settings live under [chat.compaction] in moltis.toml:
[chat.compaction]
mode = "deterministic" # "deterministic" | "recency_preserving" | "structured" | "llm_replace"
threshold_percent = 0.95 # Auto-compact trigger AND tail-budget multiplier. See below.
protect_head = 3 # Head messages kept verbatim (recency/structured).
protect_tail_min = 20 # Minimum tail messages kept verbatim (recency/structured).
tail_budget_ratio = 0.20 # Tail size as fraction of threshold_percent × context_window.
tool_prune_char_threshold = 200 # Middle tool results longer than this get placeholder-replaced.
summary_model = "openrouter/google/gemini-2.5-flash" # RESERVED — see note below.
max_summary_tokens = 4096 # RESERVED — see note below.
show_settings_hint = true # Show "Change chat.compaction.mode in moltis.toml…" footer.
Hiding the settings hint
By default, every compaction notice (web UI compact card and channel messages) includes a short footer pointing at the config key:
Change
chat.compaction.modeinmoltis.toml(or the web UI settings panel) to pick a different compaction strategy.
Once you know the setting exists, this becomes noise. Set:
[chat.compaction]
show_settings_hint = false
The mode and token counts still ship with every compaction notice — only the footer is stripped.
All fields are optional. When a field is omitted the default shown above is
used. deterministic mode ignores every field except mode and
threshold_percent.
Picking a threshold
threshold_percent serves two related purposes:
- Auto-compact trigger. When the estimated next request would
exceed
threshold_percent × context_windowtokens,send()fires a compaction pre-emptively. On a 200 K model with the default0.95, compaction starts when the session reaches 190 K tokens. (The default matches the pre-PR-#653 hardcoded trigger so upgrades are behaviour-neutral.) - Tail-budget multiplier. For
recency_preservingandstructuredmodes, the size of the verbatim tail isthreshold_percent × tail_budget_ratio × context_window. With the defaults that’s 200 K × 0.95 × 0.20 = 38 K tokens of tail preserved.
Both uses move together: lowering threshold_percent compacts earlier
and shrinks the preserved tail, which is usually what you want on a
tight context window.
- Lower values (≈ 0.5) compact more aggressively and leave more headroom for a new burst of tool calls.
- Higher values (≈ 0.9) delay compaction as long as possible but risk
blowing through the window on a single large tool result. The config
validator clamps the effective value to
0.95as an upper bound so auto-compact can’t be accidentally disabled.
Manual chat.compact RPC calls ignore threshold_percent for the
trigger check and compact whatever’s there, but still use it for the
tail-budget math inside recency-preserving and structured modes.
Picking a summary model
⚠️
summary_model/max_summary_tokensare reserved for a follow-up — beads issue moltis-8me. They’re present in the config schema so you can start setting them today, but thestructuredandllm_replacestrategies currently ignore them and always use the session’s primary provider. Setting either field to a non-default value triggers a one-shot runtime WARN that names the fields and the tracking issue so you’re not billed for the wrong model without warning.
When the auxiliary-model subsystem lands, summary_model will take
a provider-qualified model identifier understood by the provider
registry (e.g. "openrouter/google/gemini-2.5-flash",
"anthropic/claude-3-5-haiku-20241022"). Leave it unset to reuse the
session’s primary model.
Small fast models are usually the right choice for compaction: they’re cheap, respond in seconds, and are good at instruction-following on a structured template. Reserve the frontier model for the actual coding work.
Migration notes
Upgrading from a pre-PR-#653 install? The default changed from implicit
LLM compaction to deterministic. If you want the old behaviour back,
set:
[chat.compaction]
mode = "llm_replace"
No other changes needed — llm_replace uses the session’s primary model
just like the pre-PR behaviour did.
Tracking issues
- Epic: moltis-dxw — pluggable compaction modes
- moltis-g37 — config scaffolding, docs,
llm_replacemode ✓ - moltis-h0c —
recency_preservingmode ✓ - moltis-aff —
structuredmode ✓ - moltis-8me — auxiliary-model subsystem for cheap summary models (follow-up, lets users route compaction to a cheap auxiliary model instead of the session’s primary model)
Further reading
hermes-agent/agent/context_compressor.py— reference implementation of the head + LLM summary + tail strategy that inspiredstructuredmode.openclaw/src/agents/compaction.ts+pi-hooks/compaction-safeguard.ts— LLM compaction with quality auditing and tool-pair repair.crates/chat/src/compaction.rs— currentdeterministicmode implementation.
Filesystem Tools
Moltis ships six native filesystem tools that agents use for structured,
typed file I/O: Read, Write, Edit, MultiEdit, Glob, and Grep.
Their schemas match Claude Code exactly so LLMs trained on those tools
work without adaptation. See GitHub #657
for background.
Prefer these over shelling out via the exec tool running cat / sed /
rg — the native tools give the model line-numbered reads, uniqueness-
enforced edits, typed error payloads, and structured audit logs.
Tools
Read
Read a file with line-numbered output.
{
"file_path": "/absolute/path/to/file.rs",
"offset": 1,
"limit": 2000,
"pages": "1-5"
}
file_path— absolute path (required). Relative paths are rejected.offset— 1-indexed line to start at. Default1.limit— max lines to return. Default2000.pages— (PDF only) page range to extract, e.g."1-5","3", or"10-20". Max 20 pages per request.
Returns one of the following typed payloads (the kind field is the
discriminator the LLM branches on):
kind: "text"— happy path. Includescontent(cat-n style line numbers),total_lines,rendered_lines,start_line,truncated.kind: "binary"— file detected as binary. Includesbytes. Withbinary_policy = "base64"the payload also carriesbase64.kind: "not_found"— file does not exist.kind: "permission_denied"— read not permitted by filesystem ACLs.kind: "too_large"— file exceedsmax_read_bytes.kind: "not_regular_file"— path is a directory, fifo, socket, etc.kind: "path_denied"— blocked by[tools.fs]allow/deny rules.kind: "image"— file detected as a supported image format (PNG, JPEG, GIF, WebP). Includeswidth,height,format, andbase64data. Images are resized and optimized for LLM consumption.kind: "pdf"— file detected as PDF. Includestext(extracted content),pages(total page count),rendered_pages, andtruncated. Supports optionalpagesparameter (e.g."1-5") to extract a page range.
CRLF files are rendered with \r stripped so the line-number column
aligns correctly; the file on disk is not modified.
Write
Atomically write a file. Parent directories must already exist. Refuses to follow symlinks.
{
"file_path": "/absolute/path/to/file.rs",
"content": "fn main() {}\n"
}
Returns { file_path, bytes_written, checkpoint_id }. checkpoint_id
is null unless [tools.fs].checkpoint_before_mutation is enabled.
Edit
Exact-match string replacement. Refuses to edit if old_string is not
unique in the file unless replace_all=true — the uniqueness requirement
is the main correctness win over shell sed.
{
"file_path": "/absolute/path/to/file.rs",
"old_string": "fn foo()",
"new_string": "fn bar()",
"replace_all": false
}
Returns { file_path, replacements, replace_all, recovered_via_crlf, checkpoint_id }.
recovered_via_crlf is true when the tool fell back to CRLF matching
for an LF-only old_string against a CRLF file.
MultiEdit
Apply multiple sequential edits to a single file atomically. Each edit sees the output of the previous. Either all succeed or the file is left untouched.
{
"file_path": "/absolute/path/to/file.rs",
"edits": [
{ "old_string": "alpha", "new_string": "ALPHA" },
{ "old_string": "beta", "new_string": "BETA" }
]
}
Glob
Find files matching a glob pattern, sorted by modification time (newest
first). Respects .gitignore by default.
{
"pattern": "**/*.rs",
"path": "/absolute/path/to/project"
}
path is required unless [tools.fs].workspace_root is configured. A
relative path is rejected.
Grep
Regex content search. Walks with the same ignore rules as Glob.
{
"pattern": "fn\\s+main",
"path": "/absolute/path/to/project",
"output_mode": "content",
"glob": "**/*.rs",
"-n": true,
"-C": 2
}
Parameters: pattern (regex, required), path, glob, type
(rust / py / ts / etc.), output_mode (files_with_matches /
content / count), -i (case-insensitive), -n (line numbers),
-A / -B / -C (context lines), multiline, head_limit, offset.
Configuration
All fs tools are configured under a single [tools.fs] section. Every
field is optional and conservative by default — the tools work out of
the box with no configuration.
[tools.fs]
# Default search root for Glob/Grep when `path` is omitted. Absolute.
# workspace_root = "/home/user/projects/my-app"
# Absolute path globs the fs tools may touch. Empty = no allowlist.
# Evaluated after canonicalization so symlinks can't escape.
allow_paths = []
# Deny globs. Deny wins over allow.
deny_paths = []
# Per-session Read history + loop detection. Prerequisite for
# must_read_before_write.
track_reads = false
# Refuse Write/Edit/MultiEdit on files the session hasn't Read.
# Requires track_reads = true.
must_read_before_write = false
# When true, Write/Edit/MultiEdit pause for explicit operator approval
# before mutating a file.
require_approval = true
# Read size cap. Files larger than this return a typed "too_large" payload.
max_read_bytes = 10485760 # 10 MB
# Binary file handling:
# "reject" — typed marker without content (default)
# "base64" — include base64-encoded bytes in the payload
binary_policy = "reject"
# Whether Glob/Grep respect .gitignore / .ignore / .git/info/exclude.
respect_gitignore = true
# When true, Write/Edit/MultiEdit snapshot the target file via the
# existing CheckpointManager before mutating, so the pre-edit state can
# be restored via the `checkpoint_restore` tool. Off by default because
# checkpoints grow with agent activity.
checkpoint_before_mutation = false
# Adaptive read sizing: when set, Read caps per-call output so a single
# call can't consume more than ~20% of the model's working set.
# Clamped to [50 KB, 512 KB]. When unset, Read uses a fixed 256 KB cap.
# context_window_tokens = 200000 # e.g. Claude Sonnet
require_approval reuses the existing approval queue and WebSocket
prompting path. If nobody approves the request, the mutation times out
instead of landing silently.
Policy Integration
The [tools.policy] allow/deny list gates access by tool name, not by
file path. You can make an agent read-only without touching fs-level
policy:
[tools.policy]
deny = ["Write", "Edit", "MultiEdit"]
The agent retains Read, Glob, and Grep — no need to deny the
shell tool wholesale and lose every other capability.
File-path allow/deny lives in [tools.fs]. Layer both for fine-grained
control. Example: a code-reviewer agent that can read the project tree
but can’t touch anything outside it:
[tools.policy]
allow = ["exec", "browser", "memory", "Read", "Glob", "Grep"]
[tools.fs]
workspace_root = "/home/user/project"
allow_paths = ["/home/user/project/**"]
deny_paths = ["/home/user/project/.env*", "/home/user/project/secrets/**"]
respect_gitignore = true
require_approval = true
Structured Audit
Every tool invocation is a structured event through moltis’s existing
tracing layer and the moltis_tool_executions_total /
moltis_tool_execution_errors_total metrics. Writes appear in traces as
structured key/value pairs — tool=Write file_path=... bytes=... outcome=ok
— dramatically easier to review than an opaque shell command string, and
the second big win (alongside model-quality improvements) that motivated
#657.
Related
- Checkpoints — pairs with
checkpoint_before_mutationfor opt-in pre-edit snapshots. - Hooks —
BeforeToolCallandToolResultPersistreceive structured payloads for each fs tool call, so policy hooks can inspect typed parameters instead of parsing shell strings. - Sandbox — fs tools route through the sandbox when the session is sandboxed. If no real sandbox backend is available, Moltis warns at startup and the tools operate on the gateway host.
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:
| Value | Behaviour |
|---|---|
"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.
Exec-triggered wakes have a cooldown guard so a heartbeat turn that runs exec
does not immediately re-wake itself in a loop. Configure it with
[heartbeat].wake_cooldown in moltis.toml:
[heartbeat]
wake_cooldown = "5m" # default; set "0" to disable
The value uses Moltis duration syntax such as "30s", "10m", or "1h".
The cooldown applies only to exec-completion wakes. Cron jobs with
wakeMode = "now" still wake the heartbeat immediately.
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:
| Kind | Fields | Description |
|---|---|---|
every | every_ms | Repeat at a fixed interval (milliseconds) |
cron | expr, optional tz | Standard cron expression (e.g. "0 */6 * * *") |
at | at_ms | Run 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 joblist— List all jobsrun— Trigger a job immediatelyupdate— Patch an existing job (name, schedule, enabled, wakeMode, etc.)remove— Delete a jobruns— 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: truepayload.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:
| Target | Description |
|---|---|
"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
| Metric | Type | Description |
|---|---|---|
moltis_cron_jobs_scheduled | Gauge | Number of scheduled jobs |
moltis_cron_executions_total | Counter | Job executions |
moltis_cron_execution_duration_seconds | Histogram | Job duration |
moltis_cron_errors_total | Counter | Failed jobs |
moltis_cron_stuck_jobs_cleared_total | Counter | Jobs exceeding 2h timeout |
moltis_cron_input_tokens_total | Counter | Input tokens from cron runs |
moltis_cron_output_tokens_total | Counter | Output tokens from cron runs |
Webhooks
Moltis can receive inbound HTTP webhooks from external services and run AI agents in response. Each webhook delivery becomes a persistent chat session that can be inspected and continued from the web UI.
Use webhooks to trigger agents from GitHub PRs, GitLab merge requests, Stripe payments, PagerDuty incidents, or any service that can POST JSON to a URL.
How It Works
External Service (GitHub, Stripe, …)
│
▼ POST /api/webhooks/ingest/{public_id}
┌──────────────────────────────────┐
│ Ingress Handler │
│ verify → filter → dedup → 202 │
└──────────────┬───────────────────┘
│ delivery_id
▼
┌──────────────────────────────────┐
│ Background Worker │
│ normalize → create session │
│ → inject message → chat.send │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ Persistent Session │
│ Agent processes event, │
│ optionally acts back via tools │
└──────────────────────────────────┘
- The external service POSTs to the webhook’s public endpoint.
- Moltis verifies authentication, checks the event filter, and deduplicates.
- The request is acknowledged with
202 Acceptedimmediately. - A background worker normalizes the payload, creates a chat session, and runs the bound agent.
- The resulting session is visible in the web UI like any other conversation.
Setup
Webhooks can be created from the web UI, the CLI, or by the agent itself
using the webhook tool. They are not part of the onboarding flow.
Creating a Webhook (Web UI)
- Navigate to Settings → Webhooks and click Create webhook.
- Choose a source profile (GitHub, GitLab, Stripe, or Generic).
- Configure authentication — the profile pre-selects a recommended mode.
- Optionally filter which event types to process.
- Select a target agent and optional model override.
- Click Create — the endpoint URL is displayed with a copy button.
- Register this URL in the external service’s webhook settings.
Creating a Webhook (CLI)
moltis webhooks create \
--name github-pr-review \
--source-profile github \
--auth-mode github_hmac_sha256 \
--events "pull_request.opened,pull_request.synchronize" \
--system-prompt "Review this PR for security issues"
For a zero-cost event forwarder (no LLM tokens):
moltis webhooks create \
--name stripe-payments \
--source-profile stripe \
--auth-mode stripe_webhook_signature \
--deliver-only \
--prompt-template "Payment: {data.object.amount} {data.object.currency} from {data.object.customer_email}" \
--deliver-to telegram \
--events "payment_intent.succeeded"
Creating a Webhook (Agent Tool)
The agent can create webhooks programmatically using the webhook tool:
“Set up a webhook for GitHub issues on my repo and forward them to my Slack channel”
The agent will use the webhook tool with action: "create" to set up the endpoint,
then tell you the URL to register in GitHub’s settings.
Endpoint URL
Each webhook gets a stable, high-entropy public URL:
https://your-moltis-host/api/webhooks/ingest/wh_a1b2c3d4e5f6...
The wh_ prefix followed by 36 random hex characters serves as a routing
identifier — it is not authentication. Authentication is handled by the
configured auth mode.
Source Profiles
Source profiles define how to authenticate, parse, and normalize events from a specific provider. Selecting a profile pre-fills the recommended auth mode and provides an event catalog for filtering.
| Profile | Auth Mode | Event Parsing | Entity Grouping |
|---|---|---|---|
| Generic | Static header | Configurable header | None |
| GitHub | HMAC-SHA256 (X-Hub-Signature-256) | X-GitHub-Event + action | PR number, issue number |
| GitLab | Token (X-Gitlab-Token) | X-Gitlab-Event + action | MR iid, issue iid |
| Stripe | Webhook signature (Stripe-Signature) | $.type in body | Subscription ID |
GitHub
GitHub webhooks use HMAC-SHA256 signature verification. When you create a webhook with the GitHub profile:
- Moltis generates a random secret (or you provide one).
- In your GitHub repo, go to Settings → Webhooks → Add webhook.
- Set the payload URL to your Moltis webhook endpoint.
- Set content type to
application/json. - Paste the secret.
- Select the events you want to trigger (or choose “Send me everything” and filter in Moltis).
Event types:
| Event | Description | Use case |
|---|---|---|
pull_request.opened | New PR | Code review, labeling |
pull_request.synchronize | PR updated | Re-review |
pull_request.closed | PR closed/merged | Cleanup, changelog |
push | Commits pushed | CI trigger, deploy check |
issues.opened | New issue | Triage, auto-respond |
issue_comment.created | Comment on issue/PR | Answer questions |
pull_request_review.submitted | PR review posted | Respond to feedback |
release.published | New release | Announce, post-release tasks |
workflow_run.completed | Actions workflow done | Post-CI analysis |
Payload normalization extracts key fields (repo, PR number, author, branch, description, changed files) instead of dumping the full payload into the agent prompt — keeping token usage reasonable.
GitLab
GitLab webhooks use a static token in the X-Gitlab-Token header.
- In your GitLab project, go to Settings → Webhooks.
- Set the URL to your Moltis webhook endpoint.
- Paste the secret token.
- Select trigger events.
Event types:
| Event | Description |
|---|---|
merge_request.open | New merge request |
merge_request.update | MR updated |
merge_request.merge | MR merged |
push | Commits pushed |
note | Comment on MR or issue |
issue.open | New issue |
pipeline | Pipeline status change |
Stripe
Stripe webhooks use a composite signature in the Stripe-Signature header
with timestamp validation (5-minute tolerance).
- In the Stripe Dashboard, go to Developers → Webhooks → Add endpoint.
- Set the endpoint URL to your Moltis webhook endpoint.
- Select events to listen to.
- Copy the signing secret (
whsec_...) into Moltis.
Event types:
| Event | Description |
|---|---|
checkout.session.completed | Successful checkout |
payment_intent.succeeded | Payment captured |
payment_intent.payment_failed | Payment failed |
invoice.paid | Invoice paid |
customer.subscription.created | New subscription |
customer.subscription.deleted | Subscription canceled |
charge.dispute.created | Chargeback opened |
Generic
The generic profile works with any service that can POST JSON. Configure a
static header or bearer token for authentication. Event type is extracted from
common headers (X-Event-Type, X-Webhook-Event) if present.
Authentication
Each webhook is configured with an auth mode that verifies inbound requests.
| Mode | Header | Verification |
|---|---|---|
none | — | No verification (testing only) |
static_header | Configurable | Constant-time comparison of header value |
bearer | Authorization | Bearer <token> comparison |
github_hmac_sha256 | X-Hub-Signature-256 | HMAC-SHA256 of body against shared secret |
gitlab_token | X-Gitlab-Token | Constant-time token comparison |
stripe_webhook_signature | Stripe-Signature | HMAC-SHA256 with timestamp tolerance |
linear_webhook_signature | Linear-Signature | HMAC-SHA256 |
pagerduty_v2_signature | X-PagerDuty-Signature | HMAC-SHA256 |
sentry_webhook_signature | Sentry-Hook-Signature | HMAC-SHA256 |
The none auth mode accepts all requests without verification. Use it only for
local testing. The UI displays a warning when this mode is selected.
All secret comparisons use constant-time operations to prevent timing attacks.
Event Filtering
Each webhook can filter which event types to process using allow and deny lists.
- Allow list empty, deny list empty — accept all events.
- Allow list non-empty — only accept events in the list.
- Deny list — always applied last, explicitly skips matching events.
Filtered events are logged with status filtered but not processed. They do
not count against rate limits.
When using a source profile, the UI shows the event catalog as checkboxes instead of requiring free-form text.
Session Modes
Each delivery creates a chat session. The session mode controls how sessions are organized.
| Mode | Behaviour |
|---|---|
per_delivery (default) | One new session per delivery. Best for debugging and clean history. |
per_entity | Group deliveries by entity key (e.g., all events for PR #567 in one session). Useful for maintaining context across an entity’s lifecycle. |
named_session | All deliveries go to one named session. Use sparingly — can become noisy. |
Entity Keys
In per_entity mode, the source profile extracts a grouping key from the
payload:
| Profile | Entity Key Format |
|---|---|
| GitHub | github:{repo}:pr:{number} or github:{repo}:issue:{number} |
| GitLab | gitlab:{project}:mr:{iid} or gitlab:{project}:issue:{iid} |
| Stripe | stripe:{subscription_id} or stripe:dispute:{charge_id} |
| Generic | None (falls back to per_delivery) |
Session Labels
Sessions are labeled for easy identification in the sidebar:
- per_delivery:
webhook:{public_id}:{delivery_id} - per_entity:
webhook:{public_id}:{entity_key} - named_session: configured key or
webhook:{public_id}
Agent Execution
Each webhook is bound to an agent preset. When a delivery is processed:
- The worker creates a session with the webhook’s session key.
- The configured agent is assigned to the session.
- A normalized message describing the event is injected.
chat.send_syncruns the agent turn.- The delivery record is updated with status, duration, and token counts.
Execution Overrides
Webhooks can override specific agent settings without changing the base preset:
- Model — use a different LLM for webhook processing.
- System prompt suffix — append extra instructions (e.g., “Focus on security issues” for a code review webhook).
- Tool policy — restrict which tools the agent can use.
Delivery Message Format
The agent receives a structured message with three layers:
Webhook delivery received.
Webhook: GitHub PR Hook (wh_xxxxx)
Source: github
Event: pull_request.opened
Delivery: abc-123-def
Received: 2026-04-07T12:34:56Z
---
GitHub event: pull_request.opened
Repository: moltis-org/moltis
PR #567: "Add webhook support"
Author: @penso
Branch: feature/webhooks → main
URL: https://github.com/moltis-org/moltis/pull/567
Draft: false
Description:
This PR adds generic webhook support...
Changed files: 42 (+1,203 / -156)
---
Full payload available via webhook_get_full_payload tool.
The full raw payload is stored on the delivery record and available to the
agent via the webhook_get_full_payload tool, keeping prompt token usage
manageable for large payloads.
Delivery Lifecycle
Each delivery goes through a status progression:
| Status | Description |
|---|---|
received | Persisted, not yet queued |
filtered | Event type not in allow list |
deduplicated | Duplicate delivery key |
rejected | Auth failure or policy violation |
queued | Waiting for worker |
processing | Agent running |
completed | Agent finished successfully |
failed | Agent errored |
Deduplication
Deliveries are deduplicated by a provider-specific key:
- GitHub:
X-GitHub-Deliveryheader - GitLab:
Idempotency-Keyheader (falls back to body hash) - Stripe:
$.idfield in body - Generic:
X-Delivery-IdorX-Request-Idheader (falls back to body SHA-256 hash)
Duplicate deliveries are logged with status deduplicated and return 200 OK.
Rate Limiting
Two levels of rate limiting protect against abuse:
| Level | Default | Description |
|---|---|---|
| Per-webhook | 60/minute | Configurable per webhook |
| Global | 300/minute | Across all webhooks |
Rate-limited requests receive 429 Too Many Requests. Filtered and
deduplicated events do not count against rate limits.
Security
- Public IDs are routing identifiers, not secrets. Authentication is handled by the configured auth mode.
- Secrets use constant-time comparison to prevent timing attacks.
- Request body size is limited (default: 1 MB, configurable per webhook).
- Auth headers are never logged. Only safe headers (event type, delivery ID, content type) are persisted.
- Webhook secrets and source API credentials are encrypted at rest when Vault is enabled.
Without Vault, webhook secrets and API tokens remain plaintext in the SQLite database. Enable Moltis Vault if these secrets are going to live on disk. Rotate secrets periodically.
Delivery Inspector
The web UI provides a delivery inspector for each webhook:
- Deliveries list with status, event type, timestamp, and duration.
- Per-delivery detail with normalized metadata, headers, body preview, and session link.
- Response actions (when using profiles with response tools) showing what the agent did.
- Click a delivery’s session link to open the full chat conversation.
Editing and Deleting
Editing
Click Edit on a webhook card to modify its settings. Changes take effect immediately for new deliveries. In-progress deliveries use the configuration that was active when they were received.
Disabling
The toggle on each webhook card pauses it — the endpoint returns 404 but
configuration and delivery history are preserved.
Deleting
Deleting a webhook permanently removes it and all delivery records. Chat sessions created by deliveries are not deleted — they persist independently as normal sessions.
Crash Recovery
On startup, Moltis scans for deliveries with status received or queued and
re-queues them for processing. Accepted deliveries are not silently dropped on
restart.
Testing Webhooks
Use Hoppscotch (free, open source, no signup) to test your webhooks. Set the method to POST, paste your webhook endpoint URL, add a JSON body, and set any required auth headers.
Alternatively, use the included test script:
./scripts/test-webhook.sh <webhook-url> --profile github --secret <your-secret>
Available profiles: generic, github, gitlab, stripe. Each sends a
realistic sample payload with the correct headers and signature.
Example: GitHub PR Reviewer
A complete example of setting up a webhook that reviews pull requests:
-
Create webhook in Settings → Webhooks:
-
Source: GitHub
-
Auth: GitHub HMAC-SHA256 (auto-selected)
-
Events: check
pull_request.openedandpull_request.synchronize -
Agent:
code-reviewer(or your default agent) -
Session mode: Per entity (groups all events for the same PR)
-
System prompt suffix:
You are reviewing a GitHub pull request. Analyze the PR description and changed files. Focus on correctness, security, and maintainability. Provide specific, actionable feedback.
-
-
Register in GitHub:
- Repo → Settings → Webhooks → Add webhook
- Payload URL: copy from Moltis
- Content type:
application/json - Secret: copy from Moltis
- Events: “Pull requests”
-
Test it: open a PR — a new session appears in Moltis with the agent’s review.
Example: Stripe Payment Handler
-
Create webhook in Settings → Webhooks:
-
Source: Stripe
-
Auth: Stripe Signature (auto-selected)
-
Events: check
checkout.session.completed,payment_intent.payment_failed -
Session mode: Per delivery
-
System prompt suffix:
Process this Stripe payment event. For successful payments, log the details and confirm fulfillment. For failures, summarize the issue and suggest next steps.
-
-
Register in Stripe:
- Dashboard → Developers → Webhooks → Add endpoint
- Endpoint URL: copy from Moltis
- Events: select the matching events
- Copy signing secret (
whsec_...) into Moltis
Metrics
| Metric | Type | Description |
|---|---|---|
webhooks_deliveries_total | Counter | Total deliveries by webhook, status, event type |
webhooks_deliveries_rejected_total | Counter | Rejected deliveries by reason |
webhooks_deliveries_filtered_total | Counter | Filtered deliveries |
webhooks_processing_duration_seconds | Histogram | Agent execution time |
webhooks_response_actions_total | Counter | Response actions by tool and status |
webhooks_rate_limited_total | Counter | Rate-limited requests |
webhooks_worker_queue_depth | Gauge | Pending deliveries in worker queue |
Deliver-Only Mode (Webhook Proxy)
By default, each webhook delivery triggers an agent run — the LLM processes the event, reasons about it, and optionally acts using tools. This costs tokens and takes seconds.
Deliver-only mode skips the agent entirely. The webhook payload is rendered through a template and forwarded directly to a channel. Zero LLM tokens, sub-second delivery.
This turns Moltis into a webhook proxy: external services POST events, and formatted messages appear in your Telegram, Discord, Slack, or any other configured channel.
When to Use Deliver-Only
- Monitoring alerts: Datadog/Grafana/Sentry → Discord
- Payment notifications: Stripe → Telegram
- CI/CD status: GitHub Actions → Slack
- Inter-service notifications: any HTTP POST → any channel
- High-volume events: where per-event LLM calls would be wasteful
Configuration
Set deliver_only: true and provide a prompt_template with {dot.notation}
variables, plus a deliver_to channel target.
Web UI: Toggle “Deliver only” in the webhook settings, fill in the template and target channel.
CLI:
moltis webhooks create \
--name deploy-status \
--source-profile generic \
--auth-mode static_header \
--deliver-only \
--prompt-template "Deploy {status}: {environment} ({commit_sha})" \
--deliver-to slack
Agent: The agent can also create deliver-only webhooks using the webhook tool.
How It Works
External Service POSTs event
│
▼
┌──────────────────────────────────┐
│ Ingress Handler │
│ verify → filter → dedup → 202 │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ Background Worker │
│ render template → deliver to │
│ channel → mark completed │
│ (no agent, no LLM tokens) │
└──────────────────────────────────┘
Template Rendering
Templates use {dot.notation} to interpolate values from the webhook JSON payload.
| Syntax | Resolves to |
|---|---|
{action} | Top-level field: payload["action"] |
{pull_request.title} | Nested: payload["pull_request"]["title"] |
{pull_request.user.login} | Deep nested: payload["pull_request"]["user"]["login"] |
{__raw__} | Full payload as indented JSON (truncated at 4000 chars) |
- Missing keys are left as literal
{key}in the output (no error). - Objects and arrays are JSON-serialized (truncated at 2000 chars).
- Strings and numbers are inserted directly.
Templates work in both prompt_template (the message body) and deliver_extra
values (channel-specific metadata like chat IDs).
Example: GitHub Issue → Telegram
moltis webhooks create \
--name github-issues \
--source-profile github \
--auth-mode github_hmac_sha256 \
--deliver-only \
--prompt-template "#{issue.number} {issue.title} ({action} by {sender.login})" \
--deliver-to telegram \
--events "issues.opened,issues.closed"
When someone opens issue #42 “Fix auth bug”, Telegram receives:
#42 Fix auth bug (opened by alice)
Example: Stripe Payment → Discord
moltis webhooks create \
--name stripe-notify \
--source-profile stripe \
--auth-mode stripe_webhook_signature \
--deliver-only \
--prompt-template "Payment {data.object.status}: {data.object.amount} cents ({data.object.currency})" \
--deliver-to discord \
--events "payment_intent.succeeded,payment_intent.payment_failed"
Comparison with Channels and Cron
| Channels | Webhooks | Cron | |
|---|---|---|---|
| Purpose | Human messaging | Machine event ingress | Scheduled tasks |
| Trigger | User sends message | External HTTP POST | Time-based schedule |
| Reply | Back to the channel | Via response tools (optional) | Optional channel delivery |
| Session | Per conversation | Per delivery / entity | Per job run |
| Auth | Platform account | Per-webhook (HMAC, token, etc.) | Internal only |
Webhooks are not channels. They do not support reply routing, streaming, or platform presence semantics. Use channels for human messaging and webhooks for machine event ingress.
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
Four agent tools manage personal skills by default:
| Tool | Description |
|---|---|
create_skill | Write a new SKILL.md to <data_dir>/skills/<name>/ |
update_skill | Overwrite an existing skill’s SKILL.md |
patch_skill | Apply surgical find/replace patches to an existing SKILL.md |
delete_skill | Remove a skill directory |
When skills.enable_agent_sidecar_files = true, a fifth tool becomes
available:
| Tool | Description |
|---|---|
write_skill_files | Write 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.
Before any built-in skill mutation runs, Moltis creates an automatic
checkpoint. Tool results include a checkpointId you can later restore with
checkpoint_restore.
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.
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, andSKILL.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..."
}
Patching a Skill
The patch_skill tool applies surgical find/replace operations without
rewriting the full body. This reduces hallucination risk and token cost when
fixing a few lines:
{
"name": "summarize-pr",
"patches": [
{ "find": "key changes", "replace": "key changes and risks" },
{ "find": "review notes", "replace": "review action items" }
],
"description": "Optional: update the frontmatter description too"
}
Patches are applied sequentially. If a find string is not found, the tool
returns an error and no changes are written.
Deleting a Skill
{
"name": "summarize-pr"
}
This removes the entire <data_dir>/skills/summarize-pr/ directory, including
any supplementary files written alongside SKILL.md.
Deleted skills can be restored from the returned checkpointId with
checkpoint_restore, as long as the checkpoint still exists.
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)
- Open moltis in Safari
- Tap the Share button (box with arrow)
- Scroll down and tap “Add to Home Screen”
- Tap “Add” to confirm
The app will appear on your home screen with the moltis icon.
Android (Chrome)
- Open moltis in Chrome
- You should see an install banner at the bottom - tap “Install”
- Or tap the three-dot menu and select “Install app” or “Add to Home Screen”
- 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
- Open the moltis app (must be installed as PWA on Safari/iOS)
- Go to Settings > Notifications
- Click Enable to subscribe to push notifications
- 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:
- VAPID Keys: On first run, the server generates a P-256 ECDSA key pair
- Subscription: The browser creates a push subscription using the server’s public key
- Registration: The subscription details are sent to the server and stored
- 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:
| Endpoint | Method | Description |
|---|---|---|
/api/push/vapid-key | GET | Get the VAPID public key for subscription |
/api/push/subscribe | POST | Register a push subscription |
/api/push/unsubscribe | POST | Remove a push subscription |
/api/push/status | GET | Get 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
| Feature | Chrome | Safari | Firefox | Edge |
|---|---|---|---|---|
| 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
- Check permissions: Ensure notifications are allowed in browser/OS settings
- Check subscription: Go to Settings > Notifications to see if your device is listed
- Check server logs: Look for
push:prefixed log messages for delivery status - 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
- Behind a proxy: Ensure your proxy forwards
X-Forwarded-FororX-Real-IPheaders
PWA Not Installing
- HTTPS required: PWAs require a secure connection (or localhost)
- Valid manifest: Ensure
/manifest.jsonloads correctly - Service worker: Check that
/sw.jsregisters without errors - Clear cache: Try clearing browser cache and reloading
Service Worker Issues
Clear the service worker registration:
- Open browser DevTools
- Go to Application > Service Workers
- Click “Unregister” on the moltis service worker
- 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:
- Add a small Rust crate that compiles as
staticlib. - Expose a narrow C ABI (
extern "C") surface. - Call that ABI from Swift via a bridging header/module map.
- Keep Swift responsible for presentation and user interaction.
Recommended POC Architecture
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_charand 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_unwindat 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):
- Define app targets in
apps/macos/project.yml. - Generate project with XcodeGen.
- Link
Generated/libmoltis_bridge.aand includeGenerated/moltis_bridge.h. - Use a Swift facade (
MoltisClient) to own pointer and lifetime rules. - 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.
Recommended packaging for Xcode
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:
- Blocking/synchronous POC call (prove bridge correctness).
- Background
Taskwrapping on Swift side. - 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
- Add
swift-bridgecrate exposing one health function (moltis_version). - Add one end-to-end chat method (
moltis_chat_json). - Build and link from minimal SwiftUI app.
- Validate memory lifecycle with repeated calls.
- 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)
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`
Build and Link Pipeline
`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:
| Kind | Trigger |
|---|---|
created | sessions.resolve (new), sessions.fork, bridge moltis_create_session |
patched | sessions.patch |
deleted | sessions.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:
- The path set in the
OPENCLAW_HOMEenvironment variable ~/.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
| Category | Source | Destination | Notes |
|---|---|---|---|
| Identity | openclaw.json agent name, theme, and timezone | moltis.toml identity section | Preserves existing Moltis identity if already configured |
| Providers | Agent auth-profiles (API keys) | ~/.moltis/provider_keys.json | Maps OpenClaw provider names to Moltis equivalents (e.g., google becomes gemini) |
| Skills | skills/ directories with SKILL.md | ~/.moltis/skills/ | Copies entire skill directories; skips duplicates |
| Memory | MEMORY.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 |
| Channels | Telegram and Discord bot configuration in openclaw.json | moltis.toml channels section | Supports both flat and multi-account Telegram configs |
| Sessions | JSONL 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 Servers | mcp-servers.json | ~/.moltis/mcp-servers.json | Merges with existing servers; skips duplicates by name |
| Workspace Files | SOUL.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 |
| Agent Presets | Non-default agents in agents.list | moltis.toml [agents.presets.*] | Creates spawn_agent presets with name, theme, and model. Existing presets are preserved |
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 themeUSER.md— user profile (name, preferences, context the agent should know about you)TOOLS.md— tool usage guidelines and constraintsAGENTS.md— global workspace rules injected into every conversationHEARTBEAT.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
mainagent - Non-default agents are created as separate agent personas with their name, theme, and emoji
- Non-default agents become
spawn_agentpresets inmoltis.toml, so the main agent can delegate tasks to them via thespawn_agenttool. Each preset inherits the agent’s name, theme, and model override. See Agent Presets for details - 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
- Go to Settings (gear icon)
- Select OpenClaw Import from the sidebar
- Click Scan to see what data is available
- Check the categories you want to import
- 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:
| Method | Description |
|---|---|
openclaw.detect | Returns detection and scan results (what data is available) |
openclaw.scan | Alias for openclaw.detect |
openclaw.import | Performs 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
idandcreated_atare preserved - The
versionfield 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
.jsonlfile 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 duplicatingMEMORY.mdcontent. 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 Name | Moltis Name |
|---|---|
google | gemini |
anthropic | anthropic |
openai | openai |
openrouter | openrouter |
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) │
└──────────────────┘
- The gateway runs on your primary machine (or a server).
- On the gateway, briefly enable node pairing.
- On the remote machine, run
moltis node addto register it with the gateway. - The gateway authenticates the node using Ed25519 challenge-response (TOFU model).
- 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
The node generates an Ed25519 keypair on first run and presents its public key to the gateway. The operator approves the key fingerprint (TOFU model, same as SSH).
New pairing requests are disabled by default to prevent unauthenticated connection spam. Open the pairing window only while adding a node.
-
On the gateway, enable pairing:
moltis node pairing enable -
On the remote machine:
moltis node add --host ws://your-gateway:9090/ws --name "Build Server"The node prints its fingerprint and waits for approval.
-
Approve the pairing:
- Web UI: Open Settings → Nodes → Pending tab, verify the fingerprint, click Approve.
- CLI (headless gateways):
moltis node pending # list pending requests moltis node approve <request-id> # approve by ID
-
The gateway sends a challenge nonce, the node signs it, and authentication completes. The public key is pinned to this device (TOFU).
-
Disable new pairing requests:
moltis node pairing disable
Adding a Node
On the remote machine, register it as a node:
moltis node add --host ws://your-gateway:9090/ws --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:
| Platform | Service file |
|---|---|
| macOS | ~/Library/LaunchAgents/org.moltis.node.plist |
| Linux | ~/.config/systemd/user/moltis-node.service |
Options:
| Flag | Description | Default |
|---|---|---|
--host | Gateway WebSocket URL | (required) |
--name | Display name shown in the UI | none |
--node-id | Custom node identifier | random UUID |
--working-dir | Working directory for commands | $HOME |
--timeout | Max command timeout in seconds | 300 |
--foreground | Run in the terminal instead of installing a service | off |
You can also set MOLTIS_GATEWAY_URL as an environment variable instead of
passing --host.
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 --name "Build Server" --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.
Node Fingerprint
moltis node fingerprint
Prints the Ed25519 public key fingerprint (SHA256:<base64>) for this node.
Use this to verify the key shown in the gateway UI during pairing.
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
execcommands to it. Select “Local” to revert to local execution. Whentools.exec.host = "ssh", Moltis also shows either the legacy configured SSH target fromtools.exec.ssh_targetor any managed SSH targets you created in Settings → SSH as first-class execution options. - Agent tools: The agent can call
nodes_list,nodes_describe, andnodes_selectto 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.
If you configure tools.exec.host = "ssh", the Nodes page also shows SSH
targets even though they are not WebSocket-paired nodes. This makes the active
remote execution route visible instead of hiding it in config. The UI renders
these separately from paired nodes so it is clear that SSH targets do not
report telemetry or presence.
Managed SSH targets now support:
- named labels, so session routing is readable instead of
deploy@box - a default target, used when a chat session does not pin a specific route
- connectivity tests from the web UI
- either System OpenSSH auth or a managed deploy key
- optional host-key pinning via a pasted
known_hostsline - one-click scan, refresh, and clear actions for saved host pins in Settings
- passphrase-protected private-key imports during setup
The Nodes page also includes a Remote Exec Status panel that acts like a lightweight doctor:
- shows whether Moltis is currently configured for
local,node, orssh - reports paired-node inventory and managed SSH inventory
- flags obvious misconfigurations, such as
tools.exec.host = "ssh"with no active target or a managed key that cannot be decrypted because the vault is locked - warns when the active managed SSH route is not host-pinned
- lets you pin, refresh, or clear the active managed route directly from the doctor panel
- lets you test the active SSH route without leaving the page
The CLI now mirrors the basic setup view with moltis doctor, including:
- active remote-exec backend (
local,node, orssh) - SSH client discovery and version
- managed SSH key / target / host-pin inventory
- warnings for legacy
tools.exec.ssh_targetconfig and unpinned active routes
CLI Reference
| Command | Description |
|---|---|
moltis node add --host <url> | Join this machine to a gateway as a node |
moltis node add ... --foreground | Run in the terminal instead of installing a service |
moltis node fingerprint | Print this node’s Ed25519 fingerprint |
moltis node list | List all connected nodes |
moltis node pairing status | Show whether new node pairing requests are accepted |
moltis node pairing enable | Enable new node pairing requests |
moltis node pairing disable | Disable new node pairing requests |
moltis node pending | List pending pairing requests |
moltis node approve <id> | Approve a pending pairing request |
moltis node reject <id> | Reject a pending pairing request |
moltis node remove | Disconnect this machine and remove the service |
moltis node status | Show connection info and service status |
moltis node logs | Print log file path |
Security
Node Identity (TOFU)
Nodes authenticate using Ed25519 challenge-response, following the same Trust On First Use model as SSH:
- First connection: The node presents its public key. The operator verifies the fingerprint and approves the pairing.
- Subsequent connections: The gateway sends a random 32-byte nonce. The node signs it with its private key. The gateway verifies the signature against the pinned public key.
- Key pinning: Once a public key is approved for a device, the gateway rejects any future connection from that device with a different key. This prevents impersonation.
- Re-keying: If a node legitimately needs a new key (e.g., after a disk wipe), revoke the old device from the Nodes page, then re-pair.
The private key (~/.moltis/node_key) is stored with mode 0600. The gateway
only stores the public key. No shared secret crosses the wire.
General
- Environment filtering: When the gateway forwards commands to a node, only
safe environment variables are forwarded (
TERM,LANG,LC_*). Secrets like API keys,DYLD_*, andLD_PRELOADare always blocked. - Key revocation: Revoke 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:
| Platform | Service file | Init system |
|---|---|---|
| macOS | ~/Library/LaunchAgents/org.moltis.gateway.plist | launchd (user agent) |
| Linux | ~/.config/systemd/user/moltis.service | systemd (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
| Command | Description |
|---|---|
moltis service install | Install and start the service |
moltis service uninstall | Stop and remove the service |
moltis service status | Show service status and PID |
moltis service stop | Stop the service |
moltis service restart | Restart the service |
moltis service logs | Print 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 service | moltis node | |
|---|---|---|
| What it runs | The gateway server | A node client |
Needs --host/--token | No | Yes |
| Config source | ~/.moltis/moltis.toml | ~/.moltis/node.json |
| launchd label | org.moltis.gateway | org.moltis.node |
| systemd unit | moltis.service | moltis-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:
| # | Condition | Result | Auth method |
|---|---|---|---|
| 1 | auth_disabled is true | Allowed | Loopback |
| 2 | Setup not complete + local connection | Allowed | Loopback |
| 3 | Setup not complete + remote connection | SetupRequired | — |
| 4 | Valid session cookie | Allowed | Password |
| 5 | Valid Bearer API key | Allowed | ApiKey |
| 6 | None of the above | Unauthorized | — |
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:
| Tier | Condition | Behaviour |
|---|---|---|
| 1 — Full auth | Password or passkey is configured | Auth always required (any IP) |
| 2 — Local dev | No credentials + direct local connection | Full access (dev convenience) |
| 3 — Remote setup | No credentials + remote/proxied connection | Setup flow only |
Practical scenarios
| Scenario | No credentials | Credentials configured |
|---|---|---|
Local browser on localhost:18789 | Full access (Tier 2) | Login required (Tier 1) |
Local CLI/wscat on localhost:18789 | Full access (Tier 2) | Login required (Tier 1) |
| Internet via reverse proxy | Onboarding only (Tier 3) | Login required (Tier 1) |
MOLTIS_BEHIND_PROXY=true, any source | Onboarding only (Tier 3) | Login required (Tier 1) |
How “local” is determined
A connection is classified as local only when all four checks pass:
MOLTIS_BEHIND_PROXYenv var is not set- No proxy headers present (
X-Forwarded-For,X-Real-IP,CF-Connecting-IP,Forwarded) - The
Hostheader resolves to a loopback address (or is absent) - 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 12 characters
- Verified against
auth_passwordtable
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
passkeystable as serialized WebAuthn credential data - Multiple passkeys can be registered per instance
- Passkeys are bound to the hostname you visit. If you add a new public host later, for example a Tailscale name or ngrok URL, you may need to log in with a password once and register a new passkey for that host
Session cookie
- HTTP-only
moltis_sessioncookie,SameSite=Strict - Created on successful login (password or passkey)
- 30-day expiry
- Validated against
auth_sessionstable - When the request arrives on a
.localhostsubdomain (e.g.moltis.localhost), the cookie includesDomain=localhostso 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 theconnecthandshakeauth.api_keyfield (WebSocket) - Must have at least one scope — keys without scopes are denied
API Key Scopes
| Scope | Permissions |
|---|---|
operator.read | View status, list jobs, read history |
operator.write | Send messages, create jobs, modify configuration |
operator.admin | All permissions (superset of all scopes) |
operator.approvals | Handle command approval requests |
operator.pairing | Manage device/node pairing |
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:
| Path | Purpose |
|---|---|
/health | Health check endpoint |
/api/auth/* | Auth status, login, setup, passkey flows |
/assets/* | Static assets (JS, CSS, images) |
/auth/callback | OAuth callback |
/manifest.json | PWA manifest |
/sw.js | Service worker |
/ws | Node 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:
| Scope | Default |
|---|---|
POST /api/auth/login | 5 requests per 60 seconds |
Other /api/auth/* | 120 requests per 60 seconds |
Other /api/* | 180 requests per 60 seconds |
/share/* | 90 requests per 60 seconds |
/ws/* | 30 requests per 60 seconds |
When a limit is exceeded:
- API endpoints return
429 Too Many Requests - Responses include
Retry-Afterheader - JSON API responses also include
retry_after_seconds
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):
- A random 6-digit setup code is printed to the terminal
- Local connections get full access (Tier 2) — no setup code needed
- Remote connections are redirected to
/onboarding(Tier 3) — the setup code is required to set a password or register a passkey - After setting up, the setup code is cleared and a session is created
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:
- Deletes all passwords, passkeys, sessions, and API keys
- Sets
auth_disabled = truein config - Generates a new setup code for re-setup
- 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-Foror similar headers, which automatically classify connections as remote - Bare proxies (no forwarding headers) can appear local — set
MOLTIS_BEHIND_PROXY=trueto force all connections to be treated as remote - The proxy must preserve the browser origin host for WebSocket CSWSH
protection (forward
Host, orX-Forwarded-Hostwhen rewritingHost) - 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
| Operation | Endpoint | Auth required |
|---|---|---|
| Check status | GET /api/auth/status | No |
| Set password (setup) | POST /api/auth/setup | Setup code |
| Login with password | POST /api/auth/login | No (validates password) |
| Login with passkey | POST /api/auth/passkey/auth/* | No (validates passkey) |
| Logout | POST /api/auth/logout | Session |
| Change password | POST /api/auth/password/change | Session |
| List API keys | GET /api/auth/api-keys | Session |
| Create API key | POST /api/auth/api-keys | Session |
| Revoke API key | DELETE /api/auth/api-keys/{id} | Session |
| Register passkey | POST /api/auth/passkey/register/* | Session |
| Remove passkey | DELETE /api/auth/passkeys/{id} | Session |
| Remove all auth | POST /api/auth/reset | Session |
| Vault status | GET /api/auth/vault/status | No |
| Vault unlock | POST /api/auth/vault/unlock | No |
| Vault recovery | POST /api/auth/vault/recovery | No |
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_metadatatable. - 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:
| State | Meaning |
|---|---|
| Uninitialized | No vault metadata exists. The vault hasn’t been set up yet. |
| Sealed | Metadata exists but the DEK is not in memory. Data cannot be read or written. |
| Unsealed | The DEK is in memory. Encryption and decryption are active. |
set password
Uninitialized ──────────────► Unsealed
│ ▲
restart │ │ login / unlock
▼ │
Sealed
After a server restart, the vault is normally in the Sealed state until the user logs in (which provides the password needed to derive the KEK and unwrap the DEK). For unattended deployments, Moltis can also auto-unseal at startup from an explicitly configured recovery key.
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):
vault.initialize(password)generates a random DEK and recovery key- The DEK is wrapped with a KEK derived from the password
- A second copy of the DEK is wrapped with the recovery KEK
- The response includes a
recovery_keyfield (shown once, then not returned again) - Any existing plaintext env vars are migrated to encrypted
Login (POST /api/auth/login)
After successful password verification:
vault.unseal(password)derives the KEK and unwraps the DEK into memory- 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:
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 unless unattended auto-unseal is configured. All encrypted data is unreadable until the user logs in or the configured auto-unseal recovery key is accepted.
Unattended auto-unseal
For servers that need to recover fully after a reboot or /update, set one
of these environment variables:
| Variable | Purpose |
|---|---|
MOLTIS_VAULT_AUTO_UNSEAL_KEY_FILE | Path to a file containing the vault recovery key. Preferred for Docker/Kubernetes secrets. |
MOLTIS_VAULT_AUTO_UNSEAL_KEY | Vault recovery key value directly in the process environment. |
If both are set, Moltis logs a warning and uses
MOLTIS_VAULT_AUTO_UNSEAL_KEY_FILE.
Auto-unseal runs immediately after the vault is opened from SQLite and before Moltis loads persisted environment variables, MCP server configuration, cron runtime environment, and stored channel accounts. That means encrypted env vars and channel credentials are available during normal startup rather than only after a manual browser unlock.
Auto-unseal trades manual recovery for unattended availability. If an attacker
can read both the Moltis database and the configured recovery-key env/file, they
can decrypt vault-protected secrets. Prefer a Docker/Kubernetes secret file over
a plain env var, restrict file permissions, and keep the recovery key out of
moltis.toml.
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.
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:
| Data | Storage | AAD |
|---|---|---|
Environment variables (env_variables table) | SQLite | env:{key} |
Managed SSH private keys (ssh_keys table) | SQLite | ssh-key:{name} |
Provider API keys (provider_keys.json.enc) | File | provider_keys |
The encrypted column in env_variables and ssh_keys tracks whether each
row is encrypted (1) or plaintext (0). When the vault is unsealed, new env vars
and managed SSH private keys are written encrypted. Imported passphrase-protected
SSH keys are decrypted during import and then stored under the vault-managed
key hierarchy. When sealed or
uninitialized, they are written as plaintext.
On the first successful vault unseal after enabling the feature, Moltis also
migrates any previously stored plaintext env vars and managed SSH private keys
to encrypted storage in-place. Provider API keys (provider_keys.json) are
encrypted to a .enc copy alongside the original; the plaintext file is kept
for backward compatibility until all consumers use the vault-aware read path.
Voice provider API keys are now stored in provider_keys.json (same as LLM
keys), not in moltis.toml.
TokenStore (OAuth tokens in credentials.json) is currently sync/file-based
and cannot easily call async vault methods. Encryption for this store is
planned after an async refactor.
Vault Guard Middleware
When the vault is in the Sealed state, a middleware layer blocks
vault-protected API requests with 423 Locked:
{"error": "vault is sealed", "status": "sealed"}
This prevents the application from serving unreadable encrypted data while still allowing access to session history and bootstrap payloads that are not yet stored in the vault.
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/bootstrap— UI bootstrap payload/api/sessions*— session history and media endpoints/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):
| Method | Path | Purpose |
|---|---|---|
GET | /api/auth/vault/status | Returns {"status": "uninitialized"|"sealed"|"unsealed"|"disabled"} |
POST | /api/auth/vault/unlock | Unseal with password: {"password": "..."} |
POST | /api/auth/vault/recovery | Unseal with recovery key: {"recovery_key": "..."} |
Error responses:
| Status | Meaning |
|---|---|
200 | Success |
423 Locked | Bad password or recovery key |
404 | Vault not available (feature disabled) |
500 | Internal 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.
The chat/session UI remains visible while sealed because chat history is not yet encrypted by the vault.
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 withselect-allfor 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 state | Badge | What it means |
|---|---|---|
| Unsealed | Green (“Unlocked”) | Your API keys and secrets are encrypted in the database. Everything is working. |
| Sealed | Amber (“Locked”) | Log in or unlock below to access your encrypted keys. |
| Uninitialized | Gray (“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:
| Badge | Style | Meaning |
|---|---|---|
| Encrypted | Green (.provider-item-badge.configured) | Value is encrypted at rest by the vault |
| Plaintext | Gray (.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
| Parameter | Value |
|---|---|
| AEAD cipher | XChaCha20-Poly1305 (192-bit nonce, 256-bit key) |
| KDF | Argon2id |
| Argon2id memory | 64 MiB |
| Argon2id iterations | 3 |
| Argon2id parallelism | 1 |
| DEK size | 256 bits |
| Nonce generation | Random per encryption (24 bytes) |
| AAD | Context string per data type (e.g. env:MY_KEY) |
| Key wrapping | XChaCha20-Poly1305 (KEK encrypts DEK) |
| Recovery key | 128-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:
- Human-in-the-loop approval for dangerous commands
- Sandbox isolation for command execution
- Channel authorization for external integrations
- Rate limiting to prevent resource abuse
- 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:
- The command is analyzed against approval policies
- If approval is required, the user sees a prompt in the UI. Channel-backed sessions also receive a notification in the originating channel so the run does not stall silently.
- The user can approve, deny, or modify the command
- Only approved commands execute
For channel-backed sessions, operators can also use /approvals to list the
pending requests for the current session, then /approve N or /deny N
directly from Telegram or WhatsApp.
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 createdcron.job.updated- An existing job was modifiedcron.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:
| Scope | Permissions |
|---|---|
operator.read | View status, list jobs, read history |
operator.write | Send messages, create jobs, modify configuration |
operator.admin | All permissions (includes all other scopes) |
operator.approvals | Handle command approval requests |
operator.pairing | Manage 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
- Enter a label describing the key’s purpose
- Select the required scopes
- Click “Generate key”
- 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 Case | Recommended Scopes |
|---|---|
| Read-only monitoring | operator.read |
| Automated workflows | operator.read, operator.write |
| Approval handling | operator.read, operator.approvals |
| Full automation | operator.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 vault-protected API
requests with 423 Locked. Session history and bootstrap endpoints remain
available because those payloads are not yet encrypted at rest.
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)
| Tier | Condition | Behaviour |
|---|---|---|
| 1 | Password/passkey is configured | Auth always required (any IP) |
| 2 | No credentials + direct local connection | Full access (dev convenience) |
| 3 | No credentials + remote/proxied connection | Onboarding only (setup code required) |
Node Identity (Ed25519 TOFU)
Remote nodes authenticate using Ed25519 challenge-response, following the same Trust On First Use (TOFU) model as SSH:
- First connection: The operator opens the pairing window from the Nodes UI
or with
moltis node pairing enable. The node generates an Ed25519 keypair and presents its public key to the gateway. The operator verifies the fingerprint and approves the pairing (via the web UI ormoltis node approve). - Subsequent connections: The gateway sends a random 32-byte nonce. The node signs it with its private key. The gateway verifies the signature against the stored public key. No shared secret crosses the wire.
- Key pinning: Once approved, the public key is pinned to the device ID. If
the same device reconnects with a different key, the connection is rejected
and a
node.security.key-mismatchalert is broadcast to operators. - Revocation: Revoked keys are kept in the database so they cannot be re-paired without explicit operator action.
The private key (~/.moltis/node_key) is stored with mode 0600 and never
leaves the node. The gateway stores only public keys.
Pairing is disabled by default. Keep it disabled except while onboarding a new
node, then close the window with moltis node pairing disable.
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
| Scope | Default |
|---|---|
POST /api/auth/login | 5 requests per 60 seconds |
Other /api/auth/* | 120 requests per 60 seconds |
Other /api/* | 180 requests per 60 seconds |
/ws/chat upgrade | 30 requests per 60 seconds |
/ws upgrade | 30 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
-
Set
MOLTIS_BEHIND_PROXY=trueif your proxy does not add forwarding headers (safest option — eliminates any ambiguity). -
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(). -
WebSocket proxying must preserve browser origin host info (
Host, orX-Forwarded-HostifHostis rewritten). Moltis validates same-origin on WebSocket upgrades to prevent cross-site WebSocket hijacking (CSWSH). -
TLS termination should happen at the proxy. Run Moltis with
--no-tls(orMOLTIS_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. -
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=trueto 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 $http_host;
proxy_set_header X-Forwarded-Host $http_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 $http_host;
proxy_set_header X-Forwarded-Host $http_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";
Why $http_host instead of $host? Nginx’s $host strips the port,
while $http_host preserves it. Moltis validates that the WebSocket Origin
header matches the Host header (including port). On non-standard ports
(e.g., :444 instead of :443), using $host causes a mismatch and
WebSocket connections are rejected with “cross-origin WebSocket upgrade”.
Upstream scheme guidance:
- Edge TLS termination (most setups): proxy to
http://<moltis-host>:13131with Moltis started using--no-tls - HTTPS upstream / TLS passthrough: proxy to
https://<moltis-host>:13131and setMOLTIS_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
Hostand does not preserve browser host context, passkey routes can fail with “no passkey config for this hostname”.
For stable proxy deployments, set your public URL in moltis.toml:
[server]
external_url = "https://chat.example.com"
Moltis derives the WebAuthn RP ID (domain) and origin from this URL
automatically. The MOLTIS_EXTERNAL_URL environment variable takes
precedence over the config field, which is useful for container
deployments:
MOLTIS_BEHIND_PROXY=true
MOLTIS_NO_TLS=true
MOLTIS_EXTERNAL_URL=https://chat.example.com
For fine-grained control (e.g. when the RP ID differs from the URL host),
the existing env vars still work and take precedence after
external_url:
MOLTIS_WEBAUTHN_RP_ID=chat.example.com
MOLTIS_WEBAUTHN_ORIGIN=https://chat.example.com
Migration guidance when changing host/domain:
- Keep password login enabled during migration.
- Deploy with the new
server.external_url(or env var equivalent). - Ask users to register a new passkey on the new host.
- 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.
Release Signing and Verification
All release artifacts are signed with three independent methods:
- GitHub artifact attestations
(automated in CI) — cryptographic provenance records tied to the repository,
workflow, and commit SHA; provides SLSA v1.0 Build Level 2 guarantees;
verifiable with
gh attestation verify - Sigstore keyless signing (automated in CI) — proves the artifact was
built by the
moltis-org/moltisGitHub Actions pipeline; recorded in Sigstore’s Rekor transparency log - GPG signing (maintainer’s YubiKey hardware key) — proves a specific maintainer authorized the release
Checksums (SHA-256 and SHA-512) are generated for every artifact.
See Release Verification for detailed verification instructions, artifact file extensions, and maintainer signing workflow.
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 disktrusted- you explicitly marked the skill as reviewedenabled- skill is active for agent use
You cannot enable untrusted skills.
Portable bundle imports add one more step:
quarantined- imported from a portable bundle and blocked from enable until explicitly cleared
Imported bundles keep provenance metadata (original source, commit SHA when available, bundle path, export time) so you can review where they came from before clearing quarantine.
The Skills page exposes these bundle flows directly:
- import a
.tar.gzbundle from disk - export an installed repo back to a portable bundle
- clear quarantine after reviewing provenance and contents
Provenance Pinning
Moltis records a pinned commit_sha for installed repos:
- via
git rev-parse HEADafter 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=truerequired - 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.
Recommended Production Policy
- Keep sandbox enabled (
tools.exec.sandbox.mode = "all"). - Keep approval mode at least
on-miss. - Review SKILL.md and linked scripts before trust.
- Prefer pinned, known repos over ad-hoc installs.
- Monitor
security-audit.jsonlfor unusual events. - Keep imported bundles quarantined until you review their contents locally.
Release Verification
Moltis releases use multiple signing layers to provide strong supply chain guarantees:
| Method | Proves | Verification |
|---|---|---|
| GitHub Artifact Attestations (CI-generated) | Artifact was built by this repo’s GitHub Actions workflow | gh attestation verify |
| Sigstore (keyless, CI-generated) | Artifact was built by GitHub Actions from this repo | cosign verify-blob |
| GPG (YubiKey-resident key, maintainer-signed) | A specific maintainer authorized the release | gpg --verify |
| SHA256/SHA512 checksums | File integrity (no corruption/tampering in transit) | sha256sum --check |
All attestations are publicly visible on the repository attestations page.
Quick Verification
The easiest way to verify a release is with the included script:
# Verify all artifacts for a release (fetches GPG key automatically)
./scripts/verify-release.sh --version VERSION
# Also check SHA256 checksums
./scripts/verify-release.sh --checksums --version VERSION
# Verify specific local files
./scripts/verify-release.sh moltis-VERSION-x86_64-unknown-linux-gnu.tar.gz
GitHub Artifact Attestations
GitHub artifact attestations provide cryptographic proof that release artifacts were built inside this repository’s GitHub Actions workflow. Verification uses the GitHub CLI:
# Verify a downloaded binary
gh attestation verify moltis-VERSION-x86_64-unknown-linux-gnu.tar.gz \
-R moltis-org/moltis
# Verify a Docker image
gh attestation verify oci://ghcr.io/moltis-org/moltis:VERSION \
-R moltis-org/moltis
# Verify an SBOM
gh attestation verify moltis-sbom.spdx.json \
-R moltis-org/moltis
Browse all attestations at https://github.com/moltis-org/moltis/attestations.
Manual Verification
1. Verify checksums
# Download the artifact and its checksum
curl -LO https://github.com/moltis-org/moltis/releases/download/VERSION/moltis-VERSION-x86_64-unknown-linux-gnu.tar.gz
curl -LO https://github.com/moltis-org/moltis/releases/download/VERSION/moltis-VERSION-x86_64-unknown-linux-gnu.tar.gz.sha256
sha256sum --check moltis-VERSION-x86_64-unknown-linux-gnu.tar.gz.sha256
2. Verify GPG signature
The maintainer’s GPG key fingerprint is:
3103 20A8 CC1C 5BA8 6AD0 9040 C045 1BAD F764 9BBF
# Import the maintainer's public key (one-time)
curl -fsSL https://pen.so/gpg.asc | gpg --import
# Confirm the fingerprint matches
gpg --fingerprint F7649BBF
# Download the detached signature
curl -LO https://github.com/moltis-org/moltis/releases/download/VERSION/moltis-VERSION-x86_64-unknown-linux-gnu.tar.gz.asc
# Verify
gpg --verify \
moltis-VERSION-x86_64-unknown-linux-gnu.tar.gz.asc \
moltis-VERSION-x86_64-unknown-linux-gnu.tar.gz
You should see Good signature from ... with the maintainer’s identity.
3. Verify Sigstore signature
# Install cosign: https://docs.sigstore.dev/cosign/system_config/installation/
# Download signature and certificate
curl -LO https://github.com/moltis-org/moltis/releases/download/VERSION/moltis-VERSION-x86_64-unknown-linux-gnu.tar.gz.sig
curl -LO https://github.com/moltis-org/moltis/releases/download/VERSION/moltis-VERSION-x86_64-unknown-linux-gnu.tar.gz.crt
cosign verify-blob \
--signature moltis-VERSION-x86_64-unknown-linux-gnu.tar.gz.sig \
--certificate moltis-VERSION-x86_64-unknown-linux-gnu.tar.gz.crt \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp 'https://github\.com/moltis-org/moltis/' \
moltis-VERSION-x86_64-unknown-linux-gnu.tar.gz
4. Verify Docker images
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp 'https://github\.com/moltis-org/moltis/' \
ghcr.io/moltis-org/moltis:VERSION
What Each Layer Proves
Checksums detect download corruption or CDN tampering. They do not prove who created the file.
GitHub artifact attestations create unfalsifiable provenance records tied
to the repository, workflow, commit SHA, and triggering event. They are stored
in GitHub’s attestation ledger and verifiable with gh attestation verify.
This provides SLSA v1.0 Build Level 2 guarantees.
Sigstore signatures prove the artifact was built inside the
moltis-org/moltis GitHub Actions workflow using OIDC-based keyless signing.
This guards against a compromised maintainer laptop — even if someone steals
credentials, they cannot reproduce a valid Sigstore certificate from the CI
environment. Signatures are recorded in Sigstore’s Rekor transparency log.
GPG signatures prove the release was reviewed and authorized by a specific maintainer holding the corresponding private key. Because the key lives on a YubiKey hardware token, compromise requires physical access to the device plus the PIN.
Together, these layers create a strong chain: GitHub attestations and Sigstore prove where the artifact was built (CI), and GPG proves who authorized it (maintainer with hardware key).
Release Artifacts Per File
Each release artifact (.deb, .rpm, .tar.gz, .exe, etc.) may have:
| Extension | Source | Description |
|---|---|---|
.sha256 | CI | SHA-256 checksum |
.sha512 | CI | SHA-512 checksum |
.sig | CI | Sigstore detached signature |
.crt | CI | Sigstore signing certificate |
.asc | Maintainer | GPG detached armored signature |
For Maintainers: Signing a Release
After CI publishes a release, sign the artifacts locally using your YubiKey-resident GPG key:
# Sign the latest release (prompts for YubiKey PIN/touch)
./scripts/gpg-sign-release.sh
# Sign a specific version
./scripts/gpg-sign-release.sh 20260331.01
# Use a specific key
./scripts/gpg-sign-release.sh --key 0xABCD1234 20260331.01
# Dry run (sign locally, don't upload)
./scripts/gpg-sign-release.sh --dry-run 20260331.01
The script:
- Downloads all release packages from GitHub
- Verifies their SHA256 checksums against CI-generated values
- Creates
.ascdetached GPG signatures for each artifact - Uploads the
.ascfiles to the GitHub release
Prerequisites
- GitHub CLI (
gh) authenticated - GPG with your signing key available (YubiKey inserted)
- The
GPG_KEY_IDenvironment variable or--keyflag if you have multiple secret keys
Publish Your Public Key
Upload your public GPG key so users can verify signatures:
# Export and upload to a keyserver
gpg --export --armor YOUR_KEY_ID | \
curl -T - https://keys.openpgp.org
# Also add to your GitHub profile:
# Settings > SSH and GPG keys > New GPG key
gpg --export --armor YOUR_KEY_ID
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_TOKEN, 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
| Port | Purpose |
|---|---|
| 13131 | Gateway (HTTPS) — web UI, API, WebSocket |
| 13132 | HTTP — CA certificate download for TLS trust |
| 1455 | OAuth 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).
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:
| Path | Contents |
|---|---|
/home/moltis/.config/moltis | Configuration files: moltis.toml, credentials.json, mcp-servers.json |
/home/moltis/.moltis | Runtime data: databases, sessions, memory files, logs |
/home/moltis/.npm | npm cache (used by stdio-based MCP servers) |
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
For unattended recovery after host reboots or in-place /update, store the
vault recovery key as a Docker secret and point Moltis at the mounted file:
services:
moltis:
image: ghcr.io/moltis-org/moltis:latest
environment:
MOLTIS_VAULT_AUTO_UNSEAL_KEY_FILE: /run/secrets/moltis_vault_recovery_key
secrets:
- moltis_vault_recovery_key
secrets:
moltis_vault_recovery_key:
file: ./moltis-vault-recovery-key
This lets encrypted environment variables and channel credentials load during startup. Treat the secret file as sensitive as the vault recovery key itself.
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_TOKENin the Coolify UI before first deploy. - Set
SERVICE_FQDN_MOLTIS_13131to your app domain. - Keep Moltis in
--no-tlsmode 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.sockmounted 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
| Variable | Description |
|---|---|
MOLTIS_CONFIG_DIR | Override config directory (default: ~/.config/moltis) |
MOLTIS_DATA_DIR | Override data directory (default: ~/.moltis) |
MOLTIS_NO_TLS | Disable TLS (serve plain HTTP) — equivalent to --no-tls |
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_TOKEN: "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.
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):
- Host /
docker -eenvironment variables - Config file
[env]section - 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:
| Setting | Value | Purpose |
|---|---|---|
--no-tls or MOLTIS_NO_TLS=true | Disable TLS | Provider handles HTTPS |
--bind 0.0.0.0 | Bind all interfaces | Required for container networking |
--port <PORT> | Listen port | Must match provider’s expected internal port |
MOLTIS_CONFIG_DIR=/data/config | Config directory | Persist moltis.toml, credentials |
MOLTIS_DATA_DIR=/data | Data directory | Persist databases, sessions, memory |
MOLTIS_DEPLOY_PLATFORM | Deploy platform | Hides local-only providers (see below) |
MOLTIS_PASSWORD | Initial password | Set auth password via environment variable |
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.
Sandbox on cloud deploys: Most cloud providers do not support
Docker-in-Docker. To enable sandboxed command execution, configure a
remote sandbox backend — set VERCEL_TOKEN for Vercel
Firecracker microVMs, or DAYTONA_API_KEY for Daytona cloud sandboxes
(including self-hosted). Moltis auto-detects these when no local Docker is
available.
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.
ngrok
Moltis can also expose a public HTTPS endpoint through ngrok without running
an external ngrok binary. This is useful when you want a public callback
URL or temporary team access from a local machine or private host.
Configuration:
[ngrok]
enabled = true
authtoken = "${NGROK_AUTHTOKEN}" # or set NGROK_AUTHTOKEN in the environment
# domain = "team-gateway.ngrok.app" # optional reserved/static domain
Notes:
- The tunnel is feature-gated. Standard CLI builds include it by default, but
custom minimal builds can opt out of the
ngrokfeature. - In the web UI, configure Tailscale and ngrok from Settings -> Remote Access.
- ngrok forwards into a loopback-only internal HTTP listener. Your normal local TLS and bind settings remain unchanged.
- Keep authentication enabled. Exposing ngrok with
auth.disabled = truecreates a public unauthenticated endpoint, which is exactly as bad as it sounds. - Passkeys are hostname-bound. If you use ephemeral ngrok URLs, existing passkeys may not work on the new hostname. Use a reserved domain if you want stable passkey behavior.
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.ymlas a starting point. - Run Moltis with
--no-tls(Coolify terminates HTTPS at the proxy). - Set
MOLTIS_BEHIND_PROXY=trueso client IP/auth behavior is correct behind reverse proxying. - Mount
/var/run/docker.sock:/var/run/docker.sockto 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
/datapersists 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
Click the button above or create an app manually:
- Go to Apps > Create App
- Choose Container Image as source
- Set image to
ghcr.io/moltis-org/moltis:latest - Set the run command:
moltis --bind 0.0.0.0 --port 8080 --no-tls - Set environment variables:
MOLTIS_DATA_DIR=/dataMOLTIS_PASSWORD= your password
- Set the HTTP port to
8080
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
The repository includes a render.yaml blueprint. Click the button above or:
- Go to Dashboard > New > Blueprint
- Connect your fork of the Moltis repository
- Render will detect
render.yamland 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_PASSWORDin 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.
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
Remote Sandbox Backends
When Docker is unavailable (cloud deploys, restricted environments), moltis can use remote sandbox backends to provide isolated command execution via cloud APIs.
Available Backends
| Backend | Provider | Isolation | Package Manager |
|---|---|---|---|
| Vercel Sandbox | Vercel (managed) | Firecracker microVM | dnf (Amazon Linux 2023) |
| Daytona | Daytona (managed or self-hosted) | Cloud sandbox | apt-get (Ubuntu) |
| Firecracker | Self-hosted (Linux) | Local microVM | apt-get (Ubuntu) |
Vercel Sandbox
Vercel Sandbox creates ephemeral Firecracker microVMs via the Vercel API. Each session gets its own isolated VM with millisecond boot times.
Configuration
Set environment variables:
VERCEL_TOKEN=ver_your_token_here
VERCEL_TEAM_ID=team_your_team_id # optional but recommended
Or configure in moltis.toml:
[tools.exec.sandbox]
backend = "vercel" # or leave "auto" for auto-detection
# Optional: customize Vercel sandbox settings
vercel_runtime = "node24" # node24, node22, or python3.13
vercel_timeout_ms = 300000 # 5 minutes
vercel_vcpus = 2
Getting Credentials
- Token: Go to vercel.com/account/tokens → Create
- Project ID (required): Create a project at vercel.com/new, then get the ID from Project Settings → General → “Project ID”
- Team ID (optional but recommended): Go to your team’s Settings → General → scroll to “Team ID”
How It Works
backend = "auto"detectsVERCEL_TOKENwhen no local Docker is available- Each session creates an ephemeral Firecracker microVM
- Commands execute via the Vercel REST API
- Files transfer via gzipped tar upload / raw read
- On cleanup, the sandbox is stopped (resources freed immediately)
- Snapshots cache pre-installed packages for fast subsequent boots
Daytona
Daytona provides cloud sandboxes via a REST API. You can use the managed
service at app.daytona.io or self-host Daytona on your own infrastructure
(e.g., Proxmox, bare-metal Linux, Kubernetes).
Configuration
Set environment variables:
DAYTONA_API_KEY=dyt_your_api_key_here
DAYTONA_API_URL=https://app.daytona.io/api # default, change for self-hosted
Or configure in moltis.toml:
[tools.exec.sandbox]
backend = "daytona" # or leave "auto" for auto-detection
# Daytona API settings
daytona_api_url = "https://app.daytona.io/api" # change for self-hosted
daytona_target = "us" # optional target region
Self-Hosted Daytona
If you run Daytona on your own infrastructure (Proxmox, bare-metal, etc.), point the API URL to your instance:
[tools.exec.sandbox]
daytona_api_url = "https://daytona.your-server.local/api"
Or via environment variable:
DAYTONA_API_URL=https://daytona.your-server.local/api
This gives you full control over the sandbox infrastructure while still using moltis’s multi-backend routing and workspace sync.
Getting Credentials
- Sign up at daytona.io or deploy self-hosted
- Generate an API key from the Daytona dashboard
- Set
DAYTONA_API_KEYin your environment
How It Works
backend = "auto"detectsDAYTONA_API_KEYwhen no local Docker is available- Each session creates an ephemeral cloud sandbox
- Commands execute via the toolbox REST API
- Files transfer via multipart upload / download
- On cleanup, the sandbox is deleted
Local Firecracker
For Linux servers without Docker where you want VM-level isolation, the Firecracker backend boots microVMs directly using the Firecracker hypervisor.
Requirements
- Linux only (Firecracker requires KVM)
firecrackerbinary installed- Uncompressed Linux kernel (
vmlinux) - ext4 rootfs image with SSH server and
sandboxuser - Root access or
CAP_NET_ADMINfor TAP networking
Configuration
[tools.exec.sandbox]
backend = "firecracker"
firecracker_bin = "/usr/local/bin/firecracker"
firecracker_kernel = "/opt/moltis/vmlinux"
firecracker_rootfs = "/opt/moltis/rootfs.ext4"
firecracker_ssh_key = "/opt/moltis/ssh_key"
firecracker_vcpus = 2
firecracker_memory_mb = 512
How It Works
- Boots a Firecracker microVM in ~125ms
- Creates a dedicated TAP device per VM for networking
- Commands execute via SSH into the guest
- Pre-built rootfs caches packages (like Docker image building)
- On cleanup, the VM is shut down and TAP device removed
Auto-Detection
When backend = "auto" (the default), moltis selects the sandbox backend
in this order:
- Local: Apple Container → Podman → Docker → (next)
- Remote: Vercel (if
VERCEL_TOKENset) → Daytona (ifDAYTONA_API_KEYset) - Fallback: Restricted Host (rlimits only, no isolation)
Multi-Backend Routing
Multiple backends can be active simultaneously. Per-session backend selection allows different sessions to use different backends:
{ "key": "session:heavy-compute", "sandboxBackend": "vercel" }
{ "key": "session:quick-test", "sandboxBackend": "docker" }
Configure backends in the Settings → Sandboxes → Remote sandbox backends
section of the web UI, or via environment variables and moltis.toml.
Web UI Configuration
Navigate to Settings → Sandboxes and scroll to the “Remote sandbox backends” section. Enter your API tokens and save — moltis will use them after restart.
Package Provisioning
Remote sandboxes automatically install the same default packages configured for local Docker sandboxes. The first session may take longer as packages are installed, but subsequent sessions use cached images/snapshots:
| Backend | Caching Strategy |
|---|---|
| Vercel | Snapshot after first provisioning (instant subsequent boots) |
| Daytona | Runtime provisioning on first session |
| Firecracker | Pre-built rootfs with packages baked in |
Deploy Moltis on a VPS
Run your own AI agent on a $5/month VPS. This guide covers provisioning, installation, and connecting channels (Telegram, Discord, etc.) so you can talk to your agent from anywhere.
Prerequisites
- A VPS with at least 1 GB RAM and 10 GB disk (any provider: Hetzner, DigitalOcean, Linode, Vultr, etc.)
- SSH access to the server
- An API key from at least one LLM provider (Anthropic, OpenAI, etc.)
Option A: Docker (recommended)
Docker is the fastest path. It handles TLS certificates, sandbox isolation, and upgrades via image pulls.
1. Install Docker
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
# Log out and back in for group membership to take effect
2. Deploy Moltis
mkdir -p ~/moltis && cd ~/moltis
curl -fsSL https://raw.githubusercontent.com/moltis-org/moltis/main/deploy/docker-compose.yml -o docker-compose.yml
# Set your password
export MOLTIS_PASSWORD="your-secure-password"
# Start
docker compose up -d
3. Access the web UI
Open https://<your-server-ip>:13131 in your browser. You’ll see a TLS
warning because Moltis generates a self-signed certificate. Accept it or
download the CA from http://<your-server-ip>:13132.
Log in with the password you set, then configure your LLM provider in Settings.
Option B: Binary + systemd
For servers without Docker, install the binary directly.
1. Download the binary
# Replace VERSION with the latest release (e.g. 20260420.01)
VERSION=$(curl -s https://api.github.com/repos/moltis-org/moltis/releases/latest | grep tag_name | cut -d '"' -f 4)
ARCH=$(uname -m | sed 's/x86_64/x86_64/;s/aarch64/aarch64/')
curl -fsSL "https://github.com/moltis-org/moltis/releases/download/${VERSION}/moltis-${VERSION}-linux-${ARCH}.tar.gz" | sudo tar xz -C /usr/local/bin
2. Create user and directories
sudo useradd -r -s /usr/sbin/nologin moltis
sudo mkdir -p /var/lib/moltis /etc/moltis
sudo chown moltis:moltis /var/lib/moltis /etc/moltis
3. Install the systemd service
sudo curl -fsSL https://raw.githubusercontent.com/moltis-org/moltis/main/deploy/moltis.service -o /etc/systemd/system/moltis.service
sudo systemctl daemon-reload
sudo systemctl enable --now moltis
4. Set your password
sudo -u moltis MOLTIS_DATA_DIR=/var/lib/moltis MOLTIS_CONFIG_DIR=/etc/moltis moltis auth reset-password
5. Check status
sudo systemctl status moltis
sudo journalctl -u moltis -f
Connecting channels
Once Moltis is running, add messaging channels from Settings > Channels in the web UI. Each channel has its own setup flow:
| Channel | What you need |
|---|---|
| Telegram | Bot token from @BotFather |
| Discord | Bot token from the Developer Portal |
| Slack | Bot + App tokens from api.slack.com |
| Matrix | Homeserver URL + credentials |
| Nostr | Secret key (nsec) + relay URLs |
See the individual channel docs for detailed setup instructions.
Firewall
Open ports 13131 (HTTPS gateway) and 13132 (HTTP CA download) in your
firewall. If you put Moltis behind a reverse proxy (nginx, Caddy), you only
need to expose the proxy port and can use --no-tls on Moltis.
# UFW example
sudo ufw allow 13131/tcp
sudo ufw allow 13132/tcp
Upgrades
Docker: docker compose pull && docker compose up -d
Binary: Download the new release binary and restart the service:
sudo systemctl stop moltis
# Download new binary (same curl as step 1)
sudo systemctl start moltis
Resource requirements
| Workload | RAM | CPU | Disk |
|---|---|---|---|
| Chat only (no sandbox) | 512 MB | 1 vCPU | 5 GB |
| Chat + sandbox | 1 GB | 1 vCPU | 10 GB |
| Chat + sandbox + local LLM | 4+ GB | 2+ vCPU | 20+ GB |
LLM inference happens on the provider’s API servers, so even a $5 VPS handles chat workloads with external providers.
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:
- Base introduction — one-liner announcing tool access (or not)
- Agent identity — name, emoji, creature, vibe from
IDENTITY.md - Soul — personality directives from
SOUL.md(or built-in default) - User profile — user’s name from
USER.md - Project context —
CLAUDE.md/CLAUDE.local.md/AGENTS.md/.cursorrules/.claude/rules/*.md/.cursor/rules/*.{md,mdc}walked up the directory tree - Runtime context — host info, sandbox config, execution routing hints
- Skills listing — available skills as XML block
- Workspace files —
AGENTS.mdandTOOLS.mdfrom the data directory - Long-term memory hint — added when memory tools are registered
- Tool schemas — compact list (native) or full JSON (fallback)
- Tool-calling format — JSON block instructions (fallback providers only)
- 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:
| Field | Prompt 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.
nameis injected as: “The user’s name is {name}.”timezoneis used by runtime context to localizeHost: time=...andHost: 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.mdCLAUDE.local.mdAGENTS.md.cursorrules.claude/rules/*.md.cursor/rules/*.md.cursor/rules/*.mdc
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(). The block advertises
each skill by name, source, and description, and points the model at the
native read_skill tool for activation. It deliberately does not
include filesystem paths — the read_skill tool resolves names through the
same discoverer that built the prompt block, so the model never needs an
external filesystem MCP server to load skill bodies.
## Available Skills
<available_skills>
<skill name="commit" source="skill">
Create git commits
</skill>
</available_skills>
To activate a skill, call the `read_skill` tool with its name
(e.g. `read_skill(name="<skill-name>")`). To load a sidecar file
inside a skill directory (references/, templates/, scripts/), pass
the `file_path` argument as well
(e.g. `read_skill(name="<skill-name>", file_path="references/api.md")`).
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.
Project Context Safety
Project context ingestion now performs a lightweight safety pass before prompt injection:
- leading HTML comments are stripped
- suspicious instruction-override phrases are flagged
- obvious prompt/secret exfiltration text is flagged
- obvious approval/sandbox bypass text is flagged
Warnings are surfaced in the rendered project context so the model sees that the file should be treated cautiously instead of as operator intent.
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 emittingtool_callJSON blocks.
Guidelines and Silent Replies
The final section contains:
- Tool usage guidelines (conversation first, when to use exec/browser,
/shexplicit 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
| Function | Use 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) | 200 | 50 |
| Soul + identity + guidelines | 2,000 | 500 |
| Typical with tools | 5,000 | 1,250 |
| Full (tools + project context + skills) | 7,000-10,000 | 1,750-2,500 |
| Large (many MCP tools + full context) | 12,000-15,000 | 3,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
├── AGENTS.md # Project-local agent instructions
├── .cursorrules # Cursor compatibility file
├── .cursor/rules/*.mdc # Cursor rule files
├── CLAUDE.local.md # Local overrides (gitignored)
└── .claude/rules/*.md # Additional rule files
Key Source Files
crates/agents/src/prompt.rs— prompt assembly logic andDEFAULT_SOULcrates/gateway/src/chat.rs—load_prompt_persona(), runtime context detection, project context resolutioncrates/config/src/loader.rs— file loading (load_soul(),load_agents_md(),load_identity(), etc.)crates/projects/src/context.rs—CLAUDE.mdhierarchy walkercrates/skills/src/prompt_gen.rs— skills XML generation
Frontend Architecture
The moltis web UI is a TypeScript single-page application built with Preact and Vite.
Directory Layout
crates/web/
├── ui/ # TypeScript source & tooling
│ ├── src/ # Application source
│ │ ├── app.tsx # Main entry point
│ │ ├── login-app.tsx # Login page entry
│ │ ├── onboarding-app.tsx # Onboarding wizard entry
│ │ ├── types/ # Shared type definitions
│ │ ├── stores/ # Preact Signal stores
│ │ ├── components/ # Reusable Preact components
│ │ │ └── forms/ # Form field & layout components
│ │ ├── pages/ # Page components
│ │ │ ├── sections/ # Settings page sections
│ │ │ ├── channels/ # Channel modal sub-components
│ │ │ └── chat/ # Chat page sub-modules
│ │ ├── providers/ # Provider setup sub-modules
│ │ ├── sessions/ # Session management sub-modules
│ │ ├── onboarding/ # Onboarding step components
│ │ ├── ws/ # WebSocket handler sub-modules
│ │ ├── hooks/ # Custom Preact hooks
│ │ └── locales/ # i18n translations (en, fr, zh)
│ ├── e2e/ # Playwright E2E tests
│ ├── vite.config.ts # Vite build configuration
│ ├── tsconfig.json # TypeScript strict config
│ └── package.json # Dependencies & scripts
├── src/
│ ├── assets/ # Served static assets
│ │ ├── dist/ # Vite build output (committed)
│ │ ├── css/ # Stylesheets (Tailwind + custom)
│ │ ├── js/ # E2E test shims + share page
│ │ ├── icons/ # Favicons & PWA icons
│ │ └── sw.js # Service worker
│ └── templates/ # Askama HTML templates
Build Pipeline
TypeScript → JavaScript (Vite)
Source files in ui/src/ are compiled and bundled by Vite into
src/assets/dist/. Three entry points produce three bundles:
dist/main.js— main app (chat, settings, all pages)dist/login.js— login pagedist/onboarding.js— onboarding wizard
cd crates/web/ui
npm run build # Production build → ../src/assets/dist/
npm run dev # Watch mode (rebuilds on file changes)
The dist/ output is committed to git (unminified, no source maps)
so that cargo build works without Node.js installed. This mirrors the
approach used for the committed Tailwind CSS output.
CSS (Tailwind)
Tailwind CSS is built separately from the TypeScript pipeline:
cd crates/web/ui
npm run build:css # input.css → ../src/assets/css/style.css
npm run watch:css # Watch mode
The output style.css is committed unminified (one rule per line) so
diffs merge cleanly.
Service Worker
The service worker is built from TypeScript via esbuild:
cd crates/web/ui
npm run build:sw # src/sw.ts → ../src/assets/sw.js
Full Build
cd crates/web/ui
npm run build:all # Vite + Tailwind + service worker
Technology Stack
| Layer | Technology |
|---|---|
| UI framework | Preact (lightweight React alternative) |
| Templating | JSX with typed Props interfaces |
| State management | Preact Signals |
| Build tool | Vite with @preact/preset-vite |
| Type checking | TypeScript strict mode (tsc --noEmit) |
| Linting/formatting | Biome |
| CSS | Tailwind CSS v4 |
| i18n | i18next (en, fr, zh) |
| Charts | uPlot |
| Terminal | xterm.js |
| Syntax highlighting | Shiki (bundled, lazy-loaded) |
| E2E testing | Playwright |
Type Safety
The codebase enforces strict TypeScript with zero tolerance for any:
tsc --noEmitruns in CI and local-validate (must pass with 0 errors)- 107 typed RPC methods via
RpcMethodMap— callingsendRpc("models.list", {})infers the response type asModelInfo[] - 28 WebSocket events via
WsEventNameenum with typed payload discriminated unions ChannelTypeenum for channel type comparisons (no raw strings)targetValue(e)/targetChecked(e)helpers eliminate(e.target as HTMLInputElement).valuecasts
Shared Component Library
Reusable components in components/forms/:
- Form fields:
TextField,TextAreaField,SelectField,CheckboxField - Layout:
SectionHeading,SubHeading,SettingsCard,DangerZone - Lists:
ListItem,Badge,EmptyState,Loading,CopyButton - Navigation:
TabBar - State:
useSaveState()hook,SaveButton,StatusMessage
Asset Serving
The Rust moltis-web crate serves assets with three-tier resolution:
- Dev filesystem —
MOLTIS_ASSETS_DIRenv var or auto-detected from the crate source tree (cargo rundev mode) - External share dir —
share_dir()/web/for packaged deployments - Embedded fallback —
include_dir!compiled into the binary
HTML templates are rendered by Askama
with server-injected data (window.__MOLTIS__, the “gon” pattern).
E2E Test Compatibility
E2E tests dynamically import individual JS modules (e.g.,
await import("js/state.js")) to inspect and mock internal app state.
With Vite bundling, individual modules don’t exist as standalone files.
Shim layer: small proxy files in src/assets/js/ re-export from
window.__moltis_modules (populated by app.tsx at startup). This
lets tests import modules at their original paths without changes.
The shims are only loaded by E2E tests, never by the production app.
Development Workflow
After changing TypeScript source files:
cd crates/web/ui
# 1. Type check
npx tsc --noEmit
# 2. Lint and format
biome check --write src/
# 3. Build (commits dist/ output)
npm run build
# 4. Run E2E tests
npx playwright test --project default
For CSS changes, also run npm run build:css and commit style.css.
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. Delta(String), /// Raw provider event payload (for debugging API responses). ProviderRaw(serde_json::Value), /// Reasoning/planning text delta (not user-visible final answer text). ReasoningDelta(String), /// A tool call has started (content_block_start with tool_use). 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. 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 supportstream_with_tools()— Streaming with tool schemas passed to the API
Both accept Vec<ChatMessage> (not raw JSON). Providers that support
streaming with tools override stream_with_tools(). Others fall back to
stream() via the default implementation, which ignores the tools parameter.
The trait also exposes supports_tools(), reasoning_effort(), and
with_reasoning_effort() for provider capability discovery.
3. Anthropic Provider (crates/agents/src/providers/anthropic.rs)
The Anthropic provider implements streaming by:
- Making a POST request to
/v1/messageswith"stream": true - Reading Server-Sent Events (SSE) from the response
- Parsing events and yielding appropriate
StreamEventvariants:
| SSE Event Type | StreamEvent |
|---|---|
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_stop | ToolCallComplete (for tool blocks) |
message_delta | (usage tracking) |
message_stop | Done |
error | Error |
4. Agent Runner (crates/agents/src/runner/streaming.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. Chat Service (crates/chat/src/run_with_tools.rs)
The chat service’s run_with_tools() function:
- Sets up an event callback that broadcasts
RunnerEvents via WebSocket - Calls
run_agent_loop_streaming()fromcrates/agents/src/runner/streaming.rs - Broadcasts events to connected clients as JSON frames
Event types broadcast to the UI:
| RunnerEvent | WebSocket State |
|---|---|
Thinking | thinking |
ThinkingDone | thinking_done |
ThinkingText(text) | thinking_text |
TextDelta(text) | delta with text field |
ToolCallStart | tool_call_start |
ToolCallEnd | tool_call_end |
ToolCallRejected | tool_call_end with rejected: true |
Iteration(n) | iteration |
SubAgentStart | sub_agent_start |
SubAgentEnd | sub_agent_end |
AutoContinue | notice (“Auto-continue”) |
RetryingAfterError | retrying |
LoopInterventionFired | notice (“Loop detected”) |
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/ui/src/)
The TypeScript frontend handles streaming via WebSocket:
- websocket.ts - Receives WebSocket frames and dispatches to handlers
- events.ts - Event bus for distributing events to components
- 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 │◀──────────────│ Chat Service │◀────────────────│ Callback │
│ │ │ (moltis-web)│ │ │ │ (on_event) │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
Adding Streaming to New Providers
To add streaming support for a new LLM provider:
- Implement the
stream()method (basic streaming) - If the provider supports tools in streaming mode, override
stream_with_tools() - Parse the provider’s streaming format and yield appropriate
StreamEventvariants - Handle errors gracefully with
StreamEvent::Error - Always emit
StreamEvent::Donewith usage statistics when complete
Example skeleton:
#![allow(unused)] fn main() { fn stream_with_tools( &self, messages: Vec<ChatMessage>, _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, session_state
│ └── 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, agents, ...
│ └── src/server/
│ └── prepare_core.rs # orchestrates moltis.db migrations
├── webhooks/
│ ├── migrations/
│ │ └── 20260407000000_initial.sql # webhooks, webhook_deliveries, ...
│ └── src/lib.rs # run_migrations()
├── vault/
│ ├── migrations/
│ │ └── 20260214000001_vault_metadata.sql # vault_metadata
│ └── src/lib.rs # run_migrations() (feature-gated)
└── 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:
| Crate | Database | Tables | Migration File |
|---|---|---|---|
moltis-projects | moltis.db | projects | 20240205100000_init.sql |
moltis-sessions | moltis.db | sessions, channel_sessions, session_state | 20240205100001_init.sql + 9 migrations |
moltis-cron | moltis.db | cron_jobs, cron_runs | 20240205100002_init.sql + 1 migration |
moltis-gateway | moltis.db | auth_*, passkeys, api_keys, env_variables, message_log, channels, agents, session_shares, device_pairing, ssh_keys, ssh_targets, auth_audit_log | 20240205100003_init.sql + 12 migrations |
moltis-webhooks | moltis.db | webhooks, webhook_deliveries, webhook_response_actions | 20260407000000_initial.sql + 1 migration |
moltis-vault | moltis.db | vault_metadata | 20260214000001_vault_metadata.sql (feature-gated) |
moltis-memory | memory.db | files, chunks, embedding_cache, chunks_fts | 20240205100004_init.sql |
Startup Sequence
The gateway runs migrations in dependency order via
crates/gateway/src/server/prepare_core.rs:
#![allow(unused)] fn main() { 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) moltis_webhooks::run_migrations(&db_pool).await?; // 4. webhooks (independent) crate::run_migrations(&db_pool).await?; // 5. gateway tables #[cfg(feature = "vault")] moltis_vault::run_migrations(&db_pool).await?; // 6. vault (feature-gated) }
Sessions depends on projects due to a foreign key (sessions.project_id references
projects.id), so projects must migrate first. Memory runs separately against
its own memory.db pool.
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
| Database | Location | Crates |
|---|---|---|
moltis.db | ~/.moltis/moltis.db | projects, sessions, cron, gateway, webhooks, vault |
memory.db | ~/.moltis/memory.db | memory (separate, managed internally) |
Adding New Migrations
Adding a Column to an Existing Table
- Create a new migration file in the owning crate:
# Example: adding tags to sessions
touch crates/sessions/migrations/20240301120000_add_tags.sql
- 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);
- Rebuild to embed the migration:
cargo build
Adding a New Table to an Existing Crate
- Create the migration file with a new timestamp:
touch crates/sessions/migrations/20240302100000_session_bookmarks.sql
- 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
- Create the migrations directory:
mkdir -p crates/new-feature/migrations
- Create the migration file with a globally unique timestamp:
touch crates/new-feature/migrations/20240401100000_init.sql
- Add
run_migrations()to the crate’slib.rs:
#![allow(unused)] fn main() { pub async fn run_migrations(pool: &sqlx::SqlitePool) -> anyhow::Result<()> { sqlx::migrate!("./migrations").run(pool).await?; Ok(()) } }
- Call it from
prepare_core.rsin 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 yearMM- 2-digit monthDD- 2-digit dayHH- 2-digit hour (24h)MM- 2-digit minuteSS- 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”
- Check file permissions on
~/.moltis/ - Ensure the database file isn’t locked by another process
- Check for syntax errors in migration SQL files
Migration Order Issues
If you see foreign key errors, verify the migration order in prepare_core.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 EXISTSfor 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
prepare_core.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:
| Feature | Description | Default |
|---|---|---|
metrics | Enables metrics collection and the /api/metrics JSON API | Enabled |
prometheus | Enables 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:
- Add moltis to your
prometheus.yml:
scrape_configs:
- job_name: 'moltis'
static_configs:
- targets: ['localhost:18789']
metrics_path: /metrics
scrape_interval: 15s
- 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:
| Endpoint | Description |
|---|---|
GET /api/metrics | Full metrics snapshot with aggregates and per-provider breakdown |
GET /api/metrics/summary | Lightweight counts for navigation badges |
GET /api/metrics/history | Time-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
| Metric | Type | Labels | Description |
|---|---|---|---|
moltis_http_requests_total | Counter | method, status, endpoint | Total HTTP requests |
moltis_http_request_duration_seconds | Histogram | method, status, endpoint | Request latency |
moltis_http_requests_in_flight | Gauge | — | Currently processing requests |
LLM/Agent Metrics
| Metric | Type | Labels | Description |
|---|---|---|---|
moltis_llm_completions_total | Counter | provider, model | Total completions requested |
moltis_llm_completion_duration_seconds | Histogram | provider, model | Completion latency |
moltis_llm_input_tokens_total | Counter | provider, model | Input tokens processed |
moltis_llm_output_tokens_total | Counter | provider, model | Output tokens generated |
moltis_llm_completion_errors_total | Counter | provider, model, error_type | Completion failures |
moltis_llm_time_to_first_token_seconds | Histogram | provider, model | Streaming 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
| Metric | Type | Labels | Description |
|---|---|---|---|
moltis_mcp_tool_calls_total | Counter | server, tool | Tool invocations |
moltis_mcp_tool_call_duration_seconds | Histogram | server, tool | Tool call latency |
moltis_mcp_tool_call_errors_total | Counter | server, tool, error_type | Tool call failures |
moltis_mcp_servers_connected | Gauge | — | Active MCP server connections |
Tool Execution Metrics
| Metric | Type | Labels | Description |
|---|---|---|---|
moltis_tool_executions_total | Counter | tool | Tool executions |
moltis_tool_execution_duration_seconds | Histogram | tool | Execution time |
moltis_sandbox_command_executions_total | Counter | — | Sandbox commands run |
Session Metrics
| Metric | Type | Labels | Description |
|---|---|---|---|
moltis_sessions_created_total | Counter | — | Sessions created |
moltis_sessions_active | Gauge | — | Currently active sessions |
moltis_session_messages_total | Counter | role | Messages by role |
Cron Job Metrics
| Metric | Type | Labels | Description |
|---|---|---|---|
moltis_cron_jobs_scheduled | Gauge | — | Number of scheduled jobs |
moltis_cron_executions_total | Counter | — | Job executions |
moltis_cron_execution_duration_seconds | Histogram | — | Job duration |
moltis_cron_errors_total | Counter | — | Failed jobs |
moltis_cron_stuck_jobs_cleared_total | Counter | — | Jobs exceeding 2h timeout |
moltis_cron_input_tokens_total | Counter | — | Input tokens from cron runs |
moltis_cron_output_tokens_total | Counter | — | Output tokens from cron runs |
Memory/Search Metrics
| Metric | Type | Labels | Description |
|---|---|---|---|
moltis_memory_searches_total | Counter | search_type | Searches performed |
moltis_memory_search_duration_seconds | Histogram | search_type | Search latency |
moltis_memory_embeddings_generated_total | Counter | provider | Embeddings created |
Channel Metrics
| Metric | Type | Labels | Description |
|---|---|---|---|
moltis_channels_active | Gauge | — | Loaded channel plugins |
moltis_channel_messages_received_total | Counter | channel | Inbound messages |
moltis_channel_messages_sent_total | Counter | channel | Outbound messages |
Telegram-Specific Metrics
| Metric | Type | Labels | Description |
|---|---|---|---|
moltis_telegram_messages_received_total | Counter | — | Messages from Telegram |
moltis_telegram_access_control_denials_total | Counter | — | Access denied events |
moltis_telegram_polling_duration_seconds | Histogram | — | Message handling time |
OAuth Metrics
| Metric | Type | Labels | Description |
|---|---|---|---|
moltis_oauth_flow_starts_total | Counter | — | OAuth flows initiated |
moltis_oauth_flow_completions_total | Counter | — | Successful completions |
moltis_oauth_token_refresh_total | Counter | — | Token refreshes |
moltis_oauth_token_refresh_failures_total | Counter | — | Refresh failures |
Skills Metrics
| Metric | Type | Labels | Description |
|---|---|---|---|
moltis_skills_installation_attempts_total | Counter | — | Installation attempts |
moltis_skills_installation_duration_seconds | Histogram | — | Installation time |
moltis_skills_git_clone_total | Counter | — | Successful git clones |
moltis_skills_git_clone_fallback_total | Counter | — | Fallbacks 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:
| Field | Description |
|---|---|
operation | The operation being performed |
component | The component/module name |
span.name | The 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
- Use consistent naming: Follow the pattern
moltis_<subsystem>_<metric>_<unit> - Add units to names:
_totalfor counters,_secondsfor durations,_bytesfor sizes - Keep cardinality low: Avoid high-cardinality labels (like user IDs or request IDs)
- Feature-gate everything: Use
#[cfg(feature = "metrics")]to ensure zero overhead when disabled - Use predefined buckets: The
bucketsmodule has standard histogram buckets for common metric types
Troubleshooting
Metrics not appearing
- Verify the
metricsfeature is enabled at compile time - Check that the metrics recorder is initialized (happens automatically in gateway)
- Ensure you’re hitting the correct
/metricsendpoint - Check
moltis.tomlhas[metrics] enabled = true
Prometheus endpoint not available
- Ensure the
prometheusfeature is enabled (it’s separate frommetrics) - 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
- The model receives only
tool_searchin its tool list. tool_search(query="memory")returns name + description pairs (max 15), no schemas.tool_search(name="memory_search")returns the full schema and activates the tool.- On the next iteration the model calls
memory_searchdirectly — 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.
Tool Policy
Tool policies control which tools are available during a session. Policies use a layered system where each layer can restrict or widen access, and deny always wins — once a tool is denied at any layer, no later layer can re-allow it.
Layers
Six layers are evaluated in order. Later layers can replace the allow list, but deny entries accumulate across all of them.
| # | Layer | Config path | Applies to |
|---|---|---|---|
| 1 | Global | [tools.policy] | All sessions |
| 2 | Per-provider | [providers.<name>.policy] | Requests routed through that provider |
| 3 | Per-agent preset | [agents.presets.<id>.tools] | Sub-agents spawned with that preset |
| 4 | Per-channel group | [channels.<type>.<account>.tools.groups.<chat_type>] | Channel sessions matching that chat type |
| 5 | Per-sender | ...groups.<chat_type>.by_sender.<sender_id> | Messages from that sender in that group |
| 6 | Sandbox | [tools.exec.sandbox.tools_policy] | Commands running inside a sandbox container |
Web UI sessions see layers 1-3 (no channel context), plus layer 6 if sandboxed. Channel sessions can see all 6 layers.
Merge Semantics
Each layer produces an allow list and a deny list. When merging a
higher-priority layer on top of a lower one:
- Deny accumulates. Every deny entry from every layer is collected. If any layer denies a tool, it stays denied.
- Allow replaces. A non-empty allow list from a later layer replaces the previous allow list entirely. An empty allow list is a no-op (keeps the previous allow list).
- Empty allow = permit all. When the effective allow list is empty, everything not denied is allowed.
Glob Patterns
Both allow and deny entries support glob-style patterns:
"*"— matches every tool name"browser*"— matches any tool whose name starts withbrowser"exec"— matches only the exact tool nameexec
Profiles
The global layer and per-sender overrides support a profile field that
expands to a predefined allow list before the explicit allow/deny entries
are applied.
| Profile | Allow list |
|---|---|
"minimal" | exec |
"coding" | exec, browser, memory |
"full" | * (everything) |
When a profile is set, its allow list is applied first, then the explicit
allow/deny from the same layer are merged on top (deny accumulates,
non-empty allow replaces).
Layer 1 — Global
The base policy for all sessions. Set in moltis.toml under [tools.policy]:
[tools.policy]
allow = [] # empty = permit all tools not denied
deny = ["browser"] # deny browser in every session
# profile = "full" # optional named profile
Layer 2 — Per-Provider
Each provider entry can carry its own policy. When a request is routed through that provider, the policy is merged on top of the global layer.
[providers.openai]
# ... api_key, models, etc.
policy.deny = ["exec"]
This denies exec whenever OpenAI is the active provider, regardless of
what the global layer allows. Other providers are unaffected.
Layer 3 — Per-Agent Preset
Agent presets (used by spawn_agent) can restrict their sub-agent’s tools.
[agents.presets.researcher]
model = "anthropic/claude-haiku-3-5-20241022"
tools.allow = ["read_file", "glob", "grep", "web_search", "web_fetch"]
tools.deny = ["exec", "write_file"]
When the researcher preset is active, only the five listed tools are
allowed, and exec/write_file are explicitly denied. See
Agent Presets for the full preset reference.
Note: Preset tool policies apply only to sub-agents spawned via
spawn_agent. They do not affect the main agent session. Use the global[tools.policy]for the main session.
Layer 4 — Per-Channel Group
Channel accounts can restrict tools by chat type (private, group,
channel, etc.). This is useful for hardening group chats where the bot
is exposed to untrusted users.
[channels.telegram.my-bot.tools.groups.group]
deny = ["exec", "browser"]
In this example, exec and browser are denied in Telegram group chats
handled by the my-bot account. Private chats and web UI sessions are
unaffected.
Layer 5 — Per-Sender
Within a channel group, individual senders can receive overrides. This lets you trust specific users in an otherwise restricted group.
[channels.telegram.my-bot.tools.groups.group]
deny = ["exec", "browser"]
[channels.telegram.my-bot.tools.groups.group.by_sender."123456"]
allow = ["*"]
Sender 123456 gets allow = ["*"], which replaces the previous allow
list. However, because deny always accumulates, the exec and
browser denials from the group layer still apply. The sender override
is useful for widening the allow list (e.g., granting access to tools that
were not in the previous allow set) or for applying a different profile.
If you need a trusted sender to have exec access in a group, avoid
denying exec at the group layer. Instead, use a restrictive allow list
at the group level and widen it per-sender:
[channels.telegram.my-bot.tools.groups.group]
allow = ["web_search", "web_fetch", "memory_search"]
[channels.telegram.my-bot.tools.groups.group.by_sender."123456"]
allow = ["*"]
Here, untrusted group members can only use the three listed tools. Sender
123456 gets full access because the group layer did not deny anything —
it only narrowed the allow list.
Layer 6 — Sandbox
When a session runs inside a sandbox container, this layer applies on top of all other layers. It lets you restrict tools for sandboxed execution without affecting non-sandboxed sessions.
[tools.exec.sandbox.tools_policy]
allow = ["exec"] # only exec inside sandbox
deny = ["browser"] # never allow browser in sandbox
This layer is skipped entirely when the session is not sandboxed.
Examples
Deny exec for a specific provider
[providers.openai]
policy.deny = ["exec"]
When using OpenAI, the agent cannot run shell commands. All other providers retain their normal tool access.
Restrict group chats on Telegram
[channels.telegram.my-bot.tools.groups.group]
deny = ["exec", "browser*"]
Group chats cannot use exec or any tool starting with browser.
Private chats are unaffected.
Trust a sender in a restricted group
[channels.telegram.my-bot.tools.groups.group]
allow = ["web_search", "web_fetch"]
[channels.telegram.my-bot.tools.groups.group.by_sender."123456"]
allow = ["*"]
Normal group members can only search and fetch. Sender 123456 can use
every tool (nothing was denied at the group layer, so nothing accumulates).
Agent preset with limited tools
[agents.presets.researcher]
tools.allow = ["read_file", "glob", "grep"]
tools.deny = ["exec"]
The researcher sub-agent can only read files and search. Even if a
higher layer allows exec, it is denied here and the denial carries
through.
Use a profile for the global policy
[tools.policy]
profile = "coding"
deny = ["web_fetch"]
The coding profile expands to allow = ["exec", "browser", "memory"].
Then web_fetch is denied. The effective policy allows exec, browser,
and memory, and denies web_fetch. All other tools are not in the allow
list and are therefore blocked.
Widen a sender via profile
[channels.telegram.my-bot.tools.groups.group]
allow = ["web_search"]
[channels.telegram.my-bot.tools.groups.group.by_sender."123456"]
profile = "full"
Sender 123456 gets allow = ["*"] from the full profile, replacing
the group’s narrow allow list. Since the group layer only set allow (no
deny), nothing is denied and the sender has full tool access.
Debugging
Enable debug logging to see which layers are applied at runtime:
policy: applied global profile 'coding'
policy: applied global layer
policy: applied provider layer provider=openai
policy: applied agent preset layer agent_id=researcher
policy: applied group layer channel=telegram account_id=my-bot group_id=group
policy: applied sender layer channel=telegram group_id=group sender_id=123456
policy: applied sandbox layer
Each line indicates a layer was non-empty and merged into the effective policy. Missing lines mean that layer had no configuration or the runtime context did not match (e.g., no channel context for a web UI session).
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.
They are different from modes: modes are temporary overlays for the current chat session, while agent presets configure delegated sub-agents.
Built-In Presets
Moltis ships with these presets on every install:
| Preset | Role |
|---|---|
research | Evidence gathering and synthesis. This is the default when spawn_agent.preset is omitted. |
coder | Scoped implementation, debugging, cleanup, and focused verification. |
reviewer | Code review for correctness, regressions, security, and missing tests. |
qa | End-to-end behavior validation, repro steps, and pass/fail reporting. |
ux | UX, accessibility, interaction, and visual quality review. |
docs | User-facing documentation, examples, and config reference updates. |
coordinator | Delegation-first planning and result integration. |
User TOML presets and markdown agent definitions with the same name override
the built-in preset. The built-ins do not set a model or tool allow/deny
policy, so they inherit the session’s provider and normal tool access. The
coordinator preset sets delegate_only = true, restricting it to delegation,
session, and task-list tools.
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", "Glob", "Grep", "web_search", "web_fetch"]
tools.deny = ["exec", "Write"]
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_search", "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.thememodeltools.allow,tools.denysystem_prompt_suffixmax_iterations,timeout_secssessions.*access policymemory.scope,memory.max_linesdelegate_only
Tool Policy Behavior
- If
tools.allowis empty, all tools start as allowed. - If
tools.allowis non-empty, only those tools are allowed. tools.denyis applied after allow-list filtering.- For normal sub-agents,
spawn_agentis 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_search,sessions_send,task_list.
Session Access Policy
sessions policy controls what a preset can see/send across sessions:
key_prefix: optional session-key prefix filterallowed_keys: explicit allow-listcan_send: allow/disallowsessions_sendcross_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.scopedetermines where the file is stored:user(default):~/.moltis/agent-memory/<preset>/MEMORY.mdproject:.moltis/agent-memory/<preset>/MEMORY.mdlocal:.moltis/agent-memory-local/<preset>/MEMORY.md
memory.max_lineslimits 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:
- Explicit
modelparameter in tool call - Preset
model - 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_search
Search prior session history for relevant snippets. By default the current
session is excluded when _session_key is available in tool context.
{
"query": "checkpoint rollback",
"limit": 5,
"exclude_current": true
}
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_search", "sessions_send", "task_list", "spawn_agent"]
sessions.can_send = true
[agents.presets.observer]
tools.allow = ["sessions_list", "sessions_history", "sessions_search"]
sessions.key_prefix = "agent:research:"
sessions.can_send = false
Policy fields:
key_prefix: restrict visibility by session-key prefixallowed_keys: extra explicit session keyscan_send: controlssessions_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:
sessions_listto discover workerssessions_searchto find prior related worksessions_historyto inspect progresssessions_sendto dispatch next taskstask_listto 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
Changed
Deprecated
Removed
Fixed
Security
[20260507.05] - 2026-05-07
Added
- [nodes] Ed25519 challenge-response node identity (TOFU) (#979)
- [telephony] Add phone call support via Twilio (#920)
Removed
- [release] Remove gpg signing confirmation prompt
Fixed
- [website] Hide unpublished releases from changelog
- [website] Publish 20260507.04 changelog
- [release] Avoid signing build tools
- [release] Read gpg signing confirmation from tty
- [release] Log gpg signing confirmation input
- [release] Compare gpg confirmation literally
- [browser] Resolve sandbox profile host mounts (#980)
- [release] Remove macos app cache from release workflow
- [ci] Stabilize e2e navigation and nodes setup
[20260507.04] - 2026-05-07
Fixed
- [e2e] Align sandbox settings tests with tabs
[20260507.03] - 2026-05-07
Fixed
- [sandbox] Clean up linux firecracker checks
[20260507.02] - 2026-05-07
Fixed
- [sandbox] Compile firecracker backend with all features
[20260507.01] - 2026-05-07
Added
- [sandbox] Remote & multi-backend sandbox support (Vercel, Daytona, Firecracker) (#942)
Changed
- [e2e] Consolidate sendRpcFromPage into shared helpers
Removed
- Remove unused sendRpcFromPage import from sessions.spec.js
- [ci] Drop e2e runtime state before cache save
- [e2e] Remove retry-hidden flakes
- [ci] Remove temporary websocket diagnostics
Fixed
- [e2e] Revert tee log capture (breaks exec on CI), reduce RPC timeout to 5s
- [e2e] Reduce sendRpc retries from 40 to 10 — 40×5s=200s per test was hanging CI
- [gateway] Derive Default for ClientRegistryInner to satisfy clippy
- [gateway] Complete ClientRegistry migration — remove duplicate fields from GatewayInner
- [e2e] Add warmup RPC to waitForWsConnected to verify end-to-end WS
- [ws] Spawn RPC dispatch to prevent read-loop head-of-line blocking
- [gateway] Make chat() lock-free — move chat_override to std::sync::RwLock
- [ws] Increase sendRpc timeout from 5s to 30s for heavy RPCs
- [ci] Unblock chat send rpc
- [ci] Bound chat send prompt setup
- [ci] Avoid prompt waits on sandbox image builds
- [ci] Keep sandbox prebuilds off request paths
- [ci] Fail faster on e2e stalls
- [ci] Cache e2e build prerequisites
- [ci] Keep release e2e cache-free
- [ci] Cap e2e startup stalls
- [ci] Shard e2e across isolated gateways
- [ci] Isolate e2e workers per gateway
- [ci] Start one e2e gateway per project process
- [e2e] Refresh share notice and split settings specs
- [e2e] Avoid domcontentloaded navigation flake
- [ci] Stream e2e shard progress
- [ci] Keep e2e progress lines atomic
- [e2e] Make autoscroll fixture deterministic
- [e2e] Harden autoscroll setup against late renders
- [e2e] Use real voice settings save path
- [auth] Respect X-Forwarded-Proto for cookie Secure attribute (#970)
- [sandbox] Serialize container startup (#971)
- [providers] Replay DeepSeek reasoning content (#961)
- [matrix] Add debug logging for OIDC registration and deduplicate redirect normalization (#957)
[20260504.01] - 2026-05-04
[20260502.01] - 2026-05-02
Added
- [portable] Add data import/export for config, databases, and sessions (#951)
- [i18n] Add zh-TW Traditional Chinese locale support (#339)
Fixed
- [discord] Register slash command arguments and add all thinking levels (#950)
- [web-ui] Prevent horizontal overflow in chat messages container (#952)
- [portable] Allow unwrap in test modules to satisfy workspace clippy lint
- [msteams] Collapse nested if to satisfy clippy collapsible_if lint
- [discord] Use is_none_or and allow expect in tests for clippy
- [e2e] Add smartScrollToBottom to chat-ui shim, fix command palette focus test
- [e2e] Reduce appended element height below isChatAtBottom threshold in autoscroll test
- [e2e] Add “Import Your Data” heading to auth spec onboarding matcher
- [i18n] Add missing zh-TW reasoning keys and fix biome formatting
- [telegram] Upgrade teloxide 0.13→0.17 to fix multipart ThreadId panic (#954)
- [terminal] Prevent spurious “window does not exist” error on tab creation (#955)
- [e2e] Update reasoning toggle tests for 6 effort levels (added Minimal and Extra High)
- [e2e] Use poll-based scroll assertion in sequential messages autoscroll test
[20260501.01] - 2026-05-01
Added
- [providers] Add Zen (opencode.ai) multi-protocol provider (#944)
- [web-ui] Hide voice buttons when stt/tts disabled in config (#943)
[20260430.02] - 2026-04-30
Added
- [web-ui] Add message action bar to assistant responses (#932)
- [gateway] Auto-generate session titles from conversation (#933)
- Add DeepInfra provider, sandbox GPU passthrough, strict model selection (#934)
- Detect Proxmox VE and offer LXC container install
- [skills] Add per-skill usage telemetry to /insights and web UI (#935)
- [sandbox] Add GitHub CLI (gh) to default sandbox packages
- [web-ui] Show progress indicator during sandbox image build
Fixed
- Replace completion-based model probe with lightweight catalog check (#931)
- [deploy] Correct subcommand in systemd service file
- [web-ui] Clipboard copy button broken on insecure contexts (#936)
- [deploy] Use bare moltis command in systemd service
- [web-ui] System-notice text overflows excessively rounded container (#941)
- [httpd] Handle SIGTERM for graceful Docker shutdown (#940)
- [sandbox] Wait for image build before launching containers
- [e2e] Fix clipboard, provider order, and reasoning toggle tests
- [httpd] Gate msteams-only imports behind feature flag
- [e2e] Fix settings-nav, voice, onboarding, and command palette tests
[20260430.01] - 2026-04-30
Added
- Add /btw, /fast, /insights, /steer, /queue commands and auxiliary model config (#926)
Fixed
- [mcp] Preserve auth_state on expired OAuth token so re-auth button shows (#930)
- [web-ui] Remove scroll-hijacking ResizeObserver in chat (#922) (#925)
[20260429.02] - 2026-04-29
Added
- [voice] Add voice personas for deterministic TTS identity (#916)
Fixed
- [sandbox] Prevent sandbox escape via RestrictedHostSandbox and FailoverSandbox (#924)
[20260429.01] - 2026-04-29
Added
- [update] Add /update command and web UI update button (#911)
- [browser] Add Obscura as lightweight sidecar browser backend (#869)
- [web] Add command palette (Cmd+K / Ctrl+K) (#904)
- [import] Add multi-source import from Claude Code, Claude Desktop, and Hermes (#914)
- [import] Add Claude Code and Hermes import to web UI (#917)
Changed
- [config] Unify provider name validation into single source of truth (#912)
- [agents] Split model.rs into chat, convert, and stream modules
Removed
- [e2e] Remove dead tests for deleted UI features
- Remove CLAUDE.local.md from repo and add to .gitignore
- Remove unused PathBuf imports in hermes-import
- Remove unused imports in httpd gateway
Fixed
- [e2e] Stabilize rename test and skip broken Matrix ownership test
- [docs] Use $http_host in nginx proxy examples to preserve port (#907)
- [e2e] Fix rename assertion, unskip Matrix ownership test
- [gateway] Prevent USER.md from overriding saved user_name
- [e2e] Unskip clear-all and channel-rename tests, fix for new UI
- [release] Scope changelog entries to version deltas (#909)
- [e2e] Rewrite stop action test for thinking indicator UI
- [tests] Update comment preservation assertion for merge behavior
- [providers] Sanitize and strip user name field for channel messages (#915)
- [skills] Add missing origin metadata to data-sync, birdclaw, discrawl, wacrawl, slacrawl
- Collapse nested if-let in MCP import
- Collapse nested if-let in claude-import MCP servers
- Use vec![] macro instead of push-after-init in import commands
- [e2e] Use Ctrl+K instead of Meta+K in command palette tests
- Restore HashMap and ChannelPlugin imports in httpd gateway
- [web] Command palette keyboard navigation in headless Chromium
- [web] Use capture-phase listener for command palette keyboard nav
- [web] Eliminate rAF race in session rename input
- [web] Prevent session switch from stealing focus during rename
- [build] Align production feature flags
- [web] Show /mode none hint in slash command popup
- [web] Only guard chatInput focus against text inputs
- [e2e] Fix flaky command palette, reasoning toggle, and projects tests
[20260428.03] - 2026-04-28
Added
- [cron] Add heartbeat wake cooldown to prevent exec re-fire loop (#871)
- [web-ui] Show chat status badges in visible toolbar row (#886)
- [local-llm] On-demand model loading/unloading with idle timeout (#884)
- Make telegram channel optional (#891)
- Make discord and msteams channels optional (#899)
Changed
- [config] Extract initialize_config() from discover_and_load()
- Derive nightly toolchain from rust-toolchain.toml everywhere
- [gateway] Split skills service helpers into separate module
- [gateway] Move skills impl out of mod.rs into service.rs
Removed
- Remove needless return in skills toggle_bundled_skill
Fixed
- [tests] Stabilize flaky memory_config_get test
- [ci] Pin NCCL version to match CUDA 12.4 container
- [ci] Match NCCL dev headers to pre-installed runtime version
- [ci] Skip libnccl-dev to work around llama-cpp-sys-2 linking bug
- [ci] Remove pre-installed libnccl-dev from CUDA container
- [providers] Link NCCL when llama-cpp-sys-2 compiles with GGML_USE_NCCL
- Resolve clippy lints for nightly-2026-04-24 and matrix-sdk compat
- [mcp] Prefer native MCP tools over mcporter (#874)
- [ci] Use keychain-profile for notarization and log failures
- [gateway] Handle bundled skill disable/enable via config (#877)
- [gateway] Derive bundled skill enabled state from config (#878)
- [tests] Increase watcher test timeout to accommodate debounce + FSEvents latency
- [web-ui] Stop auto-enabling all skills on repository import (#882)
- [skills] Store per-skill relative paths for marketplace repos (#883)
- [tests] Mark flaky FSEvents watcher test as ignored
- [tests] Use PollWatcher in watcher tests for reliable cross-platform behavior
- Use if-let instead of single-arm match in poll watcher test
- [security] Store voice API keys in credential store, not moltis.toml (#885)
- [e2e] Update session tests for inline action buttons (#886 follow-up)
- [tests] Add missing LocalLlmService trait methods to MockLocalLlm
- Collapse nested if-let in key_store timestamp check
- Derive Default for ModelLifecycleManager
- [web-ui] Restore session name and inline rename in chat toolbar (#892)
- Use ApplicationType::Web for non-loopback Matrix OIDC redirect URIs (#893)
- Add TSX source to Tailwind content scanning
- Show RPC error messages in skills UI and auto-trust on install (#897)
- [config] Add mcp field to agent preset in schema map
- [e2e] Update tests for identity→profile route rename
- [e2e] Complete identity→profile rename in remaining tests
- [e2e] Stabilize session rename test against onBlur race
- [config] Use atomic writes to prevent moltis.toml corruption
- [config] Use unique temp file for atomic writes
- [config] Move tempfile from dev-dependencies to dependencies
- [e2e] Use .chat-session-name selector for rename assertion
- [ci] Copy scripts/ into Docker build and improve download retries
Security
- [ci] Use correct security list-keychains syntax for macOS codesign
- [ci] Align macOS certificate import with working arbor pattern
- [ci] Add –timestamp and strip get-task-allow for macOS notarization
[20260428.02] - 2026-04-28
Added
- [cron] Add heartbeat wake cooldown to prevent exec re-fire loop (#871)
- [web-ui] Show chat status badges in visible toolbar row (#886)
- [local-llm] On-demand model loading/unloading with idle timeout (#884)
- Make telegram channel optional (#891)
- Make discord and msteams channels optional (#899)
Changed
- [config] Extract initialize_config() from discover_and_load()
- Derive nightly toolchain from rust-toolchain.toml everywhere
- [gateway] Split skills service helpers into separate module
- [gateway] Move skills impl out of mod.rs into service.rs
Removed
- Remove needless return in skills toggle_bundled_skill
Fixed
- [tests] Stabilize flaky memory_config_get test
- [ci] Pin NCCL version to match CUDA 12.4 container
- [ci] Match NCCL dev headers to pre-installed runtime version
- [ci] Skip libnccl-dev to work around llama-cpp-sys-2 linking bug
- [ci] Remove pre-installed libnccl-dev from CUDA container
- [providers] Link NCCL when llama-cpp-sys-2 compiles with GGML_USE_NCCL
- Resolve clippy lints for nightly-2026-04-24 and matrix-sdk compat
- [mcp] Prefer native MCP tools over mcporter (#874)
- [ci] Use keychain-profile for notarization and log failures
- [gateway] Handle bundled skill disable/enable via config (#877)
- [gateway] Derive bundled skill enabled state from config (#878)
- [tests] Increase watcher test timeout to accommodate debounce + FSEvents latency
- [web-ui] Stop auto-enabling all skills on repository import (#882)
- [skills] Store per-skill relative paths for marketplace repos (#883)
- [tests] Mark flaky FSEvents watcher test as ignored
- [tests] Use PollWatcher in watcher tests for reliable cross-platform behavior
- Use if-let instead of single-arm match in poll watcher test
- [security] Store voice API keys in credential store, not moltis.toml (#885)
- [e2e] Update session tests for inline action buttons (#886 follow-up)
- [tests] Add missing LocalLlmService trait methods to MockLocalLlm
- Collapse nested if-let in key_store timestamp check
- Derive Default for ModelLifecycleManager
- [web-ui] Restore session name and inline rename in chat toolbar (#892)
- Use ApplicationType::Web for non-loopback Matrix OIDC redirect URIs (#893)
- Add TSX source to Tailwind content scanning
- Show RPC error messages in skills UI and auto-trust on install (#897)
- [config] Add mcp field to agent preset in schema map
- [e2e] Update tests for identity→profile route rename
- [e2e] Complete identity→profile rename in remaining tests
- [e2e] Stabilize session rename test against onBlur race
- [config] Use atomic writes to prevent moltis.toml corruption
- [config] Use unique temp file for atomic writes
- [config] Move tempfile from dev-dependencies to dependencies
Security
- [ci] Use correct security list-keychains syntax for macOS codesign
- [ci] Align macOS certificate import with working arbor pattern
- [ci] Add –timestamp and strip get-task-allow for macOS notarization
[20260428.01] - 2026-04-28
Added
- [cron] Add heartbeat wake cooldown to prevent exec re-fire loop (#871)
- [web-ui] Show chat status badges in visible toolbar row (#886)
- [local-llm] On-demand model loading/unloading with idle timeout (#884)
- Make telegram channel optional (#891)
- Make discord and msteams channels optional (#899)
Changed
- [config] Extract initialize_config() from discover_and_load()
- Derive nightly toolchain from rust-toolchain.toml everywhere
- [gateway] Split skills service helpers into separate module
- [gateway] Move skills impl out of mod.rs into service.rs
Removed
- Remove needless return in skills toggle_bundled_skill
Fixed
- [tests] Stabilize flaky memory_config_get test
- [ci] Pin NCCL version to match CUDA 12.4 container
- [ci] Match NCCL dev headers to pre-installed runtime version
- [ci] Skip libnccl-dev to work around llama-cpp-sys-2 linking bug
- [ci] Remove pre-installed libnccl-dev from CUDA container
- [providers] Link NCCL when llama-cpp-sys-2 compiles with GGML_USE_NCCL
- Resolve clippy lints for nightly-2026-04-24 and matrix-sdk compat
- [mcp] Prefer native MCP tools over mcporter (#874)
- [ci] Use keychain-profile for notarization and log failures
- [gateway] Handle bundled skill disable/enable via config (#877)
- [gateway] Derive bundled skill enabled state from config (#878)
- [tests] Increase watcher test timeout to accommodate debounce + FSEvents latency
- [web-ui] Stop auto-enabling all skills on repository import (#882)
- [skills] Store per-skill relative paths for marketplace repos (#883)
- [tests] Mark flaky FSEvents watcher test as ignored
- [tests] Use PollWatcher in watcher tests for reliable cross-platform behavior
- Use if-let instead of single-arm match in poll watcher test
- [security] Store voice API keys in credential store, not moltis.toml (#885)
- [e2e] Update session tests for inline action buttons (#886 follow-up)
- [tests] Add missing LocalLlmService trait methods to MockLocalLlm
- Collapse nested if-let in key_store timestamp check
- Derive Default for ModelLifecycleManager
- [web-ui] Restore session name and inline rename in chat toolbar (#892)
- Use ApplicationType::Web for non-loopback Matrix OIDC redirect URIs (#893)
- Add TSX source to Tailwind content scanning
- Show RPC error messages in skills UI and auto-trust on install (#897)
- [config] Add mcp field to agent preset in schema map
- [e2e] Update tests for identity→profile route rename
- [e2e] Complete identity→profile rename in remaining tests
- [e2e] Stabilize session rename test against onBlur race
- [config] Use atomic writes to prevent moltis.toml corruption
- [config] Use unique temp file for atomic writes
- [config] Move tempfile from dev-dependencies to dependencies
Security
- [ci] Use correct security list-keychains syntax for macOS codesign
- [ci] Align macOS certificate import with working arbor pattern
- [ci] Add –timestamp and strip get-task-allow for macOS notarization
[20260426.05] - 2026-04-26
Added
- [cron] Add heartbeat wake cooldown to prevent exec re-fire loop (#871)
- [web-ui] Show chat status badges in visible toolbar row (#886)
- [local-llm] On-demand model loading/unloading with idle timeout (#884)
Changed
- [config] Extract initialize_config() from discover_and_load()
- Derive nightly toolchain from rust-toolchain.toml everywhere
Removed
- Remove needless return in skills toggle_bundled_skill
Fixed
- [tests] Stabilize flaky memory_config_get test
- [ci] Pin NCCL version to match CUDA 12.4 container
- [ci] Match NCCL dev headers to pre-installed runtime version
- [ci] Skip libnccl-dev to work around llama-cpp-sys-2 linking bug
- [ci] Remove pre-installed libnccl-dev from CUDA container
- [providers] Link NCCL when llama-cpp-sys-2 compiles with GGML_USE_NCCL
- Resolve clippy lints for nightly-2026-04-24 and matrix-sdk compat
- [mcp] Prefer native MCP tools over mcporter (#874)
- [ci] Use keychain-profile for notarization and log failures
- [gateway] Handle bundled skill disable/enable via config (#877)
- [gateway] Derive bundled skill enabled state from config (#878)
- [tests] Increase watcher test timeout to accommodate debounce + FSEvents latency
- [web-ui] Stop auto-enabling all skills on repository import (#882)
- [skills] Store per-skill relative paths for marketplace repos (#883)
- [tests] Mark flaky FSEvents watcher test as ignored
- [tests] Use PollWatcher in watcher tests for reliable cross-platform behavior
- Use if-let instead of single-arm match in poll watcher test
- [security] Store voice API keys in credential store, not moltis.toml (#885)
- [e2e] Update session tests for inline action buttons (#886 follow-up)
- [tests] Add missing LocalLlmService trait methods to MockLocalLlm
- Collapse nested if-let in key_store timestamp check
- Derive Default for ModelLifecycleManager
Security
- [ci] Use correct security list-keychains syntax for macOS codesign
- [ci] Align macOS certificate import with working arbor pattern
- [ci] Add –timestamp and strip get-task-allow for macOS notarization
[20260426.04] - 2026-04-26
Added
- [cron] Add heartbeat wake cooldown to prevent exec re-fire loop (#871)
Changed
- [config] Extract initialize_config() from discover_and_load()
- Derive nightly toolchain from rust-toolchain.toml everywhere
Fixed
- [tests] Stabilize flaky memory_config_get test
- [ci] Pin NCCL version to match CUDA 12.4 container
- [ci] Match NCCL dev headers to pre-installed runtime version
- [ci] Skip libnccl-dev to work around llama-cpp-sys-2 linking bug
- [ci] Remove pre-installed libnccl-dev from CUDA container
- [providers] Link NCCL when llama-cpp-sys-2 compiles with GGML_USE_NCCL
- Resolve clippy lints for nightly-2026-04-24 and matrix-sdk compat
- [mcp] Prefer native MCP tools over mcporter (#874)
- [ci] Use keychain-profile for notarization and log failures
Security
- [ci] Use correct security list-keychains syntax for macOS codesign
- [ci] Align macOS certificate import with working arbor pattern
- [ci] Add –timestamp and strip get-task-allow for macOS notarization
[20260426.03] - 2026-04-26
Added
- [cron] Add heartbeat wake cooldown to prevent exec re-fire loop (#871)
Changed
- [config] Extract initialize_config() from discover_and_load()
- Derive nightly toolchain from rust-toolchain.toml everywhere
Fixed
- [tests] Stabilize flaky memory_config_get test
- [ci] Pin NCCL version to match CUDA 12.4 container
- [ci] Match NCCL dev headers to pre-installed runtime version
- [ci] Skip libnccl-dev to work around llama-cpp-sys-2 linking bug
- [ci] Remove pre-installed libnccl-dev from CUDA container
- [providers] Link NCCL when llama-cpp-sys-2 compiles with GGML_USE_NCCL
- Resolve clippy lints for nightly-2026-04-24 and matrix-sdk compat
- [mcp] Prefer native MCP tools over mcporter (#874)
- [ci] Use keychain-profile for notarization and log failures
Security
- [ci] Use correct security list-keychains syntax for macOS codesign
- [ci] Align macOS certificate import with working arbor pattern
[20260426.02] - 2026-04-26
Added
- [cron] Add heartbeat wake cooldown to prevent exec re-fire loop (#871)
Changed
- [config] Extract initialize_config() from discover_and_load()
- Derive nightly toolchain from rust-toolchain.toml everywhere
Fixed
- [tests] Stabilize flaky memory_config_get test
- [ci] Pin NCCL version to match CUDA 12.4 container
- [ci] Match NCCL dev headers to pre-installed runtime version
- [ci] Skip libnccl-dev to work around llama-cpp-sys-2 linking bug
- [ci] Remove pre-installed libnccl-dev from CUDA container
- [providers] Link NCCL when llama-cpp-sys-2 compiles with GGML_USE_NCCL
- Resolve clippy lints for nightly-2026-04-24 and matrix-sdk compat
- [mcp] Prefer native MCP tools over mcporter (#874)
- [ci] Use keychain-profile for notarization and log failures
Security
- [ci] Use correct security list-keychains syntax for macOS codesign
- [ci] Align macOS certificate import with working arbor pattern
[20260426.01] - 2026-04-26
Added
- [cron] Add heartbeat wake cooldown to prevent exec re-fire loop (#871)
Changed
- [config] Extract initialize_config() from discover_and_load()
- Derive nightly toolchain from rust-toolchain.toml everywhere
Fixed
- [tests] Stabilize flaky memory_config_get test
- [ci] Pin NCCL version to match CUDA 12.4 container
- [ci] Match NCCL dev headers to pre-installed runtime version
- [ci] Skip libnccl-dev to work around llama-cpp-sys-2 linking bug
- [ci] Remove pre-installed libnccl-dev from CUDA container
- [providers] Link NCCL when llama-cpp-sys-2 compiles with GGML_USE_NCCL
Security
- [ci] Use correct security list-keychains syntax for macOS codesign
- [ci] Align macOS certificate import with working arbor pattern
[20260425.09] - 2026-04-25
Added
- [cron] Add heartbeat wake cooldown to prevent exec re-fire loop (#871)
Changed
- [config] Extract initialize_config() from discover_and_load()
Fixed
- [tests] Stabilize flaky memory_config_get test
- [ci] Pin NCCL version to match CUDA 12.4 container
- [ci] Match NCCL dev headers to pre-installed runtime version
- [ci] Skip libnccl-dev to work around llama-cpp-sys-2 linking bug
- [ci] Remove pre-installed libnccl-dev from CUDA container
- [providers] Link NCCL when llama-cpp-sys-2 compiles with GGML_USE_NCCL
Security
- [ci] Use correct security list-keychains syntax for macOS codesign
- [ci] Align macOS certificate import with working arbor pattern
[20260425.08] - 2026-04-25
Added
- [cron] Add heartbeat wake cooldown to prevent exec re-fire loop (#871)
Changed
- [config] Extract initialize_config() from discover_and_load()
Fixed
- [tests] Stabilize flaky memory_config_get test
- [ci] Pin NCCL version to match CUDA 12.4 container
- [ci] Match NCCL dev headers to pre-installed runtime version
- [ci] Skip libnccl-dev to work around llama-cpp-sys-2 linking bug
- [ci] Remove pre-installed libnccl-dev from CUDA container
- [providers] Link NCCL when llama-cpp-sys-2 compiles with GGML_USE_NCCL
[20260425.07] - 2026-04-25
Added
- [cron] Add heartbeat wake cooldown to prevent exec re-fire loop (#871)
Changed
- [config] Extract initialize_config() from discover_and_load()
Fixed
- [tests] Stabilize flaky memory_config_get test
- [ci] Pin NCCL version to match CUDA 12.4 container
- [ci] Match NCCL dev headers to pre-installed runtime version
[20260425.06] - 2026-04-25
Added
- [cron] Add heartbeat wake cooldown to prevent exec re-fire loop (#871)
Changed
- [config] Extract initialize_config() from discover_and_load()
Fixed
- [tests] Stabilize flaky memory_config_get test
- [ci] Pin NCCL version to match CUDA 12.4 container
[20260425.05] - 2026-04-25
Added
- [cron] Add heartbeat wake cooldown to prevent exec re-fire loop (#871)
Changed
- [config] Extract initialize_config() from discover_and_load()
Fixed
- [tests] Stabilize flaky memory_config_get test
[20260425.04] - 2026-04-25
Changed
- [config] Extract initialize_config() from discover_and_load()
Fixed
- [tests] Stabilize flaky memory_config_get test
[20260425.03] - 2026-04-25
Fixed
- [e2e] Stabilize flaky gemini, autoscroll, and settings-nav tests
[20260425.02] - 2026-04-25
Fixed
- [ci] Allow held NCCL packages to be upgraded
[20260425.01] - 2026-04-25
Fixed
- [ci] Add NCCL to build deps and fix flaky tests
[20260424.09] - 2026-04-24
Fixed
- Replace expect() with let-else in splitter tests
- [benchmarks] Update code_index bench to use free-function chunker API
[20260424.08] - 2026-04-24
[20260424.07] - 2026-04-24
Fixed
- Replace unwrap() with expect() in splitter tests
[20260424.06] - 2026-04-24
Fixed
- Remove needless Ok wrapper in refresh_qmd_index
[20260424.05] - 2026-04-24
[20260424.04] - 2026-04-24
Changed
- [splitter] Extract moltis-splitter crate for AST-aware chunking (#791)
Fixed
- [ci] Prevent releases.json from updating before GitHub release exists
- Revert releases.json to latest published release (20260421.05)
[20260424.03] - 2026-04-24
Added
- [skills] Add bundled skill category management to onboarding and settings (#829)
- [signal] Add signal-cli channel (#841)
- [home-assistant] Native Home Assistant integration crate (#827)
- [web-ui] Smart auto-scroll for chat messages (#846)
- [projects] Add code_index_enabled toggle to project settings (#837)
- [web] Add push-to-talk, VAD continuous listening, and voice settings (#303)
- [config] Layered config with defaults.toml and override-only user config (#864)
- [discord] Channel name pattern filtering and per-pattern overrides (#865)
- [config] Add default sub-agent presets (#844)
- [config] Add config compact command and auto-compact on startup
- [skills] MCP server management skill and post-install recipes (#840)
Changed
- [providers] Split openai_compat tests into submodules
- [config] Split loader tests into submodules (file-size limit)
- [gateway] Extract NoopSkillsService into services/skills.rs (file-size limit)
Removed
- Remove set -e from codesign-debug recipe that breaks just test
- [web] Remove stale accountId assertion from signal channel E2E test
- [ui] Wire up project combo dropdown in chat header (#847)
- [config] Remove unused imports from test submodule
Fixed
- [tools] Block exec approval bypass via env-var prefix injection (#822)
- [browser] Add diagnostic logging for container readiness failures (#820)
- [gateway] Downgrade broadcast log from debug to trace (#830)
- [chat] Correct push notification click-through URL (#831)
- [tools] Skip sysfs tmpfs mounts on WSL2 (#835)
- [chat] Preserve Gemini tool call metadata (#836)
- [providers] Normalize non-strict tool schema unions (#833)
- [providers] Apply Kimi router overrides in Fireworks integration tests (#832)
- [config] Resolve ${VAR} placeholders against [env] section and DB env vars (#834)
- [web] Repair gemini tool-signature and reasoning-toggle E2E tests
- [ci] Move release test job to self-hosted runner
- [skills] Repair embedded bundled skill discovery in release builds
- [mcp] Show re-auth button when OAuth server needs re-authentication (#852)
- [sandbox] Skip sysfs tmpfs mounts for missing paths (ARM/Raspberry Pi) (#853)
- [prompt] Move datetime from system message to user content for KV cache stability (#855)
- [providers] Deep-merge properties in schema union collapse, strip redundant boolean enum (#856)
- [channels] Update ALL variant count for Signal channel type
- [web] Align signal channel E2E test with actual modal text
- [web] Stabilize flaky E2E tests
- Enforce correct date in silent memory turn daily log filenames (#859)
- [macos] Configure release signing and notarization (#842)
- [web] Add vault-sealed banner to main app (#839)
- [skills] Materialize bundled skill scripts to disk for execution (#861)
- [web] Biome formatting and i18n parity for projects page
- [config] Derive Default for HomeAssistantConfig and collapse nested if
- [config] Allow unwrap in code_index tests
- [home-assistant] Clippy collapsible-if and field-reassign-with-default
- [benchmarks] Add missing deps for code_index bench
- [code-index] Replace redundant closure with PathBuf::from
- Repair code-index compilation errors in gateway and benchmarks
- Resolve remaining clippy errors from code-index feature
- [providers] Strip null from enum arrays for Fireworks AI (#862)
- [web] Repair chat-autoscroll and projects E2E tests
- [benchmarks] Wire qmd feature to moltis-qmd dep for –all-features CI
- [ci] Explicitly set draft: false on release upload to prevent orphaned drafts
- [agents] Add missing mode field to PromptRuntimeContext in tests
- [gateway] Collapse nested if for clippy
- [ctl] Clippy unnecessary_lazy_evaluations
- [graphql] Add missing SkillsService trait methods to MockSkills
- [gateway] Collapse nested if for clippy
[20260424.02] - 2026-04-24
Added
- [skills] Add bundled skill category management to onboarding and settings (#829)
- [signal] Add signal-cli channel (#841)
- [home-assistant] Native Home Assistant integration crate (#827)
- [web-ui] Smart auto-scroll for chat messages (#846)
- [projects] Add code_index_enabled toggle to project settings (#837)
Changed
- [providers] Split openai_compat tests into submodules
Removed
- Remove set -e from codesign-debug recipe that breaks just test
- [web] Remove stale accountId assertion from signal channel E2E test
- [ui] Wire up project combo dropdown in chat header (#847)
Fixed
- [tools] Block exec approval bypass via env-var prefix injection (#822)
- [browser] Add diagnostic logging for container readiness failures (#820)
- [gateway] Downgrade broadcast log from debug to trace (#830)
- [chat] Correct push notification click-through URL (#831)
- [tools] Skip sysfs tmpfs mounts on WSL2 (#835)
- [chat] Preserve Gemini tool call metadata (#836)
- [providers] Normalize non-strict tool schema unions (#833)
- [providers] Apply Kimi router overrides in Fireworks integration tests (#832)
- [config] Resolve ${VAR} placeholders against [env] section and DB env vars (#834)
- [web] Repair gemini tool-signature and reasoning-toggle E2E tests
- [ci] Move release test job to self-hosted runner
- [skills] Repair embedded bundled skill discovery in release builds
- [mcp] Show re-auth button when OAuth server needs re-authentication (#852)
- [sandbox] Skip sysfs tmpfs mounts for missing paths (ARM/Raspberry Pi) (#853)
- [prompt] Move datetime from system message to user content for KV cache stability (#855)
- [providers] Deep-merge properties in schema union collapse, strip redundant boolean enum (#856)
- [channels] Update ALL variant count for Signal channel type
- [web] Align signal channel E2E test with actual modal text
- [web] Stabilize flaky E2E tests
- Enforce correct date in silent memory turn daily log filenames (#859)
- [macos] Configure release signing and notarization (#842)
- [web] Add vault-sealed banner to main app (#839)
- [skills] Materialize bundled skill scripts to disk for execution (#861)
- [web] Biome formatting and i18n parity for projects page
- [config] Derive Default for HomeAssistantConfig and collapse nested if
- [config] Allow unwrap in code_index tests
- [home-assistant] Clippy collapsible-if and field-reassign-with-default
- [benchmarks] Add missing deps for code_index bench
- [code-index] Replace redundant closure with PathBuf::from
- Repair code-index compilation errors in gateway and benchmarks
- Resolve remaining clippy errors from code-index feature
- [providers] Strip null from enum arrays for Fireworks AI (#862)
- [web] Repair chat-autoscroll and projects E2E tests
- [benchmarks] Wire qmd feature to moltis-qmd dep for –all-features CI
[20260424.01] - 2026-04-24
Added
- [skills] Add bundled skill category management to onboarding and settings (#829)
- [signal] Add signal-cli channel (#841)
Changed
- [providers] Split openai_compat tests into submodules
Removed
- Remove set -e from codesign-debug recipe that breaks just test
- [web] Remove stale accountId assertion from signal channel E2E test
Fixed
- [tools] Block exec approval bypass via env-var prefix injection (#822)
- [browser] Add diagnostic logging for container readiness failures (#820)
- [gateway] Downgrade broadcast log from debug to trace (#830)
- [chat] Correct push notification click-through URL (#831)
- [tools] Skip sysfs tmpfs mounts on WSL2 (#835)
- [chat] Preserve Gemini tool call metadata (#836)
- [providers] Normalize non-strict tool schema unions (#833)
- [providers] Apply Kimi router overrides in Fireworks integration tests (#832)
- [config] Resolve ${VAR} placeholders against [env] section and DB env vars (#834)
- [web] Repair gemini tool-signature and reasoning-toggle E2E tests
- [ci] Move release test job to self-hosted runner
- [skills] Repair embedded bundled skill discovery in release builds
- [mcp] Show re-auth button when OAuth server needs re-authentication (#852)
- [sandbox] Skip sysfs tmpfs mounts for missing paths (ARM/Raspberry Pi) (#853)
- [prompt] Move datetime from system message to user content for KV cache stability (#855)
- [providers] Deep-merge properties in schema union collapse, strip redundant boolean enum (#856)
- [channels] Update ALL variant count for Signal channel type
[20260423.01] - 2026-04-23
Added
- [skills] Add bundled skill category management to onboarding and settings (#829)
Fixed
- [tools] Block exec approval bypass via env-var prefix injection (#822)
- [browser] Add diagnostic logging for container readiness failures (#820)
- [gateway] Downgrade broadcast log from debug to trace (#830)
- [chat] Correct push notification click-through URL (#831)
- [tools] Skip sysfs tmpfs mounts on WSL2 (#835)
- [chat] Preserve Gemini tool call metadata (#836)
- [providers] Normalize non-strict tool schema unions (#833)
- [providers] Apply Kimi router overrides in Fireworks integration tests (#832)
- [config] Resolve ${VAR} placeholders against [env] section and DB env vars (#834)
- [web] Repair gemini tool-signature and reasoning-toggle E2E tests
- [ci] Move release test job to self-hosted runner
- [skills] Repair embedded bundled skill discovery in release builds
[20260422.01] - 2026-04-22
Added
- [skills] Add bundled skill category management to onboarding and settings (#829)
Fixed
- [tools] Block exec approval bypass via env-var prefix injection (#822)
- [browser] Add diagnostic logging for container readiness failures (#820)
- [gateway] Downgrade broadcast log from debug to trace (#830)
- [chat] Correct push notification click-through URL (#831)
- [tools] Skip sysfs tmpfs mounts on WSL2 (#835)
- [chat] Preserve Gemini tool call metadata (#836)
- [providers] Normalize non-strict tool schema unions (#833)
- [providers] Apply Kimi router overrides in Fireworks integration tests (#832)
- [config] Resolve ${VAR} placeholders against [env] section and DB env vars (#834)
- [web] Repair gemini tool-signature and reasoning-toggle E2E tests
[20260421.05] - 2026-04-21
Fixed
- [code-index] Use discover_opts with permissive trust for gix
- [web] Render inline markdown in table cells
- [code-index] Set required_trust to Reduced for gix discovery
- [code-index] Use struct init for clippy field_reassign_with_default
- [code-index] Use open_opts with forced Trust::Full for gix
- [code-index] Skip git-dependent tests when .git is absent
- [telegram] Infer MIME from filename when Telegram sends octet-stream (#819)
- [ci] Install git before checkout in CUDA containers
[20260421.04] - 2026-04-21
Fixed
- [ci] Make safe.directory step non-fatal when git is absent
[20260421.03] - 2026-04-21
Fixed
- [ci] Restore original step order, fix gix open, revert npm flag
[20260421.02] - 2026-04-21
Fixed
- [ci] Gix safe.directory, read_ops file_path regression, npm –ignore-scripts
- [tools] Empty file_path falls through to read_primary, whitespace rejected
[20260421.01] - 2026-04-21
Added
- [channels] Centralized command registry for all channels (#794)
- Gemini thought_signature round-tripping and schema validation fixes (#795)
- Self-improving agent loop — skills, memory lifecycle, deployment (#803)
- [web] Add GitHub Issues and Discussions links with count badges (#806)
- [sandbox] Install Node.js 22 LTS via NodeSource (#807)
- [web] Render markdown as HTML in chat messages (#808)
- [skills] Bundle 101 default skills with category UI and format fallback (#797)
Changed
- [tools] Split skill_tools.rs into submodules
Fixed
- [ci] Add safe.directory for container jobs (gix ownership check)
- [slack] Strip leading slash from commands before gateway dispatch (#804)
- [mcp] Implement legacy SSE transport for endpoint discovery (#805)
- [ci] Biome formatting and file size allowlist
- [sandbox] Verify image in Podman store after BuildKit build (#811)
- [providers] Disable strict tools for Fireworks Kimi router (#812)
- [ci] Npm –ignore-scripts and git install ordering
- [ci] Pass safe.directory to gix via env vars in rust-ci container
[20260420.02] - 2026-04-20
Fixed
- [ci] Address main rust failures
[20260420.01] - 2026-04-20
Fixed
- [ci] Handle .ts locale files in i18n-check on Node <22
[20260419.01] - 2026-04-19
Added
- [code-index] Add code indexing crate with builtin SQLite+FTS5 backend (#771)
- [config] Add server.external_url for reverse proxy WebAuthn (#785)
Changed
- Add thiserror Error types to 8 library crates (#792)
Fixed
- [task_list] List all tasks by default and add list_lists action (#779)
- [sandbox] Add observability and prevent repeated package provisioning (#784)
[20260417.02] - 2026-04-17
Added
- [nostr] Add NIP-59 Gift Wrap support for private DMs (#763)
- [matrix] Add OIDC authentication via Matrix Authentication Service (#730)
Removed
- [tests] Remove hardcoded secret fixtures (#768)
Fixed
- [providers] Strip $schema recursively and downgrade fallback log level (#762)
- [matrix] Retry sync loop on transient connection failures (#761)
- [e2e] Stabilize archived-sessions and nostr channel tests
- [sandbox] Skip sysfs tmpfs overlays on Podman (#765)
- [slack] Register /commands HTTP endpoint for slash commands (#767)
- [matrix] Restore main CI after OIDC changes
[20260417.01] - 2026-04-17
Added
- [web] Add reasoning effort toggle to chat toolbar (#750)
Fixed
- [providers] Sanitize MCP tool schemas regardless of JSON Schema draft (#746)
- [node-host] Install rustls CryptoProvider before wss:// connections (#749)
- [providers] Prune orphaned required entries from tool schemas (#751)
- [web] Satisfy biome and format gates
- [testing] Avoid CUDA features in Darwin test recipes
- [web] Stabilize session delete flow
- [ci] Harden linux dependency installer
[20260416.02] - 2026-04-16
Added
- [providers] Configurable context windows + oldest-first compaction (#737)
Changed
- [tests] Split oversized Rust test modules
Fixed
- [ci] Repair MCP e2e and iOS chat event handling
- [providers] Detect Grok 3/4 as reasoning-capable models (#741)
- [nostr] Replace tokio RwLock with std RwLock to prevent panic (#742)
- [providers] Restore type annotations stripped by schema canonicalization (#740)
- [ci] Retry Linux dependency installs and align Darwin validation
- [ci] Install openssh client in Linux build container
[20260415.01] - 2026-04-15
Added
- Allow GUIDELINES.md file override for hardcoded tool guidelines
- [channels] Add WhatsApp to default offered channels
- [whatsapp] Auto-approve owner, set device name, wrap buttons
- [whatsapp] Handle PairSuccess event for instant QR dismissal
- [whatsapp] Make Account ID optional, default to “main”
- [web] Add Slack channel to onboarding wizard
Changed
- Clean up provider tests and onboarding UI
- Move implementation code out of mod.rs and lib.rs (#731)
Removed
- [web] Remove duplicate assets warn import
Fixed
- [graphql] Make sessionKey required for all chat operations
- [graphql] Address PR review — harden subscription filter and tests
- [onboarding] Show version in server footer
- [web] Prefer embedded assets over stale share dir
- [chat] Preserve sender names in user messages
- [providers] Address sender-name review feedback
- Move return inside empty-guard in append_guidelines_section (PR 714 review)
- [providers] Skip strict tool schemas for Google/Gemini via OpenRouter (#716)
- [providers] Also detect googleapis.com URLs for non-strict tools
- [providers] Collapse type arrays in strict mode patching (#716)
- [whatsapp] Upgrade whatsapp-rust ecosystem 0.2 → 0.5
- [whatsapp] Use time crate for timestamps, fix DashMap sync key ordering
- [whatsapp] Add QR code polling fallback for pairing
- [whatsapp] Skip duplicate account start, add QR polling e2e test
- [whatsapp] Use res.payload not res.result for QR polling
- [whatsapp] Render QR code as SVG in polling response
- [whatsapp] Skip history sync on pairing
- [whatsapp] Detect pairing completion via polling
- [channels] Add missing useRef import in page-channels.js
- [whatsapp] Set OS name to Moltis, add no-sync hints
- [whatsapp] Fix mangled no-sync hints in onboarding view
- [whatsapp] Auto-approve both PN and LID JIDs, add debug logging
- [whatsapp] Fix QR code not displaying when Account ID is empty
- [whatsapp] Instant pairing feedback and owner auto-approve
- [whatsapp] Subscribe to channel events in onboarding WS
- [whatsapp] Only auto-approve owner PN JID, not LID
- [whatsapp] Use PN JID for self-chat replies instead of LID
- [whatsapp] Show phone number in self-chat footer instead of LID
- [channels] Don’t drop channel_user WS broadcast
- [whatsapp] Use bare phone number as self-chat session ID
- [agents] Strip trailing stop tokens leaked by LLMs as content
- [agents] Address PR review comments
- [providers] Repair openai compat CI regressions (#722)
- [agents] Collapse nested if per clippy collapsible_if lint
- [web] Restore setup onboarding alias
- [providers] Add null to enum arrays for nullable optional props (#724)
- [mcp] Treat any HTTP response as alive in health check (#732) (#733)
- [chat] Broadcast user_message event so API-sent messages appear in web UI (#734)
[20260414.02] - 2026-04-14
Changed
- [node-exec] Merge node exec types into owners
- [web] Remove prompt memory toolbar from chat header
Fixed
- [web] Harden flaky e2e tests for cron delete and identity autosave
- [sandbox] Mask /proc and /sys host metadata in Docker/Podman containers
- [sandbox] Address PR review — strengthen test assertions
- [channels] Use rand::RngExt for random_range after rand 0.10 upgrade
- Update rand 0.10 imports across workspace
- [onboarding] Surface local-llm and LM Studio in recommended providers
- [auth] Update password minimum length strings from 8 to 12 characters
- [slack] Bump slack-morphism to 2.20 to enable TLS for socket mode
- Regenerate Cargo.lock for reqwest 0.13 and add query feature
- [web] Restore clear button for main session modal (#671)
[20260414.01] - 2026-04-14
Changed
- [node-exec] Merge node exec types into owners
- [web] Remove prompt memory toolbar from chat header
Fixed
- [web] Harden flaky e2e tests for cron delete and identity autosave
- [sandbox] Mask /proc and /sys host metadata in Docker/Podman containers
- [sandbox] Address PR review — strengthen test assertions
- [channels] Use rand::RngExt for random_range after rand 0.10 upgrade
- Update rand 0.10 imports across workspace
- [onboarding] Surface local-llm and LM Studio in recommended providers
- [auth] Update password minimum length strings from 8 to 12 characters
[20260413.06] - 2026-04-13
Fixed
- [web] Gate openai live e2e server on api key
[20260413.05] - 2026-04-13
Added
- [voice] Add local whisper web setup
- [chat] Show cached input tokens
Changed
- [providers] Normalize openai schemas with schema crates
- [providers] Move schema normalization out of mod.rs
- [chat] Unify usage propagation paths
Removed
- [httpd] Remove unused route import
- [httpd] Drop unused get import
Fixed
- [providers] Sanitize openai tool schemas
- [providers] Address PR review feedback
- [providers] Keep openai compat module under file limit
- [providers] Avoid expect in schema tests
- [web] Restore nostr channel icons
- [voice] Address PR review feedback
- [chat] Satisfy clippy in cached token tests
- [agents] Preserve streamed cache token usage
- [openai] Add live e2e coverage and schema guard
- [sessions] Wire archived sessions through gateway and web ui
- [sessions] Address archived review feedback
- [sessions] Allow archiving non-current channel chats
- [sessions] Preserve orphaned search hits
- [sessions] Allow unarchiving active channel chats
[20260413.04] - 2026-04-13
Changed
- Split oversized rust modules
- Split remaining oversized rust modules
- Remove replaced module entrypoints
Removed
- [agents] Remove duplicate tool arg match arm
Fixed
- [providers] Preserve native tool arg types
- [gateway] Repair split module blockers
- Restore post-merge validation
- Repair local lint regressions
- [chat] Clean up split validation fallout
- [gateway] Remove split warning debt
- [httpd] Restore split module boundaries
- [httpd] Drop stale server re-export
- [httpd] Repair test and bridge lint fallout
- [httpd] Restore vault test imports
- [telegram] Mark byte-truncated documents
- [httpd] Repair ngrok server split
- [provider-setup] Restore ollama validation payload
- [gateway] Drop duplicate broadcaster default impl
- [gateway] Remove stale mcp service doc comment
- [httpd] Move runtime-only route import
- [tools] Scope apple sandbox helpers to macos
- [tools] Restore sandbox test imports
- [ci] Restore main build stability
[20260413.03] - 2026-04-13
Fixed
- [gateway] Resolve release CI regressions
[20260413.02] - 2026-04-13
Added
- [voice] Add base_url config for OpenAI TTS and Whisper STT providers
Changed
- [browser] Type browserless API version as enum
- [nodes] Extract core types and constants to dedicated crate
- [nodes] Move pure ssh and env helper functions to node-exec-types
- [mcp] Extract config parsing functions to moltis-mcp
- [mcp] Extract MCP-agent tool bridge to dedicated crate
- Group lock-free broadcast state into Broadcaster struct
- [tools] Unify cron schema field helper
Fixed
- [telegram] Persist inbound documents
- [review] Make inbound document replay robust
- [browser] Add Browserless v2 websocket fallback support
- [browser] Change log level from warn to debug for sandboxed browser websocket connection failures
- Complete MCP bridge extraction and address Greptile review
- [broadcast] Make seq private, add #[must_use] to next_seq, add tests
- [chat] Serialize data dir override tests
- [voice] Address PR review feedback
- [tools] Hide wasm internals and relax cron schema
- [tools] Simplify cron schema unions
- [agents] Preserve legacy tool-call compatibility
Security
- [nodes] Define NodeInfoProvider trait for decoupling
[20260413.01] - 2026-04-13
[20260412.01] - 2026-04-12
Added
- [discord] Handle inbound voice and image attachments
- [hooks] Include channel provenance in payloads
- [web] Add Projects section to Settings sidebar navigation
- [tools] Native filesystem tools (Read, Write, Edit, MultiEdit, Glob, Grep)
- [tools] Typed error taxonomy for Read (not_found / permission_denied / too_large / not_regular_file)
- [tools] Phase 1 polish (byte cap, session key, fs-tools feature, contract tests)
- [tools] CRLF-tolerant Edit recovery for fs tools
- [tools] Per-session FsState with must-read-before-write + re-read loop detection
- [tools] [tools.fs] config + path allow/deny policy (phase 4)
- [tools] Checkpoint_before_mutation + binary base64 + respect_gitignore + docs page
- [tools] Phase 2 sandbox bridge for Read/Write/Edit/MultiEdit/Glob
- [tools] Phase 2b — Grep sandbox routing
- [tools] Phase 3c — adaptive Read paging coupled to context window
- [chat] Deterministic compaction with budget discipline
- [chat] Pluggable compaction modes with config + docs
- [chat] Implement recency_preserving compaction mode
- [chat] Implement structured compaction mode
- [chat] Surface mode + token usage in compaction broadcasts
- [chat,web] Surface compaction mode + tokens in UI and channels
- [compaction] Add chat.compaction.show_settings_hint opt-out
- [tools] Claude Code compat — BOM strip, binary extensions, smart quotes, mtime tracking
- [tools] PDF text extraction in Read tool
- [tools] Image dispatch in Read + Grep context alias + Edit param aliases
- [memory] Add prompt memory styles
- [memory] Extend config surfaces
- [provider-setup] Remove automatic model probe, add manual Test button
- [tools] Auto-page reads and serialize fs mutations
- [tools] Expose read continuation offsets
- [tools] Surface sandbox scan truncation
- [tools] Wire layered tool policy into runtime with per-provider, per-agent, per-channel, and per-sender support
- [tools] Add sandbox tools_policy as layer 6 in policy resolution
- [channels] Add Nostr DM channel support
- [channels] Add Nostr web UI, E2E tests, and documentation
- [channels] Add Nostr to onboarding flow
- [nostr] Add metrics, NIP-44 decryption, and integration tests
- [website] Add slack, matrix, and nostr channels
- [website] Add Nostr channel SVG icon
- [chat] Add summary budget discipline for compaction
- [website] Add community quote from discussion #680 and make quotes horizontally scrollable
- [web] Add option to disable terminal in Web UI
- [auth] Add brute-force protection with IP ban and account lockout
Changed
- [chat] Align multimodal rewrite updates
- [telegram] Extract STT setup hint constant
- [discord] Reuse inbound downloader per handler
- [chat] Simplify deterministic compaction module
- [chat] Split compaction_run into per-strategy submodules
- [tools] Use ripgrep crates for fs grep
- [tools] Split read.rs into read/ module with pdf.rs and image.rs
- [tools] Centralize sandbox filesystem access
- [media] Sniff mime and expose sandbox file ops
- [tools] Add native host sandbox file ops
- [tools] Add container-aware fs transports
- [tools] Stream OCI file transfers
- [tools] Stream OCI reads from cp
Fixed
- [agents] Dispatch ToolResultPersist hooks
- [agents] Sanitize ToolResultPersist tool names
- [agents] Handle Z.AI text tool calls and dedupe providers
- [gateway] Address PR comment follow-ups
- [hooks] Honor MessageReceived actions
- [chat] Harden MessageReceived hook handling
- [telegram] Avoid placeholder voice fallbacks
- [telegram] Preserve caption when STT unavailable
- [discord] Address review feedback
- [discord] Log stt-unavailable voice notes
- [common] Block mapped ipv6 ssrf bypass
- [hooks] Address review feedback
- [chat] Warn on invalid channel bindings
- [hooks] Address remaining provenance feedback
- [agents] Warn on invalid hook channel context
- [plugins] Refresh hook docs and logger timestamps
- [common] Update message received fixtures
- [web] Store container ref in teardownProjects, remove unused settings_projects route
- [tools] Fs tools require absolute paths, add workspace_root for Glob/Grep
- [agents] Warn on tool name collision in ToolRegistry::register*
- [compaction] Address code review issues #1-7
- [compaction] Address Greptile PR #653 review comments
- [chat] Correct recent_messages_preserved flag in compaction
- [compaction] Strip preamble and directive from re-compaction extraction
- [compaction] Invert candidate order so bullets outlive plain lines
- [compaction] Protect summary tags from budget-pressure dropping
- [compaction] Address PR #653 review findings
- [chat] Memory-file summary lookup across all compaction modes
- [compaction] Extract_summary_body picks newest summary on iterative re-compaction
- [chat] Wire chat.compaction.threshold_percent into auto-compact trigger
- [compaction] Address Greptile round-2 review on #653 (P1 + P2)
- [web] Track compacting status message per-session to avoid removing compact card
- [compaction] Restore 0.95 default threshold + remove dead code
- [chat] Close compact/store race and tag auto_compact broadcast paths
- [compaction] Address Greptile round-5 P2 findings on #653
- [tools] Enforce exec allowlist when approval_mode is off
- [tools] Deny dangerous commands in off mode instead of hanging
- [tools] Warn when safe-bin bypasses explicit allowlist in off mode
- [config] List tools.policy.profile in preset silent-policy warning
- [tools] Address filesystem review comments
- [tools] Address Greptile round-2 findings (max_read_bytes wiring, sandbox note_fs_mutation, truncated semantics)
- [tools] Address Greptile round-3 findings (Grep policy, must-read sandbox, Glob root deny)
- [tools] Address Greptile round-4 findings (binary read tracking, sandbox Write new-file)
- [tools] Sandbox Grep post-filters results through path policy
- [tools] PDF/image dispatch now enforces path policy, sandbox guard, and FsState recording
- [memory] Tighten qmd and rpc validation
- [chat] Honor [skills] enabled=false at runtime
- [chat] Replace test Mutex<()> with Semaphore
- [chat] Reuse existing config in context skill discovery
- [httpd] Redirect remote setup traffic to onboarding wizard
- [agents] Detect and break tool-call reflex loops (#658)
- [agents] Address Greptile review feedback on #658
- [agents] Loop detector handles mixed-outcome batches correctly (#658)
- [agents] Treat success=false without error field as failure (#658)
- [e2e] Wait for Preact render flush in matrix senders test
- [web] Show Clear button for main session in modal
- [memory] Add missing runtime module
- [web] Remove unused VALIDATION_HINT_RUNNING_TEXT, clear stale test results
- [gateway] Unify config-override test lock to prevent flaky test
- [tools] Smart-quote recovery preserves file content + sandbox grep uses PCRE
- [tools] Scope tar helper to tests
- [tools] Preserve loop warnings for auto-paged reads
- [chat] Populate sender_id in channel binding and runtime context
- [chat] Read sandbox state from runtime context in PolicyContext
- [agents] Add missing channel_sender_id field in test
- [tools] Use struct init instead of field reassign in test
- [tools] Expand profile field in provider, sender, and sandbox policy layers
- [httpd] Start stored channels on vault unseal
- [httpd] Add missing continue in unsupported channel type guard
- [config] Revert unintended matrix named field promotion
- [nostr] Fix integration test DM round-trip reliability
- [nostr] Prevent panic on UTF-8 boundary when truncating large DMs
- [website] Use official Nostr protocol logo (CC0, mbarulli/nostr-logo)
- [web] Preserve Nostr OTP settings on edit modal save
- [nostr] Resolve clippy collapsible_match and len_without_is_empty
- [nostr] Implement OTP challenge initiation and DM delivery
- [nostr] Implement OTP verification path for code replies
- [nostr] Use std::sync::RwLock for accounts map to avoid blocking panic
- [web] Remove channel-error class from conditionally-rendered error divs
- [chat] Correct budget accounting bugs in compress_summary
- [chat] Address greptile review feedback (greploop iteration 1)
- [channels] Finish discussion 425 follow-ups
- [channels] Address review feedback
- [channels] Harden channel command authorization and session scoping
- [gateway] Truncate approval previews safely
- [channels] Chunk unicode safely
- [chat] Isolate sqlite memory tests
- [web] Stabilize mocked channel refresh
- [channels] Address greptile review feedback
- [channels] Truncate command in approve/deny confirmation messages
- [auth] Second-pass security hardening
- [web] Address PR review comments
- [config] Preserve TOML section order on web UI save
- Apply local fixes
Security
- [config] Warn when preset tool policies are set but tools.policy is empty
- [nostr] Address PR review comments
- [auth] Harden remote access with 9 security improvements
[20260410.01] - 2026-04-10
Added
- [oauth] Log loopback redirect URI rewrites at debug level
- [skills] Ship native read_skill tool
- [skills] Harden read_skill with assets/, binary files, and metadata surfacing
Changed
- [oauth] Share loopback redirect normalizer and apply to provider setup
- [oauth] Eliminate dead branches in normalize_loopback_redirect
Removed
- [web] Stabilize node selector and fork delete e2e
Fixed
- [gateway] Dcg-guard PATH augmentation and loud missing-dcg warning
- [gateway] Refresh stale dcg-guard files and use async subprocess
- [gateway] Dcg-guard HOME fallback and unconditional startup log
- [agents] Suppress auto-continue after substantive final answer
- [agents] Address review feedback on auto-continue fix
- [mcp] Normalize loopback redirect URIs to http for OAuth registration
- [provider-setup] Normalize pre-loaded loopback redirect URIs
- [skills] Address Greptile review feedback on read_skill
- [skills] Address greptile review feedback (greploop iteration 2)
- [skills] Cap skill body size in read_primary (greploop iteration 3)
- [skills] Per-subdir sidecar cap + data_dir-scoped discoverer
- [voice] Honor whisper.model and whisper.language in STT factory
Security
- [hooks] Pin dcg install to tag and verify checksum
[20260409.04] - 2026-04-09
Added
- [providers] Add Alibaba Cloud Coding Plan provider
Fixed
- [ci] Avoid dynamic provider secrets
[20260409.03] - 2026-04-09
Removed
- Remove redundant http client fallback check
Fixed
- [common] Ensure User-Agent survives all HTTP client fallback paths
- [test] Address PR review — clarify comment and assert no Error events
- [crons] Persist schedule field values across modal re-renders
- [crons] Read schedule fields as snake_case from server responses
- [providers] Deliver MiniMax system prompt via first user message
- [tests] Use streaming probe for OpenAI integration tests
- [providers] Bump GPT-5 probe output cap to 16 tokens
- [tests] Use Secret
for API keys in model discovery tests - [providers] Handle multimodal content in MiniMax system prompt rewrite
[20260409.02] - 2026-04-09
Added
- [msteams] Comprehensive Teams channel implementation
- [providers] Add Gemini 3.x models to catalog and update capability detection
- [providers] Add ModelCapabilities struct to ModelInfo and DiscoveredModel
- [chat] Use ModelCapabilities in API responses instead of provider lookups
Fixed
- [web] Integrate Tailscale Funnel into Teams channel setup
- [web] Remove ‘Requires public URL’ badge from Teams card
- [web] Simplify Teams onboarding now that Remote Access step exists
- [msteams] Address PR review feedback
- [msteams] Use Graph token for reactions and thread context
- [msteams] Prevent streaming retry storm and URL injection in search
- [web] Disambiguate OAuth E2E selector for model picker
- [web] Keep new chats at top of sidebar
- Auto-allow direnv in superset worktree setup
- Load BOOT.md per-session via system prompt instead of broken hook (#594)
- Remove stale boot-md assertion from discover_hooks test
- Update model count assertion for gemini-3 reasoning variants
- [installer] Address PR review feedback
- [browser] Include Podman in container availability check
- [browser] Update stale comment to include Podman
- [gateway] Narrow skill and memory watch roots
- [gateway] Avoid blocking skill watcher refresh
- [providers] Extract MiniMax system messages to top-level field
- [providers] Warn on non-string MiniMax system message content
- [providers] Add PartialEq/Eq to ModelCapabilities, use infer() in tests
- [providers] Narrow gemma exclusion to gemma-3n- only
- [agents] Surface workspace prompt truncation
- [gateway] Address review feedback
- [gateway] Match workspace prompt normalization
- [agents] Make workspace file truncation limit configurable (#593)
- Replace magic constant with ChatConfig::default() fallback
- Eliminate double chars().count() and add zero-value validation
- Report truncation when max_chars is zero
- Remove duplicate workspace_file_max_chars key in schema map
- [tools] Wire ExecConfig timeout and max_output_bytes to ExecTool
- [tools] Make timeout schema description reflect configured default
- [providers] Resolve 404 when selecting Ollama model in web UI
- [web] Preserve ollama pull hint in humanizeProbeError
- [providers] Forward auth header in Ollama native probe fallback
- Align auth middleware tests with gateway state
[20260409.01] - 2026-04-09
Added
- [matrix] Add slash command support
- [models] Make model detection opt-in and add stop button
Fixed
- [matrix] Match help command by exact name, not prefix
- [models] Abort probe tasks on cancel, show feedback, await RPC
- [tls] Include lan bind SANs in auto-generated certs
- [tls] Address PR review feedback
- [agents] Use system message for auto-continue nudge instead of user message
- [common] Add default User-Agent header to shared HTTP client
- [common] Use MOLTIS_VERSION for default user-agent and apply headers in apply_proxy
- [agents] Keep auto-continue nudge as user message
- [provider-setup] Include lmstudio in known_providers and replace ollama name checks
- [provider-setup] Add dedicated local_only field to KnownProvider
- Harden superset setup envrc handling
[20260408.01] - 2026-04-08
Added
- [agents] Auto-continue when model stops mid-task + max iterations UX
- [config] Make auto-continue tool-call threshold configurable
Fixed
- Address PR review — translatable continue message, document tool-call threshold
- Guard auto-continue against min_tool_calls=0 usize tautology
- [minimax] Restore system prompts and null tool args
- [providers] Discover live anthropic models
- [providers] Mark anthropic recommendations globally
[20260407.01] - 2026-04-07
Added
- [webhooks] Add generic webhook ingress for triggering AI agents
- [web] Link to Hoppscotch for webhook testing
- [web] Add CORS to webhook ingress and copy-curl button
- [website] Add Webhooks to landing page features
Changed
- [web] Extract webhooks nav icon to external SVG file
Fixed
- [cli] Report release version in –version output
- [providers] Propagate cache tokens in Responses API and custom providers
- [providers] Read cached_tokens from input_tokens_details in non-streaming Responses SSE
- [agents] Match provider-specific context window error strings
- [chat] Honor public sessionKey in GraphQL flows
- [chat] Address PR review — safer mock assertions and precedence test
- [web] Use globe icon for webhooks settings nav
- [web] Use ModelSelect and ComboSelect for webhook agent/model fields
- [web] Use public URL (ngrok/tailscale) for webhook endpoint display
- Improve webhook test script with verbose output and TLS support
- Use OnceLock for webhook state fields instead of Arc::get_mut
- [web] Show Hoppscotch link below webhook list, not only in empty state
- [web] Match webhooks layout to cron/sandbox and fix nav icon
- [web] Add missing space before Hoppscotch link
- [web] Constrain webhooks list width with max-w-form
- [web] Revert max-w-form, keep webhooks list full width
- [web] Add webhooks icon to settings nav via components.css
- [web] Recommend curl and Hoppscotch desktop for webhook testing
- Use HeaderValue::from_static for CORS headers, remove warning
- [web] Improve webhook test command button and footer text
- Don’t dedup generic webhooks by body hash, rebuild i18n
- Address PR review comments
- [security] Redact auth secrets from webhook API responses
- Drain unprocessed webhook deliveries on worker startup
- Include ‘processing’ deliveries in crash-recovery drain
- Gitlab_token auth config key mismatch, add regression tests
- [security] Fail closed on non-parseable Stripe signature timestamp
- Enable foreign_keys pragma and explicit cascade delete
- [ci] Restore CHANGELOG.md to main state (changelog guard)
- Forward agent_id to chat.send_sync in webhook worker
- Enforce CIDR allowlist, set foreign_keys on pool options
- [security] Gate forwarded headers on behind_proxy, fix PagerDuty multi-sig
- Use ConnectInfo for direct IP, disable source_profile on edit
- [webhooks] Harden webhook execution and secrets
- Proactive audit — 6 issues found and fixed
- Wrap cascade deletes in transactions to prevent partial data loss
- Resolve settings nav CI regression
Security
- Add webhooks feature documentation
[20260406.05] - 2026-04-06
Added
- [openclaw-import] Convert non-default agents to spawn_agent presets
Fixed
- [web] Allow session sidebar links to open in new tabs
- [web] Tighten session sidebar link accessibility
- [docker] Add missing default features to Dockerfile build
- [docker] Use default features instead of explicit list
[20260406.04] - 2026-04-06
Added
- [website] Add provider/channel pills section and update branding
- [website] Add positioning, how-it-works, use cases, and community quote
Changed
- [providers] Avoid quadratic SSE buffer copies
- [providers] Align copilot stream error handling
Fixed
- [providers] Route Copilot enterprise tokens via proxy endpoint (#352)
- [providers] Address PR review comments on Copilot enterprise
- [providers] Harden Copilot enterprise proxy security
- [providers] Reject bare IP addresses in Copilot proxy-ep
- [providers] Address Copilot enterprise review feedback
- [providers] Stream enterprise copilot responses
- [website] Crop MiniMax icon, grayscale raster icons, official GraphQL logo
- [website] Add Discord source links to community quote
- [website] Update LoC stats on security page
- [website] Update LoC stats in all locale files
- [website] Regenerate all locale files with new homepage sections
- [website] Update i18n titles and regenerate all locale files
- [website] Translate new homepage sections in all 9 locale files
- [website] Translate remaining English strings in all locale files
- [website] Localize injected nav tabs
- [website] Address greptile review feedback
- [website] Sync i18n builder with locale pages
- [website] Correct i18n generator keys
Security
- [providers] Redact CopilotTokenResponse token in Debug output
[20260406.03] - 2026-04-06
Fixed
- [web] Restore all-features build
[20260406.02] - 2026-04-06
Fixed
- [web] Map config reload errors explicitly
[20260406.01] - 2026-04-06
Added
- [cron] Auto-clean orphaned sessions and prune sandbox containers
Fixed
- [swift-bridge] Await embedded httpd shutdown
- [cron] Use time crate for retention math and fix named-session guard
- [sandbox] Include remove_image_override in cleanup_session
- [cron] Skip pruning cycle when session key lookup fails
- [web] Reload offered channels from config
- [ci] Await channel preload in settings e2e
[20260405.06] - 2026-04-05
Security
- Add GitHub artifact attestations to release workflow
[20260405.05] - 2026-04-05
[20260405.04] - 2026-04-05
[20260405.03] - 2026-04-05
Fixed
- [web] Restore matrix onboarding icon
[20260405.02] - 2026-04-05
Added
- [providers] Add zai-code provider for Z.AI Coding plan
- [tools] Add cross-session search recall
- [tools] Add automatic edit checkpoints
- [projects] Harden context loading
- [skills] Add portable bundle quarantine flow
- [exec] Add ssh remote routing
- [web] Add skills bundle ui and ssh target visibility
- [web] Clarify ssh execution targets
- [ssh] Add managed deploy keys and targets
- [nodes] Add remote exec doctor panel
- [ssh] Harden managed targets and host pinning
- [nodes] Repair active ssh host pins from doctor
- [ssh] Add actionable runtime failure hints
- [web] Add tools overview to settings
- [web] Allow renaming channel-bound sessions
- [security] Add GPG signing for release artifacts
- [security] Add release verification script
- [gateway] Add channel settings agent tool
- [providers] Collapse model lists, hide legacy models, add recommended flag
- [web] Add live remote access settings
- [remote] Improve onboarding public access flow
- [tools] Add Firecrawl integration for web scraping and search
- [web] Add recommended provider tier in onboarding and docs guide
- [config] Add upstream_proxy for application-level HTTP proxy support
- [channels] Add Matrix channel integration
- [matrix] Complete channel parity and web ui coverage
- [matrix] Add encrypted chat support and vault-backed channel secrets
- [matrix] Add account ownership mode
- [matrix] Harden ownership recovery flow
- [matrix] Add generic channel location fallback
Changed
- [ssh] Use secrecy for imported key material
- Replace vendored sqlx-sqlite with git dependency
Removed
- [web] Remove unused gon import
Fixed
- [providers] Address PR review comments for zai-code
- [vault] Allow unencrypted session history while sealed
- [vault] Address PR review comments
- [security] Address PR review feedback
- [ssh] Tighten timeout and warning handling
- [ssh] Address latest review follow-ups
- [gateway] Collapse legacy ssh node lookup
- [auth] Guard ssh key deletion race
- [httpd] Satisfy ssh route lint
- [ssh] Reject option-like targets
- [ssh] Hide import passphrases from argv
- [ssh] Quote known hosts path
- [web] Use browser location port for node join URL
- [web] Guard e2e assertion for default-port case
- [e2e] Use sidebar selector for sealed-vault session visibility test
- [web] Fall back to getRandomValues for session UUID on plain HTTP
- [web] Address PR review feedback
- [providers] Speed up model probes
- [security] Address PR review feedback for GPG signing
- [security] Prevent gpg –import grep from aborting verify script
- [security] Show GPG signer identity and failure diagnostics
- [security] Pin GPG key fingerprint to prevent TOFU attacks
- [voice] Surface elevenlabs stt failures
- [gateway] Address channel settings PR feedback
- [voice] Handle empty stt transcripts
- [web] Show unsupported model reason inline instead of tooltip-only
- [web] Show probe error inline in model selector cards
- [web] Show probe error inline in preferred models selector
- [web] Preserve server error message for model probes
- [web] Sort and collapse onboarding model selector like settings
- [web] Sort models by version number when no date available
- [httpd] Harden ngrok controller lifecycle
- [httpd] Retain ngrok controller after startup
- [ngrok] Harden loopback tunnel handling
- [ngrok] Avoid fatal startup on tunnel errors
- [ngrok] Clarify defaults and warnings
- Update local setup and ElevenLabs error logging
- [web] Localize toggle button in onboarding and fix JSDoc comment
- [chat] Use relative timestamps in created_at test
- [tools] Address firecrawl PR review feedback
- [tools] Resolve firecrawl web_search registration and timeout race
- [agents] Stabilize prompt cache and compact tool results
- [web] Stabilize send-document e2e test
- [mcp] Address PR review comments for streamable HTTP transport
- [mcp] Update template docs and log messages for streamable HTTP
- [providers] Default to vision support for unknown models (#556)
- [providers] Also exempt gpt-4-vision from denylist
- [providers] Surface real error on provider probe failure
- [web] Apply serverMessage pattern to validateProviderConnection
- [web] Allow multi-model selection during provider setup
- [web] Scope Select All to visible models, check save_models response
- [config] Wrap upstream_proxy in Secret
and redact credentials in logs - [proxy] Use rfind for @ redaction, warn on parse failure, document Slack gap
- [providers] Rediscover models from /v1/models before probing
- [providers] Address PR review — move Ollama probes outside lock, use runtime env
- [providers] Check total model count in RediscoveryResult::is_empty
- [matrix] Address review feedback
- [matrix] Address latest review feedback
- [matrix] Set reply thread ids after main merge
- [matrix] Gate DM invites through dm_policy instead of room_policy
- [matrix] Gate poll responses through access control
- [voice] Use inspect_err in elevenlabs stt
- [web] Preserve @ in matrix allowlists
- [matrix] Unify otp approval flow and sender visibility
- [web] Default matrix setup to password auth
- [matrix] Improve ownership recovery UX
- [graphql] Implement retry ownership in test mock
- [e2e] Correct username assertion in matrix senders test
- [web] Satisfy biome hook and lint checks
[20260328.03] - 2026-03-28
Fixed
- [telegram] Route forum-topic replies to correct thread
- [telegram] Restore raw chat_id in logs, add thread_id to tracing
- [providers] Increase model probe timeout for local LLM servers
- [providers] Address PR review feedback
[20260328.02] - 2026-03-28
Added
- [telegram] Isolate forum-topic sessions by thread_id
Changed
- [telegram] Consolidate parse_chat_target, fix typing indicator
Fixed
- [telegram] Propagate thread_id parse errors in parse_chat_target
- [providers] Skip model discovery for custom providers with explicit models
- [providers] Replace redundant test with one that pins the new guard
- [provider-setup] Skip probe for custom providers without model
- [provider-setup] Remove redundant is_chat_capable_model filter
- [chat] Use system role for compaction summary
- [chat] Use user role for compaction summary
- [providers] Restore MiniMax top-level system prompt extraction
- [telegram] Allow unwrap in topic tests to satisfy workspace clippy lints
- [provider-setup] Use Arc
instead of static in test
[20260328.01] - 2026-03-28
Added
- [website] Add local dev server with SSR partial injection
- [web] Add changelog link to header nav
- [providers] Add prompt caching for Anthropic and OpenRouter
- [telegram] Extract plaintext and markdown documents from messages
- [providers] Add Fireworks.ai as primary provider
Changed
- [website] Shared nav via SSR partial, add Changelog link
- [telegram] Use std::str::from_utf8 for UTF-8 truncation
- [telegram] Normalize MIME type once, avoid redundant UTF-8 scans
Fixed
- [website] Allow nav links without data-page to navigate normally
- [website] Highlight Changelog tab and show GitHub stars on /changelog
- [website] Share GitHub stars script via nav partial, fix Changelog click
- [web] Point report issue link to template chooser
- [providers] Align indentation in stream_with_tools debug macro
- [telegram] Address PR review comments on document handling
- [telegram] Enforce char limit on all document content paths
- [telegram] Prevent U+FFFD from byte-boundary truncation of CJK text
- [install] Remove spurious -1 revision from .deb filename in installer
[20260327.05] - 2026-03-27
Changed
- [release] Build changelog HTML in prepare-release instead of CI
[20260327.04] - 2026-03-27
Fixed
- [ci] Use file input for changelog blob to avoid argument list too long
[20260327.03] - 2026-03-27
Added
- [website] Add changelog HTML page and fix RPM version override
[20260327.02] - 2026-03-27
Fixed
- [install] Support date-based version tags in installer and package builds
[20260327.01] - 2026-03-27
Added
- [gateway] Embedded web chat UI at root endpoint
- [gateway] Add services, pairing, expanded methods and auth
- [agents] Add LLM chat with streaming, multi-provider support and feature flags
- [gateway] Add Tailwind-based chat UI with dark/light theme
- [config] Add multi-format config file with provider enable/disable support
- [gateway] Add model selector and WebSocket auto-reconnect
- [oauth] Add OpenAI Codex OAuth provider and reusable OAuth infrastructure
- [tools] Add LLM code execution with agent loop, tool calling, and security layers
- [agents] Add debug logging and end-to-end exec tool test
- [agents] Text-based tool calling fallback for non-native providers
- [gateway] Log user message on chat.send
- [memory] Implement memory management system with hybrid search
- [tools] Wire approval gating into exec tool with UI
- [brew] Add Homebrew formula for tap-based installation
- [website] Add static site and roadmap for moltis features
- [website] Rewrite with Tailwind CSS, Inter/JetBrains fonts, and polish
- [packaging] Add Debian package builds for amd64 and arm64
- [packaging] Add Arch Linux package builds for x86_64 and aarch64
- [packaging] Add RPM, Flatpak, Snap, AppImage, Nix, and Homebrew packaging
- [agents] Register all Codex models in provider registry
- [gateway] Structured error handling, exec cards, and approval UI
- [gateway] Add provider management UI with multi-model support
- [gateway] Persist API keys and add session management
- [gateway] Add session sidebar UI and fix session management
- [gateway] Route chat events by session key, add unread dots and thinking restore
- [agents] Add missing LLM providers and GitHub Copilot OAuth
- [gateway] Add session search with autocomplete, scroll-to and highlight
- [gateway] Include model and provider name in chat final events
- [claude] Save plans and sessions to prompts/ via hooks
- [gateway] Multi-page SPA with nav panel, crons page and methods page
- [cron] Wire cron callbacks and register CronTool for LLM use
- [cron] Implement production-grade cron scheduling system
- [projects] Add project management with context loading and session binding
- [gateway] Searchable model selector, per-session model, chat history
- [gateway] Persist model/provider in chat history and style model footer
- [gateway] Add token usage display per-message and per-session
- [gateway] Move model selector to chat page, add providers to nav panel
- [projects,sessions] Migrate project and session metadata storage to SQLite
- [gateway] Reorganize navigation, move providers to dedicated page
- [sandbox] Per-session sandbox toggle with sandbox-on-by-default
- [gateway] Show LLM thinking text, persist token usage, fix chat scroll
- [gateway] Add slash commands (/clear, /compact, /context) with autocomplete
- [context] Display sandbox details in /context command
- [providers] Add Kimi Code OAuth (device flow) provider
- [gateway] Add Channels navigation page
- [gateway] Move enabled toggle to last column in cron job table
- [telegram] Add username allowlist matching and message log store
- [compact] Auto-compact on context limit, use session provider, show summary card
- [worktree] Implement workspace worktree lifecycle features
- [ui] Project selector combo in chat header with session filtering
- [telegram] Per-channel sessions, slash commands, and default model config
- [gateway] Add logs/forensic page with real-time streaming and persistence
- [telegram] Command autocomplete, /new session, /sessions with inline keyboard
- [gateway] Live session updates, channel icons, and active session indicator
- [gateway] Add amber ping dot indicator for active sessions
- [gateway] UI improvements and provider/context enhancements
- [skills] Add agent skills system crate with discovery, registry, and CLI
- [gateway] Add Skills navigation page to web UI
- [skills] Repository-based skills with per-skill enable/disable
- [skills] Accept GitHub URLs in skill source input
- [gateway] Add native HTTPS/TLS support behind
tlscargo feature - [gateway] Hybrid asset serving with filesystem dev and embedded release
- [sandbox] Configurable images, on-demand caching, Apple Container auto-detection
- [onboarding] Replace wizard with inline identity editing in Settings
- [plugins] Add plugins crate with format adapters, install, and chat integration
- [gateway] Add Images page for managing sandbox container images
- [telegram] Add /model, /sandbox commands and improve /context display
- [gateway] Add setup code at startup and configurable config/data dirs
- [gateway] Clean auth state separation, gon pattern, and identity improvements
- [env] Add write-only environment variables with sandbox injection
- [security] Wrap secrets in secrecy::Secret
to prevent leaks - [hooks] Add hook dispatch system with native and shell handlers
- [hooks] Add hook discovery, eligibility, metadata, CLI commands, and bundled hooks
- [memory] Wire memory system into gateway with tools, compaction, and session hooks
- [memory] Add embedding cache, local GGUF, fallback chain, batch API, file watcher, and pre-compaction flush
- [tools] Add web_search and web_fetch agent tools
- [sandbox] Add tracing to Apple Container and exec tool lifecycle
- [ui] Show container name in /context sandbox section
- [web] Forward client Accept-Language to web_fetch and web_search
- [sandbox] Auto-provision curl, python3, nodejs, npm in containers
- [sandbox] Expand default packages inspired by GitHub runner images
- [sandbox] Apple Container pre-built images, CLI commands, default config
- [sandbox] Expand packages from GitHub runner images, use Secret for web search keys
- [hooks] Wire all 15 hook events and add examples
- [chat] Add per-session run serialization to prevent history corruption
- [chat] Add configurable agent-level timeout enforcement
- [agents] Retry agent loop after compaction on context window overflow
- [tools] Add SpawnAgentTool for sub-agent / nested agent support
- [chat] Add message queue modes for concurrent send handling
- [agents] Sanitize tool results before appending to LLM message history
- [agents] Execute tool calls concurrently with join_all
- [mcp] Add MCP client support with discovery UI
- [gateway] Add nav sidebar count badges and MCP UI improvements
- [mcp] MCP context in chat, duplicate name handling, and misc improvements
- [mcp] Add McpTransport and McpClientTrait trait abstractions
- [mcp] Wire MCP tool bridges into agent ToolRegistry
- [mcp] SSE transport, health polling, auto-restart, and edit config
- [gateway] Add tailscale serve/funnel management, UI consistency overhaul, and HTTP/2 support
- [memory] Log status with DB size after initial sync
- [tailscale] Add Start Tailscale button when daemon is not running
- [channels] Assign default model to new telegram sessions
- [agents] Add model failover with per-provider circuit breakers
- [gateway] Add report an issue link to nav sidebar
- [config] Support MOLTIS_* env var overrides for all config fields
- [cron] Add heartbeat feature with persistent run history
- [cli] Add cargo-binstall support for binary installation
- Add Homebrew tap and auto-update workflow
- Generate random port on first run and make gateway the default command
- [ci] Add multi-arch Docker build workflow
- [agents] Enable streaming responses with tool support
- [metrics] Add Prometheus metrics with feature-gated support
- [metrics] Expand metrics to all crates with tracing feature
- [metrics] Add provider alias support for metrics differentiation
- [metrics] Add SQLite persistence and per-provider charts
- [db] Add sqlx migrations with per-crate ownership
- [local-llm] Add local LLM provider with GGUF/MLX backend selection
- [local-llm] Add MLX models and filter by backend
- [local-llm] Add HuggingFace search and custom model support
- [agents] Add unified local-llm provider with pluggable backends
- [ui] Add auto-search with debounce for HuggingFace search
- [ui] Show chat-only mode notice when selecting model without tools
- [ui] Show all configured models in providers page
- [providers] Add per-model removal and disable support
- [local-llm] Add tracing and metrics to GGUF provider
- [cli] Add database management commands (db reset, clear, migrate)
- [telegram] Auto-disable channel when another bot instance is running
- Add install script and update URLs to moltis.org
- Add Pi-inspired features — skill state, self-extension, branching, hot-reload
- [auth] Add scope support to API keys
- Typed ToolSource, per-session MCP toggle, debug panel convergence, docs & changelog
- [gateway] Add Fork button to chat header
- [gateway] Show warning banner when running on non-main git branch
- [gateway] Add mobile PWA support with push notifications (#40)
- [tls] Default HTTP redirect port to gateway port + 1 (#49)
- [memory] Add QMD backend support, citations, session export, and LLM reranking (#27)
- [deploy] Add –no-tls flag and one-click cloud deploy configs
- [gateway] Display process and system memory usage in header
- [ui] Red favicon and branch-prefixed title for non-main branches
- [ui] Add security warnings to MCP and Plugins pages
- [ui] Add click-to-view-source detail panel to enabled plugins table
- [deploy] Add MOLTIS_DEPLOY_PLATFORM env var to hide local-only providers on cloud
- [ui] Add allowlist field to onboarding channel step
- [ui] Prefill agent name and emoji in onboarding identity step
- [hooks] Add hooks web UI page
- [hooks] Seed example hook on first run
- [hooks] Add Preview/Source tabs for HOOK.md content
- [hooks] Click-to-copy hook source path
- [hooks] Show built-in hooks in the web UI
- [hooks] Wire up built-in hooks (boot-md, command-logger, session-memory)
- [channels] Replace allowlist textarea with tag-style input
- [workspace] Move persona and startup context to markdown files
- [browser] Add full browser automation support via CDP
- [browser] Enable browser tool by default
- [settings] Add Load Template button and docs link
- [browser] Display screenshot thumbnails in chat UI
- Add restart API and improve browser tool integration
- [browser] Add fullscreen lightbox for screenshot thumbnails
- [local-llm] Auto-detect backend based on model type
- [local-llm] Support Homebrew-installed mlx-lm
- [local-llm] Download and cache MLX models locally
- [local-llm] Notify user when downloading missing models
- [ui] Show download progress in chat when model is missing
- [browser] Add sandboxed browser support using Docker containers
- [browser] Add sandbox mode visibility in logs and response
- [browser] Pre-pull container image at startup with UI feedback
- [browser] Include page content in snapshot response
- [cli] Add browser command for managing browser configuration
- [browser] Add detailed logging for browser execution mode
- [ui] Show browser action and execution mode in chat
- [browser] Add screenshot download functionality
- [browser] Increase default viewport to 1920x1080
- [browser] Increase viewport to 2560x1440 with 2x Retina scaling
- [telegram] Add screenshot support for browser tool
- [browser,telegram] Improve screenshot handling and display
- [browser] Memory-based pool limits instead of fixed count
- [telegram] Send tool execution status during chat
- [logs] Show crate/module target in log output
- [ci] Add local validation gate with CI fallback
- [security] Add emergency disable for third-party skills
- [security] Surface commit provenance in skills UI
- [skills] Add websocket install progress and loading states
- [skills] Use websocket install status with honest UI messaging
- [scripts] Support local-only validation without a PR
- [workspace] Align context files with OpenClaw semantics
- [heartbeat] Skip LLM turns when HEARTBEAT.md is empty
- [heartbeat] Surface empty-file skip status in UI
- [gateway] Surface GitHub release update banner
- [voice] Add voice crate with TTS and STT providers
- [gateway] Integrate voice services with TTS and STT support
- [voice] Add voice UI with feature flag support
- [voice] Add multiple STT providers
- [voice] Add voice provider management UI with auto-detection
- [voice] Add ElevenLabs Scribe STT provider
- [voice] Integrate ElevenLabs Scribe STT and upgrade to v2
- [voice] Improve STT/TTS testing UX and fix ElevenLabs API
- [voice] Send voice messages directly to chat
- [telegram] Transcribe voice messages before dispatching to chat
- [telegram] Add info logging for unhandled media types
- [telegram] Add image support for multimodal LLM messages
- [channels] Improve voice handling and typed STT config
- [voice] Add typed provider metadata and voice preference flows
- [chat] Prefer same reply medium with text fallback
- [voice] Add provider list allowlists and narrow template defaults
- [chat] Add runtime host+sandbox prompt context
- [chat] Add Prompt button to inspect full system prompt
- [sandbox] Add tmux to default packages
- [tools] Add process tool and tmux skill for interactive terminal sessions
- Ship provider onboarding and model discovery improvements
- [ui] Redesign Voice onboarding step with settings-like experience
- [ui] Personalize TTS test phrases with user and bot names
- [gateway] Voice pending UI, TTS phrase generation, and empty message filtering
- [gateway] Persist TTS audio, per-session media, and silent replies
- [gateway] Add Context button showing full LLM messages array
- [gateway] Add queued message UI with cancel support
- [gateway] Add Copy button to full context panel
- [gateway] Add client-side message sequence number for ordering diagnostics
- [gateway] Add run_id and seq to persisted messages for parent/child linking
- [channels] Add reply threading support for Telegram messages
- [gateway] Auto-detect browser timezone via Intl.DateTimeFormat
- [metrics] Populate cache token counters from provider responses
- [tools] Add get_user_location tool with browser geolocation
- [sandbox] Add image, audio, media, and data processing packages
- [sandbox] Add document, office, and search packages
- [sandbox] Add GIS and OpenStreetMap packages
- [tools] Add sandbox_packages tool for on-demand package discovery
- [sandbox] Add communication packages, hybrid sandbox query, and mise
- [telegram] Add location sharing with live location tracking
- [onboarding] Redesign provider step as multi-provider list
- [gateway] Show no-providers card when no LLM models available
- [gateway] Validate-first provider setup in onboarding and settings
- [oauth] Import auto-detected tokens into central store at startup
- [providers] Add model selection for auto-detected providers
- [onboarding] Add summary step and improve channel UX
- [gateway] Add Telegram-style waveform audio player
- [gateway] Add generic session upload endpoint replacing base64-over-WS
- [gateway] Add drag-and-drop image upload to chat and auth tests
- [voice] Add TTS text sanitization and voice-friendly LLM prompting
- [tools] Reverse geocode user location to human-readable place names
- [agents] Retry once on transient LLM provider errors
- [tools] Add show_map tool and location precision modes
- [telegram] Consolidate bot responses into single message with logbook
- [auth] Passkey UX improvements and mDNS origin support
- [benchmarks] Add boot-path performance benchmarks for CodSpeed
- [cli] Add
moltis memorysubcommand with search and status - [telegram] Send native location pin for show_map tool
- [telegram] Send voice replies with text transcript
- [gateway] Add search to project filter and fix button alignment
- [gateway] Attach project to new sessions from filtered view
- [sessions] Add server-side unread tracking via last_seen_message_count
- [gateway] Auto-detect models after saving provider API key (#83)
- [sessions] Add entity versioning to prevent stale updates
- [gateway] Improve logs filter UX and branch favicon contrast
- [gateway] Add logout button to header bar (#86)
- [gateway] Add comprehensive e2e test suite for web UI (#87)
- [gateway] Add auth-aware endpoint throttling and login retry UX
- [gateway] Auto-detect WebAuthn RP ID from PaaS environment variables
- [cli] Add –version flag
- [gateway] Centralize SPA routes and preserve config TOML comments
- [models] Tighten allowlist matching and support probe output (#91)
- [config] Add moonshot to default offered providers
- [onboarding] Preselect passkey auth method when available
- [config] Auto-create SOUL.md with default content on first run
- [gateway] Disconnect all WS clients on credential changes
- [providers] Prefer configured models and merge live model discovery
- [providers] Multi-select preferred models, keyOptional, createdAt sorting
- [providers] Filter non-chat models and fix per-model tool support
- [providers] Show model dates, probe on select, spacebar pause
- [onboarding] Multi-select model picker with probe badges
- [memory] Inject MEMORY.md into system prompt and fix file watcher
- Auto-select and install browser tool backends (#130)
- [agents] Strip
tags from OpenAI-compatible providers and add MiniMax - [chat] Preserve reasoning text in tool cards across page reloads
- [agents] Add Z.AI (Zhipu) as OpenAI-compatible provider
- Env injection, sandbox recovery, UI fixes, provider improvements (#108)
- [gateway] Show server start time at bottom of onboarding
- [browser] Auto-inject low-memory Chromium flags on constrained systems
- [gateway] Add generic OpenAI-compatible provider support
- [tools] SSRF allowlist for Docker inter-container networking (#146)
- [config] Enable openrouter in default offered providers
- [mcp] Add OAuth 2.1 support for remote MCP servers (#148)
- [telegram] Add channel streaming with stream_mode fallback (#165)
- [tools] Add calc tool for safe arithmetic evaluation
- [cron] Add per-job model and execution target controls (#170)
- [gateway] Cache session histories and show switch loader
- [map] Add provider-aware show_map links
- [gateway] Render markdown and ansi tables in chat
- [browser] Persist Chrome profile across sessions (#162)
- [memory] Reduce baseline memory footprint for lightweight devices
- [telegram] Save voice audio to session media for web UI playback
- [cron] Add event-driven heartbeat wake with system events queue
- [telegram] Render markdown tables as formatted pre blocks
- [gateway] Static file caching for public share pages
- [cron] Deliver agent turn output to Telegram channels
- [gateway] Seed dcg-guard hook and polish onboarding badges
- [graphql] Add GraphQL API exposing all RPC methods (#200)
- [tools] Add send_image tool for channel image delivery (#224)
- [gateway] Expand identity emoji picker options (#206)
- [web] Browsable skills list in repo cards
- [providers] Add configurable OpenAI websocket stream transport (#227)
- [sandbox] Install latest gogcli in default sandbox images (#232)
- [agents] Add multi-agent personas with CRUD UI (#97)
- [caldav] Add CalDAV integration for calendar CRUD (#84)
- [channels] Add Microsoft Teams channel integration (#231)
- [import] Add OpenClaw import crate, CLI, gateway RPC, and UI (#217)
- [web] Add vault algorithm description to encryption settings page
- [web] Internationalization (i18n) with English and French locales (#237)
- [whatsapp] Add WhatsApp channel support (#73)
- [web] Add sandbox shared-home settings UI and config
- [protocol] Upgrade WebSocket protocol from v3 to v4 (#247)
- [agents,providers,chat] Universal tool support for all models
- [openclaw-import] Import workspace files and add safety messaging
- [providers] Promote Gemini to first-class OpenAI-compatible provider
- [i18n] Add full zh-CN (Simplified Chinese) localization (#260)
- Add channel-aware heartbeat delivery and send_message agent tool (#270)
- [memory] Add tree-sitter code splitter and RRF search merge
- [web] Add Shiki syntax highlighting to code blocks
- [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)
- [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
- [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)
- [providers] Add Responses API support to GitHub Copilot provider (#393)
- [release] Migrate to date-based versioning (YYYYMMDD.NN) (#394)
- Support secret remote MCP URLs and headers (#416)
- [docker] Support generic provider env bootstrap (#401)
- [skills] Support safe agent-written sidecar files (#413)
- [mcp] Add custom display names for MCP servers
- [mcp] Add displayName to iOS GraphQL schema
- [skills] Gate installer behind install feature
- [gateway] Embedded web chat UI at root endpoint
- [gateway] Add services, pairing, expanded methods and auth
- [agents] Add LLM chat with streaming, multi-provider support and feature flags
- [gateway] Add Tailwind-based chat UI with dark/light theme
- [config] Add multi-format config file with provider enable/disable support
- [gateway] Add model selector and WebSocket auto-reconnect
- [oauth] Add OpenAI Codex OAuth provider and reusable OAuth infrastructure
- [tools] Add LLM code execution with agent loop, tool calling, and security layers
- [agents] Add debug logging and end-to-end exec tool test
- [agents] Text-based tool calling fallback for non-native providers
- [gateway] Log user message on chat.send
- [memory] Implement memory management system with hybrid search
- [tools] Wire approval gating into exec tool with UI
- [brew] Add Homebrew formula for tap-based installation
- [website] Add static site and roadmap for moltis features
- [website] Rewrite with Tailwind CSS, Inter/JetBrains fonts, and polish
- [packaging] Add Debian package builds for amd64 and arm64
- [packaging] Add Arch Linux package builds for x86_64 and aarch64
- [packaging] Add RPM, Flatpak, Snap, AppImage, Nix, and Homebrew packaging
- [agents] Register all Codex models in provider registry
- [gateway] Structured error handling, exec cards, and approval UI
- [gateway] Add provider management UI with multi-model support
- [gateway] Persist API keys and add session management
- [gateway] Add session sidebar UI and fix session management
- [gateway] Route chat events by session key, add unread dots and thinking restore
- [agents] Add missing LLM providers and GitHub Copilot OAuth
- [gateway] Add session search with autocomplete, scroll-to and highlight
- [gateway] Include model and provider name in chat final events
- [claude] Save plans and sessions to prompts/ via hooks
- [gateway] Multi-page SPA with nav panel, crons page and methods page
- [cron] Wire cron callbacks and register CronTool for LLM use
- [cron] Implement production-grade cron scheduling system
- [projects] Add project management with context loading and session binding
- [gateway] Searchable model selector, per-session model, chat history
- [gateway] Persist model/provider in chat history and style model footer
- [gateway] Add token usage display per-message and per-session
- [gateway] Move model selector to chat page, add providers to nav panel
- [projects,sessions] Migrate project and session metadata storage to SQLite
- [gateway] Reorganize navigation, move providers to dedicated page
- [sandbox] Per-session sandbox toggle with sandbox-on-by-default
- [gateway] Show LLM thinking text, persist token usage, fix chat scroll
- [gateway] Add slash commands (/clear, /compact, /context) with autocomplete
- [context] Display sandbox details in /context command
- [providers] Add Kimi Code OAuth (device flow) provider
- [gateway] Add Channels navigation page
- [gateway] Move enabled toggle to last column in cron job table
- [telegram] Add username allowlist matching and message log store
- [compact] Auto-compact on context limit, use session provider, show summary card
- [worktree] Implement workspace worktree lifecycle features
- [ui] Project selector combo in chat header with session filtering
- [telegram] Per-channel sessions, slash commands, and default model config
- [gateway] Add logs/forensic page with real-time streaming and persistence
- [telegram] Command autocomplete, /new session, /sessions with inline keyboard
- [gateway] Live session updates, channel icons, and active session indicator
- [gateway] Add amber ping dot indicator for active sessions
- [gateway] UI improvements and provider/context enhancements
- [skills] Add agent skills system crate with discovery, registry, and CLI
- [gateway] Add Skills navigation page to web UI
- [skills] Repository-based skills with per-skill enable/disable
- [skills] Accept GitHub URLs in skill source input
- [gateway] Add native HTTPS/TLS support behind
tlscargo feature - [gateway] Hybrid asset serving with filesystem dev and embedded release
- [sandbox] Configurable images, on-demand caching, Apple Container auto-detection
- [onboarding] Replace wizard with inline identity editing in Settings
- [plugins] Add plugins crate with format adapters, install, and chat integration
- [gateway] Add Images page for managing sandbox container images
- [telegram] Add /model, /sandbox commands and improve /context display
- [gateway] Add setup code at startup and configurable config/data dirs
- [gateway] Clean auth state separation, gon pattern, and identity improvements
- [env] Add write-only environment variables with sandbox injection
- [security] Wrap secrets in secrecy::Secret
to prevent leaks - [hooks] Add hook dispatch system with native and shell handlers
- [hooks] Add hook discovery, eligibility, metadata, CLI commands, and bundled hooks
- [memory] Wire memory system into gateway with tools, compaction, and session hooks
- [memory] Add embedding cache, local GGUF, fallback chain, batch API, file watcher, and pre-compaction flush
- [tools] Add web_search and web_fetch agent tools
- [sandbox] Add tracing to Apple Container and exec tool lifecycle
- [ui] Show container name in /context sandbox section
- [web] Forward client Accept-Language to web_fetch and web_search
- [sandbox] Auto-provision curl, python3, nodejs, npm in containers
- [sandbox] Expand default packages inspired by GitHub runner images
- [sandbox] Apple Container pre-built images, CLI commands, default config
- [sandbox] Expand packages from GitHub runner images, use Secret for web search keys
- [hooks] Wire all 15 hook events and add examples
- [chat] Add per-session run serialization to prevent history corruption
- [chat] Add configurable agent-level timeout enforcement
- [agents] Retry agent loop after compaction on context window overflow
- [tools] Add SpawnAgentTool for sub-agent / nested agent support
- [chat] Add message queue modes for concurrent send handling
- [agents] Sanitize tool results before appending to LLM message history
- [agents] Execute tool calls concurrently with join_all
- [mcp] Add MCP client support with discovery UI
- [gateway] Add nav sidebar count badges and MCP UI improvements
- [mcp] MCP context in chat, duplicate name handling, and misc improvements
- [mcp] Add McpTransport and McpClientTrait trait abstractions
- [mcp] Wire MCP tool bridges into agent ToolRegistry
- [mcp] SSE transport, health polling, auto-restart, and edit config
- [gateway] Add tailscale serve/funnel management, UI consistency overhaul, and HTTP/2 support
- [memory] Log status with DB size after initial sync
- [tailscale] Add Start Tailscale button when daemon is not running
- [channels] Assign default model to new telegram sessions
- [agents] Add model failover with per-provider circuit breakers
- [gateway] Add report an issue link to nav sidebar
- [config] Support MOLTIS_* env var overrides for all config fields
- [cron] Add heartbeat feature with persistent run history
- [cli] Add cargo-binstall support for binary installation
- Add Homebrew tap and auto-update workflow
- Generate random port on first run and make gateway the default command
- [ci] Add multi-arch Docker build workflow
- [agents] Enable streaming responses with tool support
- [metrics] Add Prometheus metrics with feature-gated support
- [metrics] Expand metrics to all crates with tracing feature
- [metrics] Add provider alias support for metrics differentiation
- [metrics] Add SQLite persistence and per-provider charts
- [db] Add sqlx migrations with per-crate ownership
- [local-llm] Add local LLM provider with GGUF/MLX backend selection
- [local-llm] Add MLX models and filter by backend
- [local-llm] Add HuggingFace search and custom model support
- [agents] Add unified local-llm provider with pluggable backends
- [ui] Add auto-search with debounce for HuggingFace search
- [ui] Show chat-only mode notice when selecting model without tools
- [ui] Show all configured models in providers page
- [providers] Add per-model removal and disable support
- [local-llm] Add tracing and metrics to GGUF provider
- [cli] Add database management commands (db reset, clear, migrate)
- [telegram] Auto-disable channel when another bot instance is running
- Add install script and update URLs to moltis.org
- Add Pi-inspired features — skill state, self-extension, branching, hot-reload
- [auth] Add scope support to API keys
- Typed ToolSource, per-session MCP toggle, debug panel convergence, docs & changelog
- [gateway] Add Fork button to chat header
- [gateway] Show warning banner when running on non-main git branch
- [gateway] Add mobile PWA support with push notifications (#40)
- [tls] Default HTTP redirect port to gateway port + 1 (#49)
- [memory] Add QMD backend support, citations, session export, and LLM reranking (#27)
- [skills] Show confirmation hint after skill creation/update
- [skills] Add skill editing and forking from the web UI
- [skills] Show confirmation hint after skill creation/update
- [skills] Add skill editing and forking from the web UI
- [mcp] Make request timeout configurable
- [agents] Lazy tool registry with tool_search meta-tool
- [local-llm] Add opt-in vulkan gguf support
- [providers] Add MiniMax M2.7 and missing M2.1 highspeed models
- [prompt] Stabilize system prompt for local LLM KV cache
- [scripts] Skip local-validate.sh when commit already passed
- [docker] Add Node.js/npm to Docker image for MCP servers
- [ci] Add pre_release option to release workflow
Changed
- [website] Extract inline CSS to separate styles.css file
- [gateway] Split monolithic app.js into 24 ES modules
- [gateway] Extract inline JS styles to CSS and add message dedup
- [gateway] Preact skills page, REST APIs, biome linting, perf fixes
- [gateway] Reduce cognitive complexity in JS modules and add biome CI
- [gateway] Faster log loading with memory-first reads and batch rendering
- Simplify auth, onboarding, and settings code
- Simplify hooks and memory code
- [env] Wrap env var values in secrecy::Secret
- Remove all unsafe code and add workspace-wide deny(unsafe_code)
- Keep API keys wrapped in Secret
through provider construction - [agents] Simplify tool result sanitization
- [mcp] Remove dead code and deduplicate config parsing
- [memory] Skip redundant work on sync restart
- [gateway] Use inline script for identity instead of server-side HTML replace
- [api] Split /api/skills and /api/plugins into separate endpoints
- [ui] Use CSS classes for tailscale status bar, single line layout
- [ui] Rename ts-status-bar to generic info-bar classes and fix image page font size
- [tailscale] Remove Start Tailscale button and up endpoint
- [ui] Move session rename/delete to chat header and clean up
- Remove unsafe set_var in detect.rs tests, resolve merge conflicts
- [gateway] Reorder nav items alphabetically with Chat first
- [ci] Extract signing logic into composite action
- [ui] Move install hint markup to HTML template
- [ui] Move model notice card HTML to template
- Rename crate moltis-cli to moltis
- [deps] Centralize all dependency versions in workspace root
- [ci] Run lightweight lint jobs on GitHub-hosted runners
- [ci] Restore Docker jobs to release workflow, remove docker.yml
- [plugins] Replace hand-rolled date arithmetic with time crate
- [gateway] Use time::Duration::days in tls expiry check
- Use moltis_config::data_dir() for all path resolution
- [browser] Add browser detection and simplify session handling
- [settings] Rename Tools to Configuration and edit full config
- [browser] Use typed structs for OpenAI tool schemas
- [local-llm] Add modular ResponseParser trait for output parsing
- [browser] Use unified sandbox infrastructure for browser containers
- [gateway] Add defense-in-depth auth checks and DRY server.rs
- [browser] Send execution mode from server in tool_call_start
- [channels] Use ChannelType enum instead of string matching
- [browser] Sandbox mode follows session, fix tall screenshot lightbox
- [ci] Move local status check logic into script
- [ci] Avoid blocking lint and test on local zizmor
- [security] Use gitoxide metadata and improve trust UX
- [scripts] Rename local-validate-pr.sh to local-validate.sh
- [chat] Simplify runtime host+sandbox prompt context
- [sandbox] Remove redundant mkdir exec from ensure_ready
- Simplify branch review fixes across UI, gateway, and config
- [gateway] Use typed structs for chat broadcast payloads
- [ui] Rename /images and /api/images routes to /sandboxes
- [gateway] Consolidate GatewayState per-field RwLocks into single RwLock
- [mcp] Consolidate McpManager per-field RwLocks into single RwLock
- [gateway] Replace project select with custom combo dropdown
- [gateway] Unify auth into single check_auth() gate
- [browser] Make stale cleanup path expression-based
- [ci] Move E2E job into CI workflow
- [build] Enable thin LTO and binary stripping in release profile
- [gateway] Extract shared voice, identity, and channel utils
- [tools] Resolve browser sandbox mode from SandboxRouter directly
- [gateway] Reorder onboarding screens
- [session] Typed params for patch and voice_generate (#131)
- [gateway] Remove standalone /crons route, use /settings/crons
- [agents] Simplify prompt builder and runtime context
- [prompt] Compact prompt sections and add server runtime time
- [web] Extract web UI into dedicated moltis-web crate
- [identity] Consolidate creature+vibe into single theme field
- [gateway] Extract moltis-service-traits crate (Phase 0)
- [gateway] Extract moltis-tls and moltis-tailscale crates (Phase 1)
- [gateway] Extract moltis-auth crate (Phase 2)
- [gateway] Extract moltis-provider-setup crate (Phase 3)
- [gateway] Extract moltis-chat crate (Phase 4)
- [web] Move share_render.rs from gateway to moltis-web (Phase 1c)
- [providers] Extract provider implementations into new crate
- [errors] Move crates to typed thiserror enums (#226)
- [tools] Replace anyhow bridge with crate::Result for internal APIs (#257)
- [ffi] Tighten unsafe_code allowances
- [channels] Registry-driven dispatch for cheap new channels (#277)
- [gateway] Fetch updates from releases manifest instead of GitHub API
- [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
- [tools] Split sandbox.rs into sandbox/ module directory
- [website] Extract inline CSS to separate styles.css file
- [gateway] Split monolithic app.js into 24 ES modules
- [gateway] Extract inline JS styles to CSS and add message dedup
- [gateway] Preact skills page, REST APIs, biome linting, perf fixes
- [gateway] Reduce cognitive complexity in JS modules and add biome CI
- [gateway] Faster log loading with memory-first reads and batch rendering
- Simplify auth, onboarding, and settings code
- Simplify hooks and memory code
- [env] Wrap env var values in secrecy::Secret
- Remove all unsafe code and add workspace-wide deny(unsafe_code)
- Keep API keys wrapped in Secret
through provider construction - [agents] Simplify tool result sanitization
- [mcp] Remove dead code and deduplicate config parsing
- [memory] Skip redundant work on sync restart
- [gateway] Use inline script for identity instead of server-side HTML replace
- [api] Split /api/skills and /api/plugins into separate endpoints
- [ui] Use CSS classes for tailscale status bar, single line layout
- [ui] Rename ts-status-bar to generic info-bar classes and fix image page font size
- [tailscale] Remove Start Tailscale button and up endpoint
- [ui] Move session rename/delete to chat header and clean up
- Remove unsafe set_var in detect.rs tests, resolve merge conflicts
- [gateway] Reorder nav items alphabetically with Chat first
- [ci] Extract signing logic into composite action
- [ui] Move install hint markup to HTML template
- [ui] Move model notice card HTML to template
- Rename crate moltis-cli to moltis
- [media] Replace manual MIME lookup with mime_guess crate
- [auth] Extract GatewayState::is_secure() to centralise cookie Secure logic
- [browser] Use match instead of nested if/else for dir creation
- [httpd] Extract moltis-httpd crate as HTTP transport facade
- [ci] Restore release.yml clippy runner to self-hosted
- [tools] Unify rescue helper, add tracing, exclude action-level keys
- [docker] Split runtime installs into separate layers
- [dev] Deduplicate release-preflight by delegating to lint
Removed
- Merge branch ‘main’ into claude/remove-unsafe-code-ehZlQ
- [ui] Remove decimal digits from memory display
- [ci] Add latest Docker tag on tag pushes, remove unused branch tag
- [ci] Remove template-expanded gate debug output
- [gateway] Use danger button for session delete
- [gateway] Size-match delete button with fork and add danger style
- [gateway] Match delete button size to fork button
- [browser] Remove linux-only unused mut in stale cleanup
- [codspeed] Remove noisy n=1 session_store_list case
- Remove cargo-binstall install path
- [browser] Remove needless return in match arm
- [gateway] Remove unnecessary path qualification flagged by clippy
- [cron] Remove oneOf from tool schema for OpenAI responses
- [readme] Drop logo icon to save vertical space
- [readme] Restore 64px favicon, remove link from title
- Remove trailing whitespace in validate.rs
- [cli] Remove binstall metadata and fix feature indentation
- [sandbox] Remove redundant error conversions
- [web] Remove nested onboarding scroll and restore settings nav icons
- [web] Declutter chat controls and fix dropdown positioning
- Merge branch ‘main’ into claude/remove-unsafe-code-ehZlQ
- [local-llm] Remove unused gguf runtime helper
Fixed
- [oauth] Use correct OpenAI client_id, add config layer and tests
- [agents] Prefer tool-capable provider when tools are registered
- [agents] Register builtin providers before genai to enable tool calling
- [gateway] Auto-focus chat input on page load
- [sessions] Add missing sandbox_enabled column migration, stop swallowing errors
- [gateway] Move sandbox button next to model select, fix default state
- Resolve all clippy warnings (collapsible_if, map_flatten, or_default, unused imports)
- [gateway] Open nav panel by default
- [gateway] Add session icons and replace spinner in-place
- [gateway] Add missing renderCompactCard export to page-chat.js
- [gateway] Fix routing, styling, and layout issues in JS modules
- [telegram] Remove channel prefix from user messages and log LLM responses
- [ci] Fix biome version and worktree test default branch
- [ci] Restore biome version pin and fix cargo fmt
- Update CLAUDE.md pre-commit checks and fix clippy warnings
- [ci] Update setup-biome to v2.7.0 and fix sort_by_key clippy lint
- [tests] Configure git user in worktree test setup for CI
- Replace sort_by with sort_by_key in project store
- [assets] Fix biome formatting and empty catch block in sandbox.js
- [ui] Match settings nav item height and style to main navigation
- [security] Wrap API keys in Secret
for memory subsystem - Use RecommendedCache for cross-platform watcher, sort_by_key for clippy
- [hooks] Ignore broken pipe on stdin write when child doesn’t read it
- [ui] Show URL and query in web tool call cards
- [sandbox] Restart stopped Apple containers in ensure_ready
- [sandbox] Pass sleep infinity to Apple Container run
- [sandbox] Promote exec routing and container logs to info level
- [sandbox] Handle Apple Container inspect returning empty for nonexistent containers
- [sandbox] Default working_dir to “/” when running inside container
- [security] Reject cross-origin WebSocket upgrades (CSWSH protection)
- [sandbox] Inject env vars in Apple Container backend
- [exec] Redact env var values from command output
- [exec] Redact base64 and hex encoded env var values from output
- [config] Use correct defaults for ToolsConfig when not deserialized
- [gateway] Nav badge counts and cron page instant load
- [memory] Write memory files to data dir instead of cwd
- [gateway] Eliminate identity flash on page load
- [ui] Use consistent button class for skills Remove button
- [ui] Fix skills Disable button style and add error feedback
- [skills] Route disable to correct RPC for plugin-sourced skills
- [ui] Use inline style for tailscale status green dot
- [ui] Use status-dot connected class for tailscale green dot
- [ui] Replace Tailwind arbitrary classes with inline styles in tailscale templates
- [ui] Make tailscale status bar fit-content width
- [ui] Make info-bar full width to match warning/error divs
- [ui] Constrain info-bar to max-width 600px matching alert divs
- [ui] Add alert-info-text to shared alert base styles
- [ui] Add btn-row and btn-row-mt CSS classes for button spacing
- [ui] Space cancel/create buttons apart and normalize height
- [ui] Improve tailscale loading message to set expectations
- [tailscale] Open Tailscale.app on macOS instead of running tailscale up
- [ui] Preserve funnel security warning when rebuilding tailscale DOM
- [ui] Use alert-warning-text style for funnel auth warning
- [ui] Replace auth text with green button linking to security settings
- [ui] Move auth button below the funnel security warning panel
- [ui] Remove @layer components wrapper to fix nav specificity
- [ui] Update session list after /clear and select next on delete
- [ui] Format environment variable timestamps with luxon
- [gateway] Stop echoing web UI messages to Telegram channel
- Collapse nested if statements in config loader
- [ci] Fix package build failures
- [ci] Correct cargo-deb assets order
- [local-llm] Detect available package managers for MLX install
- [local-llm] Detect mlx-lm installed via brew
- [ui] Show install commands on separate lines
- [local-llm] Rename provider from local-gguf to local-llm
- [local-llm] Fix HuggingFace API response parsing
- [ui] Show searching state on HuggingFace search button
- [ui] Close modal and prevent multiple clicks on HF model selection
- [chat] Remove per-message tools warning broadcast
- [ui] Remove scroll from local model list
- [ci] Install cmake for llama.cpp build and fix Biome errors
- [ci] Fix local-llm build in CI
- [migrations] Use set_ignore_missing to allow multi-crate migrations
- [cli] Run gateway migrations from db migrate command
- [telegram] Don’t delete channel from database on conflict
- [gateway] Load images nav count async to avoid blocking page serve
- [gateway] Replace branch icon, fix fork UX issues
- Streaming tool calls, skill directory, skills page UX
- [skills] Handle disable for personal/project skills
- [skills] Use app modal for delete confirmation instead of system confirm()
- [skills] Danger button on delete modal, fix disable routing for SkillsService
- [ui] Use decimal units (MB, GB) for memory display
- [ui] Strengthen skills security warning
- [ui] Prevent branch banner error from blanking the page
- [ci] Deploy docs to gh-pages branch instead of using deploy-pages action
- [ci] Use GitHub-hosted runners for PRs to protect self-hosted runners
- [ci] Add persist-credentials: false to docs workflow checkout
- [ci] Use macOS runners for apple-darwin builds
- [ci] Use project-local cargo config for cross-compilation
- [ci] Fix all build failures in release workflow
- [ci] Migrate release builds to GitHub-hosted runners
- [ci] Build Docker images natively per arch instead of QEMU emulation
- [ci] Only build Docker images on release tags, not main pushes
- [deploy] Persist config directory on cloud providers
- [ci] Use ubuntu-24.04-arm for Linux ARM jobs
- [ci] Correct cargo-sbom CycloneDX format flag
- [ci] Derive release package versions from git tags
- [ui] Compact memory info display in header bar
- [docker] Add runtime libgomp and image smoke test
- [gateway] Route setup through onboarding and stabilize hosted WS auth
- [ui] Disable sandbox controls when no backend is available
- [ci] Remove gh dependency from homebrew workflow
- [ci] Run homebrew update after release assets
- [ci] Avoid invalid secrets context in release workflow
- [hooks] Show GitHub source link for built-in hooks instead of editor
- [hooks] Use checked_div for avg latency calculation
- [gateway] Keep telegram typing active until reply send
- [sandbox] Skip prebuild when disabled and require daemon access
- [gateway] Use instance callback URL for OAuth flows
- [ci] Apply nightly rustfmt and clarify formatting checklist
- [ci] Add CUDA static libs and linker paths
- [ci] Map Debian CUDA libs into CUDA root for static link
- [browser] Register BrowserTool in tool registry
- [agents] Correctly pass tools through ProviderChain and detect tool result failures
- [browser] Include install instructions when browser launch fails
- [browser] Check macOS app bundles before PATH for browser detection
- [browser] Improve tool description with explicit examples
- [agents] Add strict mode to OpenAI-compatible tool schemas
- [browser] Default to navigate action when only url is provided
- [chat] Preserve message order when tool calls interleave with text
- [chat] Hide schema validation error cards from UI
- [chat] Show validation errors as muted informational cards
- [browser] Recursively add additionalProperties to nested schemas
- [browser] Ensure all properties in required for OpenAI strict mode
- [browser] Use shared to_openai_tools in openai_codex provider
- [browser] Add to_responses_api_tools for Codex API format
- [browser] Handle objects without properties in strict mode
- [browser] Use data URI format for screenshots
- [browser] Handle both raw base64 and data URI in screenshot display
- [local-llm] Reject MLX models in GGUF loader with helpful error
- [local-llm] Route MLX models to MLX backend via local_llm provider
- [local-llm] Allow HuggingFace repo IDs for MLX models
- [local-llm] Parse mlx_lm CLI output correctly
- [gateway] Use LocalLlmProvider for UI-added models
- [local-llm] Check legacy registry for MLX models
- [local-llm] Use local cache for MLX models instead of HF repo path
- [local-llm] Multiple fixes for model download and registration
- [browser] Improve container detection and settings error handling
- [lint] Resolve biome errors for CI
- [browser] Update deprecated rand API calls
- [browser] Improve tool description to trigger on ‘browse’ keyword
- [browser] Don’t write screenshot files to disk
- [sandbox] Resolve race condition where sandbox shows disabled at startup
- [gateway] Add missing config and metrics routes to push-notifications build
- [ui] Remove duplicate browser info from result area
- [browser] Retry on dead WebSocket connections
- [browser] Validate URLs to reject LLM garbage
- [browser] Improve screenshot handling and suppress chromiumoxide logs
- [browser] Ensure viewport is applied to pages and add debug logging
- [browser] Wait for Chrome to be ready before connecting
- [browser] Auto-track session_id to prevent pool exhaustion
- [browser] Use navigation timeout for sandboxed browser connections
- [telegram] Re-send typing indicator after tool status message
- [ci] Pin sccache action to commit SHA
- [ci] Publish local validation statuses to PR head repo
- [ci] Fallback to gh auth token for local status publishing
- [ci] Clean stale llama cmake dirs in local validator
- [ci] Use non-CUDA local validation defaults on macOS
- [ci] Wait for local statuses before failing PR checks
- [ci] Correct parallel local check PID handling
- [ci] Stabilize local validator output and show run URL
- [security] Harden skill execution and web asset safety
- [security] Require trust before enabling installed skills
- [security] Harden tarball extraction and pin install provenance
- [security] Require re-trust when skill source drifts
- [security] Block suspicious dependency install commands
- [security] Recover orphaned installs and protect seed skills
- [skills] Seed template and quiet invalid skill warnings
- [memory] Align indexing scope with openclaw defaults
- [memory] Coalesce stale-index cleanup logs
- [validation] Require clean tree for local PR status
- [agents] Replace serde_json::Value with typed ChatMessage in LlmProvider
- [scripts] Add lockfile check and enforce clean worktree in all modes
- [clippy] Address nested-if and duplicate branch warnings
- [ui] Remove redundant heartbeat token-saver panel
- [chat] Keep skill management tools available at runtime
- [gateway] Support configured GitHub repo for update checks
- [update] Disable checks when repo URL is unset
- [ci] Pin workflow actions to commit SHAs
- [ci] Expose CUDA compat libs for test runtime
- [concurrency] Remove Mutex<()> sentinels and harden exec working dir
- [startup] Ensure data dir exists and isolate exec tests
- [startup] Fail fast when workspace dirs cannot be created
- [exec] Satisfy clippy lints in sandbox detection tests
- Add voice schema to build_schema_map for validation
- [voice] Auto-enable provider and share ElevenLabs key between TTS/STT
- [voice] Align transcribing indicator right and fix recording delay
- [voice-ui] Align mic state timing and preserve settings scroll
- [chat-ui] Show reply medium badge in assistant footer
- [sandbox] Create /home/sandbox directory in generated images
- [sandbox] Create /home/sandbox at container startup via exec
- [sandbox] Default working directory to /home/sandbox
- [sandbox] Keep apt index in pre-built images
- [gateway] Remove no-op or_else closure flagged by clippy
- [ui] Bust Safari cache in dev mode and fix detected providers border
- [tools] Handle empty session_name in process tool start action
- [gateway] Validate config before restart to prevent crash loops
- [gateway] Use YAML list for allowed-tools in seeded tmux skill
- [gateway] Auto-enable ElevenLabs counterpart in onboarding voice test
- [gateway] Persist empty assistant messages for LLM history coherence
- [gateway] Remove separate voice-generating indicator during TTS
- [gateway] Enable TTS counterpart when saving ElevenLabs/Google STT key
- [gateway] Reconstruct tool messages in full context view
- [gateway] Fix queued message detection and ordering
- [gateway] Move queued messages to dedicated bottom tray
- [gateway] Clear queued tray on session switch and add debug logs
- [gateway] Never attach model footer or timestamp to user messages
- [gateway] Prevent multi-move race in queued message tray
- [gateway] Defer user message persist until after semaphore
- [gateway] Move queued messages only after response is rendered
- [tools] Fall back to DuckDuckGo when web_search API key is missing
- [onboarding] Skip finish screen and redirect to chat directly
- [ui] Rename Images nav item to Sandboxes
- [ui] Revert API routes to /api/images, remove “live” status text
- [cron] Skip heartbeat when no prompt configured, fix duplicate runs
- [tools] Collapse nested if in location tool (clippy)
- [gateway] Collapse nested if in ws timezone handling (clippy)
- [agents] Make openai-codex token refresh async to prevent runtime panic
- [gateway] Ensure session row exists before setting channel binding
- Sync Cargo.lock with workspace version 0.3.1
- [gateway] Use is_none_or instead of map_or for clippy
- [sessions] Use blocking file lock to prevent concurrent write failures
- Auto-sync Cargo.lock in local-validate.sh
- [docker] Resolve deployment errors on DigitalOcean Docker containers
- [install] Add missing -1 revision to .deb filename
- [gateway] Show real IP in banner when bound to 0.0.0.0
- [oauth] Downgrade missing token file log from warn to debug
- [oauth] Downgrade routine token store logs to debug
- [providers] Show model selector after OAuth in onboarding
- [onboarding] Improve scroll padding in channel and summary steps
- [gateway] Use route-specific body limit for upload endpoint
- [gateway] Reduce global request body limit to 2 MB
- [agents] Pass multimodal content to LLM providers instead of stripping images
- [gateway] Unlock audio autoplay via AudioContext on user gesture
- [tools] Instruct LLM to use lat/lon for searches, not place names
- [tools] Use place names in map links and skip Brave when unconfigured
- [tools] Block DuckDuckGo fallback for 1h after CAPTCHA
- [voice] Add autoplay debug logging, stop audio on mic, Esc cancels
- [ci] Move coverage job into CI workflow
- [ci] Slim down coverage job to avoid disk space exhaustion
- [ci] Exclude moltis-tools from coverage and drop sccache
- Collapse nested if blocks per clippy
- Collapse nested if blocks in provider_setup per clippy
- [tools] Use if-let instead of is_some/unwrap in map test
- Deny unwrap/expect in production code via clippy lints
- [gateway] Mark boot_time test as ignored for CI coverage
- [gateway] Fix session project filter and add clear all sessions
- [gateway] Prevent layout shift from active session border
- [gateway] Collapse nested if per clippy collapsible_if lint
- [ci] Consolidate release uploads into single job
- [gateway] Reactive session badge with optimistic updates
- [gateway] Bump session badge for channel messages
- [gateway] Stop auto-switching session on channel messages
- [gateway] Broadcast session_cleared so /clear from channels syncs web UI
- [voice] Add media-src to CSP for TTS playback and improve voice diagnostics
- [auth] Clear setup_complete when last passkey is removed
- [gateway] Bump session badge on every WS event
- [gateway] Shrink passkey action buttons and hide danger zone when no auth configured
- [ci] Align release clippy with nightly flags and fix test lints
- [browser] Add lifecycle management for browser containers (#88)
- [auth] Break onboarding redirect loop for non-local connections
- [settings] Disable browser password autofill for env fields
- [gateway] Reconnect WebSocket after remote onboarding auth
- [docker] Bind 0.0.0.0 and expose CA cert port for TLS setup
- [ci] Gate release builds on E2E tests and fix onboarding-auth flake
- [ci] Gate release builds on E2E tests and fix onboarding-auth regex
- [docker] Add chromium and demote codex token warning to debug
- [scripts] Skip –all-features for clippy on macOS without CUDA
- [scripts] Allow dirty working tree in local-only validation
- [exec] Use host data dir when no container runtime is available
- [onboarding] Update low memory warning message
- [ui] Rename “provider” to “LLM” in navigation and headings
- [gateway] Preserve chat stream event order
- [auth] Enforce auth after passkey/password setup (#93)
- [e2e] Make NoopChatService::clear() succeed and handle no-provider errors in tests
- [config] Resolve lock reentrancy deadlock in update_config
- [onboarding] Defer WS connection until auth completes
- [config] Prevent SOUL.md re-seeding after explicit clear
- [onboarding] Update test for save_soul empty-file behavior
- [ci] Restore node cache hardening and gate on zizmor
- [onboarding] Keep voice step visible in auth-gated flow
- [gateway] Stabilize anthropic onboarding model selection
- Pin deploy templates to explicit versions and auto-update on release (#98)
- [ci] Sync workspace version in Docker build
- [gateway] Update session preview immediately after clear
- [onboarding] Persist selected llm models on continue
- [agents] Cap tool-call IDs to OpenAI 40-char limit
- [config] Isolate stale-key test from env overrides
- [gateway] Include saveProviderKey extraction in provider-validation
- [e2e] Handle pre-configured providers in onboarding tests
- [gateway,tools] Fix sessions.patch camelCase, streaming artifacts, and host working dir
- [tools] Gate macOS-only sandbox helpers with cfg to fix dead-code errors on Linux CI
- [tools] Browser tool falls back to host when no container runtime
- [agents] Add memory_save and memory-anchor hints to system prompt
- [agents] Guide memory_save toward topic files to keep context lean
- [gateway] Fix disabled button rendering and add validation progress hints
- [gateway] Restore voicePending state on session reload
- [tools] Is_sandboxed() returns false when no container runtime exists
- [gateway] Constrain sandbox warnings to max-w-form width
- [memory] Normalize embeddings base_url and add disable_rag mode (#147)
- [gateway] Hide crons/heartbeat submenu when embedded in settings
- [cron] Use data_dir for job storage instead of hardcoded ~/.clawdbot/cron
- [plugins] Set working_dir on shell hooks so relative paths resolve correctly
- [gateway] Improve onboarding and provider validation flows
- [gateway] Allow openrouter model discovery without model id
- [chat] Retry provider 429s across web and channel flows (#149)
- [gateway] Queue channel replies per message with followup default (#150)
- [onboarding] Restore saved model selection and stabilize Anthropic e2e
- [tools] Harden apple container sandbox workdir bootstrap
- [cron] Normalize shorthand cron tool inputs
- [e2e] Avoid response listener race in sandboxes test
- [chat] Compact based on next-request context pressure (#166)
- [agents] Require explicit /sh for forced exec fallback (#161)
- [map] Support show_map multipoints and grouped map links (#168)
- [config] Make agent loop limit configurable and sync docs
- [skills] Support marketplace skills[] repositories
- [onboarding] Persist browser timezone and expose prompt today
- [web-search] Make DuckDuckGo fallback opt-in
- [gateway] Align show_map listing ratings to the right
- [prompt] Clarify sandbox vs data_dir path semantics
- [agents] Append runtime datetime at prompt tail
- [web-search] Load runtime env keys and robustify brave parsing
- [prompt] Preserve datetime tail ordering and add profile plan
- [mcp] Strip internal metadata from MCP tool call arguments
- [crons] Fix modal default validation and form reset on schedule change (#181)
- [gateway] Deduplicate voice replies on Telegram channels (#173)
- [browser] Centralize stale CDP connection detection (#172)
- [browser] Enable profile persistence on Apple Container
- [terminal] Force tmux window resize on client viewport change
- [telegram] Default DM policy to allowlist for new channels
- Update expired Discord invite link
- [telegram] Dispatch voice messages with empty transcription to chat
- [gateway] Broadcast voice audio filename over WebSocket for real-time playback
- [voice] Improve voice reply prompt and audio player placement
- [voice] Deliver TTS audio even when text was streamed
- [tools] Don’t register web_search tool when API key is missing
- [agents] Don’t emit regular text as ThinkingText when alongside tool calls
- [gateway,telegram,voice] Correct web UI stuck on thinking dots, table rendering, and TTS
- [telegram] Use vertical card layout for wide tables, deliver logbook for streamed voice
- [telegram] Send activity logbook as raw HTML, not markdown
- [e2e] Update share page nonce assertion after script externalization
- [cli] Skip jemalloc on Windows
- [gateway] Redirect plain HTTP to HTTPS on TLS port and optimize metrics SQLite
- [agents] Register DeepSeek via OpenAI-compatible provider for tool support
- [gateway] Sync cron modal signals when editing a job
- [providers] Prefer subscription OAuth and expose Codex onboarding
- [gateway] Surface insufficient_quota without retries
- [voice] Reuse OpenAI LLM API key for TTS and STT providers (#198)
- [ci] Only tag Docker image as latest for highest semver release (#211)
- [e2e] Fix provider selector and onboarding voice step tests
- [e2e] Use exact text match for onboarding provider row selection
- [graphql] Fix session id type, missing ok fields, chat routing, and add session.active query (#218)
- [cron] Document delivery fields in tool description and schema (#213)
- Pass speaking_rate, pitch and speed from config to voice providers (#212)
- [cli] Forward feature flags to moltis-web optional dependency
- [gateway] Harden reverse-proxy tls and websocket handling (#230)
- [cli] Skip jemalloc on linux/aarch64 (#229)
- [gateway,web] Normalize service error mapping and nav expectations
- [e2e] Rebuild gateway when startup binary is stale
- [e2e] Harden session creation wait and anthro cargo env
- [e2e] Handle no-provider chat state in agent specs
- [e2e] Skip openclaw step in onboarding openai flow
- [import] Resolve OpenClaw workspace from config and cross-machine paths
- [openclaw-import] Collapse nested if chains for clippy
- [docker] Use modern Docker CLI in published image (#238)
- [web] Update qmd package references to @tobilu/qmd
- [ci] Exclude swift-bridge from coverage and increase E2E poll timeout
- [e2e] Match error code instead of localized message in full context tests
- [gateway] Remove duplicate /certs/ca.pem route in start_gateway
- [ci] Unblock clippy and macOS bridge wasm build
- [oauth] Make OpenAI Codex callback port 1455 work in Docker (#258)
- [tests] Accept restricted-host auto sandbox backend
- [ios] Add missing .none case in NWTXTRecord switch
- [web] Read DOM value in identity blur handlers to prevent stale closure skip
- [ios] Add cancel button to connection banner for unreachable servers
- [agents] Resolve clippy collapsible-if and expect-used lints
- [ci] Add WASM component build step to all release jobs
- [ci] Parallelize macOS app with release package builds
- [release] Ship v0.10.2 packaging fixes
- [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
- [web] Lazy-load Shiki to prevent blocking page mount
- [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
- [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
- [ci] Add Tailwind CSS build step to release workflow, Dockerfile, and snapcraft
- [e2e] Wait for session history render before DOM injection in chat-abort
- [ci] Harden tailwindcss cli downloads
- [swift-bridge] Stabilize gateway migration and stream tests
- [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
- [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)
- Node WebSocket connection and UI connection string (#382)
- [config] Write IDENTITY.md and SOUL.md to agents/main/ instead of root (#384)
- [auth] Bypass auth for local API requests during onboarding (#386)
- [whatsapp] Sled persistence, graceful shutdown, and review fixes (#387)
- [cron] Add delay_ms to avoid LLM computing absolute timestamps (#377)
- [gateway] Retain proxy shutdown sender to prevent immediate proxy exit (#368)
- [agents] Include tool_result messages in LLM conversation history (#389)
- [sandbox] Auto-detect host data dir for docker-in-docker (#396)
- [chat] Compact the active channel session (#399)
- [providers] Strip stop tokens from MLX streaming output (#397)
- [config] Support legacy memory embedding keys (#400)
- [web] Address installation feedback from user testing (#398)
- [tools] Harden apple container bootstrap execs (#405)
- [telegram] Strip HTML tags from plain fallback (#404)
- [web] Clarify cron setup modal copy (#409)
- [web] Keep onboarding accessible after auth reset (#415)
- [web] Improve onboarding password autofill hints (#406)
- [chat] Persist aborted partial history (#418)
- [agents] Retry empty structured tool names (#410)
- [tools] Make sandbox cfg gates consistent for cross-platform CI
- [local-llm] Restore custom GGUF setup without restart (#417)
- [browser] Scope cached browser sessions per chat (#412)
- [browser] Align sandbox browserless timeout with pool lifecycle (#403)
- [providers] Keep minimax system messages in history
- [tools] Only expose exec node parameter when nodes are connected
- [gateway] Address greptile review feedback
- [gateway] Create heartbeat cron job on update when missing
- [gateway] Address review feedback on heartbeat cron job creation
- [agents] Sanitize model-mangled tool names from parallel calls
- [agents] Pass sanitized tool name to hook dispatch
- [agents] Add suffix-stripping invariant comment and functions_ edge case test
- [mcp] Fix JS bugs in display name edit flow
- [mcp] Add missing display_name field to doctor test structs
- [mcp] Destructure props in renderServerName and send null for blank display_name
- [tools] Replace hand-rolled html_to_text with html2text crate
- [tools] Address PR review for html_to_text
- [tools] Address second round of PR review
- [tools] Collapse consecutive blank lines, fix doc comment
- [sessions] Use write(true)+seek instead of append(true) for fd_lock on Windows
- [release] Update conditions for jobs to handle dry-run scenarios correctly
- [oauth] Use correct OpenAI client_id, add config layer and tests
- [agents] Prefer tool-capable provider when tools are registered
- [agents] Register builtin providers before genai to enable tool calling
- [gateway] Auto-focus chat input on page load
- [sessions] Add missing sandbox_enabled column migration, stop swallowing errors
- [gateway] Move sandbox button next to model select, fix default state
- Resolve all clippy warnings (collapsible_if, map_flatten, or_default, unused imports)
- [gateway] Open nav panel by default
- [gateway] Add session icons and replace spinner in-place
- [gateway] Add missing renderCompactCard export to page-chat.js
- [gateway] Fix routing, styling, and layout issues in JS modules
- [telegram] Remove channel prefix from user messages and log LLM responses
- [ci] Fix biome version and worktree test default branch
- [ci] Restore biome version pin and fix cargo fmt
- Update CLAUDE.md pre-commit checks and fix clippy warnings
- [ci] Update setup-biome to v2.7.0 and fix sort_by_key clippy lint
- [tests] Configure git user in worktree test setup for CI
- Replace sort_by with sort_by_key in project store
- [assets] Fix biome formatting and empty catch block in sandbox.js
- [ui] Match settings nav item height and style to main navigation
- [security] Wrap API keys in Secret
for memory subsystem - Use RecommendedCache for cross-platform watcher, sort_by_key for clippy
- [hooks] Ignore broken pipe on stdin write when child doesn’t read it
- [ui] Show URL and query in web tool call cards
- [sandbox] Restart stopped Apple containers in ensure_ready
- [sandbox] Pass sleep infinity to Apple Container run
- [sandbox] Promote exec routing and container logs to info level
- [sandbox] Handle Apple Container inspect returning empty for nonexistent containers
- [sandbox] Default working_dir to “/” when running inside container
- [security] Reject cross-origin WebSocket upgrades (CSWSH protection)
- [sandbox] Inject env vars in Apple Container backend
- [exec] Redact env var values from command output
- [exec] Redact base64 and hex encoded env var values from output
- [config] Use correct defaults for ToolsConfig when not deserialized
- [gateway] Nav badge counts and cron page instant load
- [memory] Write memory files to data dir instead of cwd
- [gateway] Eliminate identity flash on page load
- [ui] Use consistent button class for skills Remove button
- [ui] Fix skills Disable button style and add error feedback
- [skills] Route disable to correct RPC for plugin-sourced skills
- [ui] Use inline style for tailscale status green dot
- [ui] Use status-dot connected class for tailscale green dot
- [ui] Replace Tailwind arbitrary classes with inline styles in tailscale templates
- [ui] Make tailscale status bar fit-content width
- [ui] Make info-bar full width to match warning/error divs
- [ui] Constrain info-bar to max-width 600px matching alert divs
- [ui] Add alert-info-text to shared alert base styles
- [ui] Add btn-row and btn-row-mt CSS classes for button spacing
- [ui] Space cancel/create buttons apart and normalize height
- [ui] Improve tailscale loading message to set expectations
- [tailscale] Open Tailscale.app on macOS instead of running tailscale up
- [ui] Preserve funnel security warning when rebuilding tailscale DOM
- [ui] Use alert-warning-text style for funnel auth warning
- [ui] Replace auth text with green button linking to security settings
- [ui] Move auth button below the funnel security warning panel
- [ui] Remove @layer components wrapper to fix nav specificity
- [ui] Update session list after /clear and select next on delete
- [ui] Format environment variable timestamps with luxon
- [gateway] Stop echoing web UI messages to Telegram channel
- Collapse nested if statements in config loader
- [ci] Fix package build failures
- [ci] Correct cargo-deb assets order
- [local-llm] Detect available package managers for MLX install
- [local-llm] Detect mlx-lm installed via brew
- [ui] Show install commands on separate lines
- [local-llm] Rename provider from local-gguf to local-llm
- [local-llm] Fix HuggingFace API response parsing
- [ui] Show searching state on HuggingFace search button
- [ui] Close modal and prevent multiple clicks on HF model selection
- [chat] Remove per-message tools warning broadcast
- [ui] Remove scroll from local model list
- [ci] Install cmake for llama.cpp build and fix Biome errors
- [ci] Fix local-llm build in CI
- [migrations] Use set_ignore_missing to allow multi-crate migrations
- [cli] Run gateway migrations from db migrate command
- [telegram] Don’t delete channel from database on conflict
- [gateway] Load images nav count async to avoid blocking page serve
- [gateway] Replace branch icon, fix fork UX issues
- Streaming tool calls, skill directory, skills page UX
- [skills] Handle disable for personal/project skills
- [skills] Use app modal for delete confirmation instead of system confirm()
- [skills] Danger button on delete modal, fix disable routing for SkillsService
- [graphql] Add skill_save to MockSkills trait impl
- [gateway] Warm container_cli OnceLock at startup via spawn_blocking
- [security] Prevent stored XSS via HTML inline and block script extensions
- [e2e] Use correct event bus dispatch for send_document tests
- [e2e] Use system-event RPC to dispatch tool events in send_document tests
- [sandbox] Fix clippy CI failures on Linux
- [sandbox] Gate apple-only tests with cfg(target_os = “macos”)
- [e2e] Mock slow sandbox APIs in container tests
- [e2e] Make send_document icon test more robust on CI
- [mcp] Address review feedback on is_alive and timeout field naming
- [mcp] Reorder config update to write memory before disk
- Add missing request_timeout_secs field and allow expect in test
- [agents] Address PR review feedback for lazy tool registry
- [e2e] Mock sandbox backend in container e2e tests
- [e2e] Add afterEach unrouteAll to sandbox tests
- [auth] Add Secure attribute to session cookie when TLS is active
- [tools] Make sandbox off test explicit
- [ci] Replace glslc with glslang-tools for Ubuntu 22.04
- [providers] Address PR #408 review feedback
- [ci] Install glslc from LunarG Vulkan SDK on Ubuntu 22.04
- [whatsapp] Improve discoverability and debug logging (#460)
- [whatsapp] Address PR review feedback
- [channels] Redact secrets in channel config API responses
- [channels] Address PR review feedback
- [channels] Compute serialize_struct field counts dynamically
- [providers] Update stale MiniMax context-window comment to include M2.7
- [tools] Ignore exec node param when no nodes are connected (#427)
- [tools] Error on configured default_node when disconnected, not silent fallthrough
- [anthropic] Document system message merging for alternating-role constraint
- [chat] Re-inject datetime in context-overflow retry path
- [browser] Set world-writable permissions on container profile directory
- [browser] Replace expect() with ? in test to satisfy clippy
- [server] Support IPv6 bind addresses (#447)
- [server] Address PR review feedback on IPv6 bind fix
- [server] Include bind address in oauth error, assert address family in test
- [import] Preserve config template comments during OpenClaw import
- [import] Assert on import report in comment-preservation test
- [httpd] Address greptile review findings on PR #465
- Address second round of greptile review on PR #465
- [node-host] Update systemd unit test for quoted ExecStart args
- [web] Allow delete button on cron sessions
- Address greptile review round 3 on PR #465
- [httpd] Align metrics history window and cleanup interval with gateway
- [providers] Address PR review — double-Done, missing ProviderRaw, URL normalization
- [tools] Rescue stringified JSON and flat params in cron tool
- [tools] Exclude “patch” and “job” from flat-param rescue keys
- [ci] Replace non-existent glslc package with glslang-tools
- [docker] Use Node 22 LTS via NodeSource, persist npm cache
- [docker] Avoid silent curl failure, cache npx in container example
- [docker] Use GPG key + apt source instead of NodeSource setup script
- [docker] Ensure /etc/apt/keyrings exists before NodeSource GPG import
- [web] Await event subscriptions before accepting broadcasts
- [ci] Install vulkan-sdk from LunarG instead of Ubuntu’s old libvulkan-dev
- [dev] Make local checks OS-aware for LLM backends
- [dev] Merge main and update OS-aware local-LLM checks
- [dev] Address PR review feedback
- [skills] Use slug as fallback when skill name fails validation
- [skills] Address PR review feedback on slug fallback
- [skills] Address second round of PR review feedback
- [skills] Use tempdir in slug fallback error tests for isolation
- [skills] Improve slug error messages and test isolation
- [gateway] Suppress update banner for dev builds
Security
- [ci] Add zizmor workflow security scan to deb-packages workflow (#8)
- [skills] Add requirements system, spec compliance, and markdown rendering
- [gateway] Add passkey/password auth, API key support, and protected API routes
- Update README/CLAUDE.md, add From impl for SandboxConfig
- [readme] Rewrite with introduction, quickstart, how-it-works, and security sections
- [ui] Replace inline alert/width styles with CSS classes
- [ui] Replace inline style with ml-2 class and add funnel security warning
- [ui] Merge funnel security warning into auth warning banner
- [ui] Split funnel warning into always-visible security text and conditional auth text
- [ci] Add supply chain security and Docker documentation
- [ci] Switch to Sigstore keyless signing for all artifacts
- [ci] Require signed commits
- [ci] Remove redundant signed commits workflow
- Update repository URLs from penso/moltis to moltis-org/moltis
- [security] Add cron rate limiting, job notifications, and fix method auth
- [cli] Add
moltis config checkcommand - [ui] Add multi-step onboarding wizard at /onboarding
- [ui] Hide auth banner during onboarding, show auth step for remote users
- [telegram] OTP self-approval for non-allowlisted DM users
- [tools] Add BrowserTool for LLM agents with documentation
- [browser] Add security features and tools settings UI
- Update CHANGELOG and browser-automation docs
- Merge remote-tracking branch ‘origin/main’ into security-skills
- [security] Add append-only skill security audit log
- [security] Add third-party skills hardening guide
- Merge remote-tracking branch ‘origin/main’ into security-skills
- Unify plugins and skills into single system
- Add voice services documentation
- [gateway] Add logs download, compression, CORS security, and tower middleware stack
- [onboarding] Add passkey as default auth option in security step
- [cli] Add
moltis doctorhealth check command - [hooks] Add BeforeLLMCall/AfterLLMCall hooks and nonce-based CSP
- [gateway] Consolidate navigation into settings page
- Gate workflows on zizmor security checks
- [docs] Update Docker image references from penso/moltis to moltis-org/moltis
- Keep security controls after auth reset on localhost
- Unify localhost auth-disabled security warning
- [tools] Add dangerous command blocklist as approval safety floor
- [readme] Restructure with comparison matrix and crate architecture
- [vault] Add encryption-at-rest vault for environment variables (#219)
- [discord] Add Discord channel integration (#239)
- [sandbox] Add Wasmtime WASM sandbox, Docker hardening, generic failover (#243)
- [sandbox] Trusted network mode with domain-filtering proxy (#15)
- [ios,courier] IOS companion app and APNS push relay (#248)
- [macos] Wire settings UI to rust config backend (#267)
- [channels] Shared channel webhook middleware pipeline (#290)
- [nodes] Add multi-node support with device pairing, remote exec, and UI (#291)
- [security] Add direct nginx websocket proxy example (#364)
- [ci] Add zizmor workflow security scan to deb-packages workflow (#8)
- [skills] Add requirements system, spec compliance, and markdown rendering
- [gateway] Add passkey/password auth, API key support, and protected API routes
- Update README/CLAUDE.md, add From impl for SandboxConfig
- [readme] Rewrite with introduction, quickstart, how-it-works, and security sections
- [ui] Replace inline alert/width styles with CSS classes
- [ui] Replace inline style with ml-2 class and add funnel security warning
- [ui] Merge funnel security warning into auth warning banner
- [ui] Split funnel warning into always-visible security text and conditional auth text
- [ci] Add supply chain security and Docker documentation
- [ci] Switch to Sigstore keyless signing for all artifacts
- [ci] Require signed commits
- [ci] Remove redundant signed commits workflow
- Update repository URLs from penso/moltis to moltis-org/moltis
- [security] Add cron rate limiting, job notifications, and fix method auth
- [tools] Add send_document tool for file sharing to channels
[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 coreduring macOS binary builds. - Docker release builds now copy
apps/courierinto 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_KEYandGOOGLE_API_KEYenvironment 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 thepodmanCLI 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. Whentrusted_domainsis 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 viasandbox.trusted_domainsinmoltis.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 to0.0.0.0so 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-filtercrate: domain filtering, proxy, and audit buffer logic extracted frommoltis-toolsandmoltis-gatewayinto a standalone crate with feature flags (proxy,service,metrics). The macOS app can now depend on it directly for network audit log display viamoltis-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_callbackbridges 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 sharedreqwestclient is initialized with proxy config at gateway startup;web_fetchuses 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 (
wasmfeature, 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;.wasmmodules run via Wasmtime with preopened dirs and captured I/O. Backend:"wasm"in config - Restricted-host sandbox — new
"restricted-host"backend (extracted from the oldWasmtimeSandbox) providing honest naming for what it does: env clearing, restricted PATH, andulimitresource 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/tmpand/run, and--read-onlyroot 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-discordcrate 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 = trueto 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), andstatus(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,/helpregistered as Discord application commands with ephemeral responses - OTP module moved from
moltis-telegramto sharedmoltis-channelscrate 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 callbackmoltis_set_session_event_callbackand 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_streamfunctions 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
i18nextwith 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 (
vaultfeature, 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_imagetool 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-graphqlcrate with queries, mutations, subscriptions, customJsonscalar, andServiceCallertrait abstraction - New
moltis-providerscrate that owns provider integrations and model registry/catalog logic (OpenAI, Anthropic, OpenAI-compatible, OpenAI Codex, GitHub Copilot, Kimi Code, local GGUF, local LLM) graphqlfeature 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-guardhook in~/.moltis/hooks/dcg-guard/(manifest + handler), so destructive command guarding is available out of the box oncedcgis installed - Swift embedding POC scaffold with a new
moltis-swift-bridgestatic library crate, XcodeGen YAML project (apps/macos/project.yml), and SwiftLint wiring for SwiftUI frontend code quality - New
moltis-openclaw-importcrate 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, andopenclaw.import - New
moltis importCLI commands (detect,all,select) with--dry-runand--jsonoutput 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-msteamsplugin 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 bootstrapplus an in-UI endpoint generator in Settings → Channels - Multi-agent personas with per-agent workspaces (
data_dir()/agents/<id>/),agents.*RPC methods, and session-levelagent_idbinding/switching across web + Telegram flows chat.peekRPC method returning real-time session state (active flag, thinking text, active tool calls) for any session key- Active tool call tracking per-session in
LiveChatServicewith camelCase-serializedActiveToolCallstructs - Web UI: inline red “Stop” button inside thinking indicator,
abortedbroadcast 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 intomoltis-web - Provider wiring now routes through
moltis-providersinstead ofmoltis-agents::providers, and local LLM feature flags (local-llm,local-llm-cuda,local-llm-metal) now resolve viamoltis-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-guardexample hook docs and handler behavior to gracefully no-op whendcgis 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, andmemory.config - GraphQL
logs.ackmutation now matches backend behavior and no longer takes anidsargument - 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-importfeature in default builds - Providers now support
stream_transport = "sse" | "websocket" | "auto"in config. OpenAI can stream via Responses API WebSocket mode, andautofalls 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, andTOOLS.mdfrom 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 latestgog(steipete/gogcli) asgogandgogcli - Sandbox config now supports
/home/sandboxpersistence strategies (off,session,shared), withsharedas the default and a shared host folder mounted fromdata_dir()/sandbox/home/shared - Settings → Sandboxes now includes shared-home controls (enabled + folder path), and sandbox config supports
tools.exec.sandbox.shared_home_dirfor 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 withunknown_error. The OAuth callback server now also respects the gateway bind address (0.0.0.0in 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.dbandmemory.dbnow usejournal_mode=WALandsynchronous=NORMAL(matchingmetrics.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-ttsandgoogle-ttsaliases 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-keybadge 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-Idfrom 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-KeywithoutConnection: upgrade - GraphQL channel and memory status bridges now return schema-compatible shapes for
channels.status,channels.list, andmemory.status - Provider errors with
insufficient_quotanow surface as explicit quota/billing failures (with the upstream message) instead of generic retrying/rate-limit behavior - Linux
aarch64builds now skipjemallocto 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=truewith Moltis TLS enabled) and explains using--no-tls; HTTPS-upstream proxy setups can explicitly opt in withMOLTIS_ALLOW_TLS_BEHIND_PROXY=true - WebSocket same-origin checks now accept proxy deployments that rewrite
Hostby usingX-Forwarded-Hostin proxy mode, and treat implicit:443/:80as 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, andtopayload fields
Changed
Deprecated
Removed
Fixed
- Accessing
http://on the HTTPS port now returns a 301 redirect tohttps://instead of a garbled TLS handshake page - SQLite metrics store now uses WAL journal mode and
synchronous=NORMALto 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
wakeModefield ("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
lightweightfeature profile for memory-constrained devices (Raspberry Pi, etc.) with only essential features:jemalloc,tls,web-ui.- jemalloc allocator behind
jemallocfeature flag for lower memory fragmentation. - Configurable
history_points(metrics) andlog_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 = falsein[tools.browser], or set a custom path withprofile_dir. (#162) - Added
examples/docker-compose.coolify.ymlplus 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_maplinks 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::Clientsingleton inmoltis-agentsandmoltis-toolsreplaces 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"andcodegen-units = 1for smaller binaries.
Deprecated
Removed
Fixed
- Onboarding identity save now captures browser timezone and persists it to
USER.mdviauser_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-DDfield 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.jsonplugins[].skills[]paths (for exampleanthropics/skills), including loadingSKILL.mdentries underskills/*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_dirpath semantics in system prompts. - Gateway: align
show_maplisting 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 (
hostorsandbox), 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_iterationsinmoltis.toml(default25) instead of being hardcoded at runtime.
Security
[0.8.38] - 2026-02-17
Added
show_mapnow supports multi-point maps viapoints[], 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_modegating sooffkeeps the classic final-message delivery path. - Telegram per-account
stream_notify_on_completeoption to send a final non-silent completion message after edit-in-place streaming finishes. - Telegram per-account
stream_min_initial_charsoption (default30) 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-terminalsession, plus a+ Tabaction to create new tmux windows from the UI. - New terminal window APIs:
GET /api/terminal/windowsandPOST /api/terminal/windowsto list and create host tmux windows. - Host terminal websocket now supports
?window=<id|index>targeting and returnsactiveWindowIdin the ready payload.
Changed
- Web chat now supports
/shcommand mode: entering/shtoggles a dedicated command input state, command sends are automatically prefixed with/sh, and the token bar shows effective execution route (sandboxedvshost) 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/sandboxbeforesleep infinity, and uses explicit exec workdirs to avoidWORKDIRchdir 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 likeheyfrom 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
/shexplicit 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-Dare 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
McpOAuthOverrideconfig option for servers that don’t implement standard OAuth discoverymcp.reauthRPC 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_allowlistconfig field to exempt trusted CIDR ranges from SSRF blocking, enabling Docker inter-container networking. - Memory config: add
memory.disable_ragto 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 theChatConfigdefault, 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, parsestext/event-streamresponses, and sends best-effortDELETEon shutdown to close server sessions. - MCP docs/config examples now use the current table-based config shape and
/mcpendpoint examples for remote servers. - Memory embeddings endpoint composition now avoids duplicated path segments like
/v1/v1/embeddingsand 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_saveprompt hint — core facts go to MEMORY.md, everything else tomemory/<topic>.mdto keep context lean
Changed
Deprecated
Removed
Fixed
Security
[0.8.34] - 2026-02-15
Added
- Add explicit
memory_savehint 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_searchreturns empty - Add
zaito 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]inmoltis.tomlare injected into the Moltis process at startup. This makes API keys (Brave, OpenRouter, etc.) available to features that read fromstd::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
/modelsendpoint. 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
.jsonlfile. -
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
DefaultHasherfor 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
replyingstate insessions.listandsessions.switchRPC 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.switchresponse. - 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
AlreadyExistserrors, preventing orphan containers. AddednotFounderror 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.mdcontent directly into the system prompt (truncated at 20,000 chars) so the agent always has core memory available without needing to callmemory_searchfirst. Matches OpenClaw’s bootstrap behavior - Memory save tool: new
memory_savetool lets the LLM write to long-term memory files (MEMORY.mdormemory/<name>.md) with append/overwrite modes and immediate re-indexing for search
Changed
- Memory writing:
MemoryManagernow implements theMemoryWritertrait directly, unifying read and write paths behind a single manager. The silent memory turn andMemorySaveToolboth 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.mdat 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:
BrowserToolnow resolves sandbox mode directly fromSandboxRouterinstead of relying on a_sandboxflag injected via tool call params.
Deprecated
Removed
Fixed
- E2E onboarding failures: Fixed missing
saveProviderKeyexport inprovider-validation.jsthat 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.jsandpage-settings.js/page-channels.jsinto dedicatedvoice-utils.js,identity-utils.js, andchannel-utils.jsmodules.
Deprecated
Removed
Fixed
- Config test env isolation: Fixed spurious
save_config_to_path_removes_stale_keys_when_values_are_clearedtest failure caused byMOLTIS_IDENTITY__NAMEenvironment variable leaking into the test viaapply_env_overrides.
Security
[0.8.26] - 2026-02-14
Added
- Rustls/OpenSSL migration roadmap: Added
plans/2026-02-14-rustls-migration-and-openssl-reduction.mdwith 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
.exerelease 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_modelsRPC 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
DiscoveredModelstruct: Replaced(String, String)tuples with a typedDiscoveredModelstruct across all providers (OpenAI, GitHub Copilot, OpenAI Codex). The struct carries an optionalcreated_attimestamp from the/v1/modelsAPI, 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/modelsresponse 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 staticHashMapbuilt once at startup. - Replaced hardcoded Ollama checks with
keyOptionalmetadata: JS files no longer checkprovider.name === "ollama"for behavior. Instead, the backend exposes akeyOptionalfield 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_changedevent 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_partialtest 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.mdis now seeded with the default soul text when the file doesn’t exist, mirroring howmoltis.tomlis 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
Sessionstitle 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 assistantsuffix 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 shiptask andscripts/ship-pr.shhelper 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
LLMwhere 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
authPlaywright project’stestMatchregex/auth\.spec/also matchedonboarding-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
--versionflag:moltis --versionnow 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_NAMEenvironment variable (constructs{app}.fly.dev). - PaaS proxy detection: Added explicit
MOLTIS_BEHIND_PROXY=truetorender.yamlandfly.tomlso 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
ChatMessageobjects 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
localhostfor loopback hosts, while WebAuthn also allowsmoltis.localhostas an additional origin when RP ID islocalhost.
[0.8.3] - 2026-02-11
Fixed
- Linux clippy
unused_mutfailure: Fixed a target-specificunused_mutwarning 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
rustfmtalongsideclippy. 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 -- --checkpasses 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, with429responses,Retry-Afterheaders, and JSONretry_after_seconds. - Login retry UX: The login page now disables the password Sign In button
while throttled and shows a live
Retry in Xscountdown.
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, anddocs/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 moduleusestatement; replaced.unwrap()with.expect()in auth route tests.
[0.6.0] - 2026-02-10
Added
- CalDAV integration: New
moltis-caldavcrate 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 thecaldavfeature flag. BeforeLLMCall/AfterLLMCallhooks: 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.tomltemplate now lists all 17 hook events with correct PascalCase names and one-line descriptions. - Hook event validation:
moltis config checknow warns on unknown hook event names in the config file. - Authentication docs: Comprehensive
docs/src/authentication.mdwith 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_allimmediately closes all browser sessions. ADropsafety 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 oneauth_gatemiddleware. 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 usedis_setup_complete()(correct) while the others usedhas_password()(incorrect for passkey-only setups). - Hooks documentation: Rewritten
docs/src/hooks.mdwith complete event reference, correctedToolResultPersistclassification (modifying, not read-only), and new “Prompt Injection Filtering” section with examples. - Logs level filter UI: Settings -> Logs now shows
DEBUG/TRACElevel options only when they are enabled by the active tracing filter (including target-specific directives). Default view remainsINFOand 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 thoughis_setup_complete()was true.
[0.5.0] - 2026-02-09
Added
moltis doctorcommand: 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-scriptsto 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()withif let Someto fixclippy::unnecessary_unwrapthat 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 centraloauth_tokens.jsonstore 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_keyRPC 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_modelRPC method: Save the preferred model for any configured provider without changing credentials.models.testRPC 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_maptool: 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. Supportsuser_latitude/user_longitudeto show both positions with auto-zoom. Solves the “I can’t share links” problem in voice mode.- Location precision modes: The
get_user_locationtool now accepts aprecisionparameter —"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/meminfowhensysinforeturns 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-getwhen 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 explicitwarn!/info!logging inTokenStoreload/save/delete for diagnosability. - Provider warning noise: Downgrade “tokens not found” log from
warn!todebug!for unconfigured providers (GitHub Copilot, OpenAI Codex). - models.detect_supported noise: Downgrade UNAVAILABLE RPC errors from
warn!todebug!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@v4to commit SHA to satisfy zizmor’sunpinned-usesaudit.
[0.3.7] - 2026-02-09
Fixed
- Clippy warnings: Fixed
MutexGuardheld across await in telegram test,field assignment outside initializerin provider setup test, anditems after test modulein gateway services.
[0.3.6] - 2026-02-09
Fixed
- Release CI zizmor audit: Removed
rust-cachefrom the release workflow’s clippy-test job entirely instead of usingsave-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: falseonrust-cachein 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 blockingwrite()inSessionStore::append()andreplace_history()so concurrent tool-result persists wait for the file lock instead of failing withEAGAIN(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 fixblock_oninside async runtime panic when refreshing expired OAuth tokens. - Channel session binding: Ensure session row exists before setting channel
binding, fixing
get_user_locationfailures 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 viaGET /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
processtool 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-intmuxskill with usage instructions. -
Runtime host+sandbox prompt context: Chat system prompts now include a
## Runtimesection with host details (hostname, OS, arch, shell, provider, model, session, sudo non-interactive capability) andexecsandbox 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_idto thread responses under the original user message, keeping conversations visually grouped in the chat. -
get_user_locationtool: 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_packagestool: 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.DateTimeFormatand 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/downloadwith 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 CSWSHis_same_originlogic, safe for Docker/cloud deployments with unknown hostnames CatchPanicLayerto convert handler panics to 500 responsesRequestBodyLimitLayer(16 MiB) to prevent memory exhaustionSetSensitiveHeadersLayerto redact Authorization/Cookie in traces- Security response headers (
X-Content-Type-Options,X-Frame-Options,Referrer-Policy) SetRequestIdLayer+PropagateRequestIdLayerforx-request-idcorrelation across HTTP request logs- zstd compression alongside gzip for better ratios
- Replace
-
Message run tracking: Persisted messages now carry
run_idandseqfields 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). Addedserver.http_request_logs(Axum HTTP traces) andserver.ws_request_logs(WebSocket RPC request/response traces) config options (both defaultfalse) 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/modelsat startup (with fallback defaults), and the gateway refreshes Codex models hourly so long-running sessions pick up newly available models (for examplegpt-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_modelsinmoltis.tomlto 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 inmoltis.tomlto control which providers are shown in onboarding/provider-picker UI. New config templates default this to["openai", "github-copilot"]; settingoffered = []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_searchnow 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,/onboardingredirects 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_DIRbehavior 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 missinguser_code. -
Kimi Code provider authentication compatibility:
kimi-codeis now API-key-first in the web UI (KIMI_API_KEY, default base URLhttps://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 returnsaccess_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 inMcpManagerwith a singleRwLock<McpManagerInner>to reduce lock contention and simplify state management. - GatewayState lock consolidation: Replaced per-field
RwLocks inGatewayStatewith a singleRwLock<GatewayInner>for the same reasons. - Typed chat broadcast payloads: Chat WebSocket broadcasts now use typed
Rust structs instead of ad-hoc
serde_json::Valuemaps.
Documentation
- Expanded default
SOUL.mdwith 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 singleskills-manifest.jsonandinstalled-skills/directory. The/pluginspage has been removed — everything is accessible from the/skillspage. 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_urlfrommoltis.toml, defaults new configs tohttps://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 extracreated_atfield. ReplacedVec<serde_json::Value>with a typedChatMessageenum in theLlmProvidertrait — 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 viavalues_to_chat_messages(). - Chat skill creation not persisting new skills: Runtime tool filtering incorrectly
applied the union of discovered skill
allowed_toolsto all chat turns, which could hidecreate_skill/update_skilland leave only a subset (for exampleweb_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.providersandvoice.stt.providersconfig 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/chromeimage by default (configurable viasandbox_image) - Container readiness detection via HTTP endpoint probing
- Browser sandbox mode automatically follows the session’s sandbox mode
(no separate
browser.sandboxconfig - 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 memorymemory_limit_percent = 90blocks new instances when system memory exceeds threshold- Idle browsers cleaned up automatically before blocking
- Set
max_instances > 0for 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_factorconfig (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_DIMENSIONSandPHOTO_SAVE_FILE_INVALIDerrors
-
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
- Tool execution messages sent to Telegram (e.g., “🌐 Navigating to…”,
“💻 Running:
-
Log Target Display: Logs now include the crate/module path for easier debugging
- Example:
INFO moltis_gateway::chat: tool execution succeeded tool=browser
- Example:
-
Contributor docs: local validation: Added documentation for the
./scripts/local-validate.shworkflow, including published local status contexts, platform behavior, and CI fallback expectations. -
Hooks Web UI: New
/hookspage 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.statusevent - RPC methods:
hooks.list,hooks.enable,hooks.disable,hooks.save,hooks.reload
-
Deploy platform detection: New
MOLTIS_DEPLOY_PLATFORMenv 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) andotp_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
/onboardingand remove the separate/setupweb UI page. - Startup observability: Log each loaded context markdown (
CLAUDE.md/AGENTS.md/.claude/rules/*.md), memory markdown (MEMORY.mdandmemory/*.md), and discoveredSKILL.mdto 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.tomlinto workspaceSOUL.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 workspaceIDENTITY.mdusing YAML frontmatter; settings UI continues to edit these fields through the same RPC/API. - User profile storage: Persist user profile fields (
name,timezone) to workspaceUSER.mdusing YAML frontmatter; onboarding/settings continue to use the same API/UI while reading/writing the markdown file. - Workspace markdown support: Add
TOOLS.mdprompt injection from workspace root (data_dir), and keep startup injection sourced fromBOOT.md. - Heartbeat prompt precedence: Support workspace
HEARTBEAT.mdas heartbeat prompt source with precedenceheartbeat.prompt(config override) →HEARTBEAT.md→ built-in default; log when config prompt overridesHEARTBEAT.md. - Heartbeat UX: Expose effective heartbeat prompt source (
config,HEARTBEAT.md, or default) viaheartbeat.statusand display it in the Heartbeat settings UI. - BOOT.md onboarding aid: Seed a default workspace
BOOT.mdwith in-file guidance describing startup injection behavior and recommended usage. - Workspace context parity: Treat workspace
TOOLS.mdas general context (not only policy) and add workspaceAGENTS.mdinjection support fromdata_dir. - Heartbeat token guard: Skip heartbeat LLM turns when
HEARTBEAT.mdexists but is empty/comment-only and there is no explicitheartbeat.promptoverride, 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 skillallowed_toolsconstraints when selecting callable tools. - Skill trust lifecycle: Installed marketplace skills/plugins now track a
trustedstate 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 togitfor 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
libgomp1in the runtime image to satisfy OpenMP-linked binaries and prevent startup failures withlibgomp.so.1missing. - 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
connecton 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_approverestarted 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 = Allowlistand 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_depnow 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 restrictiveContent-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 assource 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.jsonlfor incident review. - Emergency kill switch: Added
skills.emergency_disableto 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_depnow blocks suspicious install command patterns by default (e.g. piped shell payloads, base64 decode chains, quarantine bypass) unless explicitly overridden withallow_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
gitsubprocess 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
orphanedentries 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.mdon startup when missing, so users always have a starter personal skill template. - Memory indexing scope tightened: Memory sync now indexes only
MEMORY.md/memory.mdandmemory/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.mdwith 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.tomlfor 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: falseto docs workflow checkout (fixes zizmor artipacked warning)
[0.1.4] - 2026-02-06
Added
-
--no-tlsCLI flag:--no-tlsflag andMOLTIS_NO_TLSenvironment 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 checkvalidates 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
qmdfeature flag (enabled by default) - Web UI shows installation instructions and QMD status
- Comparison table between built-in SQLite and QMD backends
- Gated behind
-
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.mdwith 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_pathconfig orCHROMEenvironment 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 toLlmProviderfor capability detection
-
Session state store: per-session key-value persistence scoped by namespace, backed by SQLite (
session_statetool). -
Session branching:
branch_sessiontool 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_skilltools let the agent manage project-local skills at runtime. -
Skill hot-reload: filesystem watcher on skill directories emits
skills.changedevents via WebSocket when SKILL.md files change. -
Typed tool sources:
ToolSourceenum (Builtin/Mcp { server }) replaces string-prefix identification of MCP tools in the tool registry. -
Tool registry metadata:
list_schemas()now includessourceandmcpServerfields so the UI can group tools by origin. -
Per-session MCP toggle: sessions store an
mcp_disabledflag; 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
/contextslash 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.statusRPC method for checking QMD availability -
Extended
memory.config.getto includeqmd_feature_enabledflag -
Push notifications feature is now enabled by default in the CLI
-
TLS HTTP redirect port now defaults to
gateway_port + 1instead of the hardcoded port18790. This makes the Dockerfile simpler (both ports are adjacent) and avoids collisions when running multiple instances. Override via[tls] http_redirect_portinmoltis.tomlor theMOLTIS_TLS__HTTP_REDIRECT_PORTenvironment variable. -
TLS certificates use
moltis.localhostdomain. Auto-generated server certs now includemoltis.localhost,*.moltis.localhost,localhost,127.0.0.1, and::1as SANs. Banner and redirect URLs usehttps://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/notAfterare 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. -
McpToolBridgenow stores and exposesserver_name()for typed registration. -
mcp_service::sync_mcp_tools()usesunregister_mcp()/register_mcp()instead of scanning tool names by prefix. -
chat.rsusesclone_without_mcp()instead ofclone_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
.localhostsubdomains (e.g.moltis.localhost) as loopback equivalents per RFC 6761. - Browser tool schema enforcement: Added
strict: trueandadditionalProperties: falseto OpenAI-compatible tool schemas, improving model compliance with required fields - Browser tool defaults: When model sends URL without action, defaults to
navigateinstead 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, andProviderChainnow implementstream_with_tools()so tool schemas are sent in the streaming API request and tool-call events are properly parsed. Previously onlyAnthropicProvidersupported 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_callshas only 1 element at position 0, the condition1 < 1was 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, anddelete_skillusedstd::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/skillsendpoint 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.