diff --git a/Directory.Build.props b/Directory.Build.props
index 50bfdee..d9d5b8c 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -23,6 +23,14 @@
snupkg
+
+ standard
+ $(OpenClawFeatureVariant)-maf
+ $(OpenClawFeatureVariant)-opensandbox
+ obj/$(OpenClawFeatureVariant)/
+ $(DefaultItemExcludes);obj/**
+
+
diff --git a/README.md b/README.md
index 435f5c7..b8926dc 100644
--- a/README.md
+++ b/README.md
@@ -7,10 +7,12 @@
[](https://opensource.org/licenses/MIT)


+
+
> **Disclaimer**: This project is not affiliated with, endorsed by, or associated with [OpenClaw](https://github.com/openclaw/openclaw). It is an independent .NET implementation inspired by their work.
-Self-hosted **AI agent runtime and gateway for .NET** with a NativeAOT-friendly `aot` lane, an expanded `jit` compatibility lane, explicit tool execution, practical OpenClaw ecosystem compatibility, observability, and security hardening controls.
+Self-hosted **AI agent runtime and gateway for .NET** with 48 native tools, 9 channel adapters, multi-agent routing, built-in tool presets, NativeAOT support, and practical OpenClaw ecosystem compatibility.
## Why This Project Exists
@@ -19,23 +21,109 @@ Most agent stacks assume Python- or Node-first runtimes. That works until you wa
OpenClaw.NET takes a different path:
- **NativeAOT-friendly runtime and gateway** for .NET agent workloads
-- **Practical reuse of existing OpenClaw TS/JS plugins and `SKILL.md` packages**
+- **Practical reuse of existing OpenClaw TS/JS plugins and `SKILL.md` packages** — install directly with `openclaw plugins install`
- A real **tool execution layer** with approval hooks, timeout handling, usage tracking, and optional sandbox routing
-- Explicit **compatibility boundaries by runtime mode** instead of vague parity claims
-- A foundation for experimenting with **production-oriented agent infrastructure in .NET**
-
-The goal is to explore what a dependable runtime and gateway layer for AI agents can look like in the .NET ecosystem.
+- **48 native tools** covering file ops, sessions, memory, web search, messaging, home automation, databases, email, calendar, and more
+- **9 channel adapters** (Telegram, SMS, WhatsApp, Teams, Slack, Discord, Signal, email, webhooks) with DM policy, allowlists, and signature validation
+- A foundation for **production-oriented agent infrastructure in .NET**
If this repo is useful to you, please star it.
-## What It Does Today
+## Key Features
+
+### Agent Runtime
+- Multi-step execution with tool calling, retries, per-call timeouts, streaming, and circuit-breaker behavior
+- Context compaction (`/compact` command or automatic) with LLM-powered summarization
+- Configurable reasoning effort (`/think off|low|medium|high`)
+- Delegated sub-agents with configurable profiles, tool restrictions, and depth limits
+- Multi-agent routing — route channels/senders with per-route model, prompt, tool preset, and tool allowlist overrides
+
+### 48 Native Tools
+
+| Category | Tools |
+|----------|-------|
+| **File & Code** | `shell`, `read_file`, `write_file`, `edit_file`, `apply_patch`, `process`, `git`, `code_exec`, `browser` |
+| **Sessions** | `sessions`, `sessions_history`, `sessions_send`, `sessions_spawn`, `sessions_yield`, `session_status`, `session_search`, `agents_list` |
+| **Memory** | `memory`, `memory_search`, `memory_get`, `project_memory` |
+| **Web & Data** | `web_search`, `web_fetch`, `x_search`, `pdf_read`, `database` |
+| **Communication** | `message`, `email`, `inbox_zero`, `calendar` |
+| **Home & IoT** | `home_assistant`, `home_assistant_write`, `mqtt`, `mqtt_publish` |
+| **Productivity** | `notion`, `notion_write`, `todo`, `automation`, `cron`, `delegate_agent` |
+| **Media & AI** | `image_gen`, `vision_analyze`, `text_to_speech` |
+| **System** | `gateway`, `profile_read`, `profile_write` |
+
+### Tool Presets & Groups
+
+Named presets control which tools are available per surface:
+
+| Preset | Description |
+|--------|-------------|
+| `full` | All tools, no restrictions |
+| `coding` | File I/O, shell, git, code execution, browser, memory, sessions |
+| `messaging` | Message, sessions, memory, profiles, todo |
+| `minimal` | `session_status` only |
+| `web` / `telegram` / `automation` / `readonly` | Channel-specific defaults |
+
+Presets compose from reusable **tool groups**: `group:runtime`, `group:fs`, `group:sessions`, `group:memory`, `group:web`, `group:automation`, `group:messaging`.
+
+### 9 Channel Adapters
+
+| Channel | Transport | Features |
+|---------|-----------|----------|
+| **Telegram** | Webhook | Media markers, photo upload, signature validation |
+| **Twilio SMS** | Webhook | Rate limiting, opt-out handling |
+| **WhatsApp** | Webhook / Bridge | Official Cloud API + Baileys bridge, typing indicators, read receipts |
+| **Teams** | Bot Framework | JWT validation, conversation references, group/DM policy |
+| **Slack** | Events API | Thread-to-session mapping, slash commands, HMAC-SHA256, mrkdwn conversion |
+| **Discord** | Gateway WebSocket | Persistent connection, slash commands, Ed25519 interaction webhook, rate limiting |
+| **Signal** | signald / signal-cli | Unix socket or subprocess bridge, privacy mode (no-content logging) |
+| **Email** | IMAP/SMTP | MailKit-based |
+| **Webhooks** | HTTP POST | Generic webhook triggers with HMAC validation |
-- A multi-step **agent runtime** with tool calling, retries, per-call timeouts, streaming, circuit-breaker behavior, context compaction, and optional parallel tool execution
-- A dedicated **tool execution layer** with approval flows, hooks, usage tracking, deterministic failure handling, and sandbox routing
-- **Skills**, memory-backed sessions, memory recall injection, project memory, and delegated sub-agents
-- A **gateway layer** for browser UI, WebSocket, OpenAI-compatible endpoints, a typed integration API, MCP, webhooks, auth, rate limits, and observability
-- Practical **OpenClaw ecosystem** compatibility: JS/TS bridge plugins, standalone `SKILL.md` packages, and native dynamic plugins in `jit`
-- Two runtime lanes (**`aot`** and **`jit`**) plus an optional **MAF orchestrator** in MAF-enabled artifacts
+All channels support DM policy (`open` / `pairing` / `closed`), sender allowlists, and deduplication.
+
+### Skills
+
+7 bundled skills: `daily-news-digest`, `data-analyst`, `deep-researcher`, `email-triage`, `homeassistant-operator`, `mqtt-operator`, `software-developer`.
+
+Skills are loaded from workspace (`skills/`), global (`~/.openclaw/skills/`), or plugins. Install from ClawHub with `openclaw clawhub install `.
+
+### Chat Commands
+
+| Command | Description |
+|---------|-------------|
+| `/status` | Session info (model, turns, tokens) |
+| `/new` / `/reset` | Clear conversation history |
+| `/model ` | Override LLM model for session |
+| `/think off\|low\|medium\|high` | Set reasoning effort level |
+| `/compact` | Trigger history compaction |
+| `/verbose on\|off` | Show tool calls and token usage per turn |
+| `/usage` | Show total token counts |
+| `/help` | List commands |
+
+### Plugin System
+
+Install community plugins directly from npm/ClawHub:
+
+```bash
+openclaw plugins install @sliverp/qqbot
+openclaw plugins install @opik/opik-openclaw
+openclaw plugins list
+openclaw plugins search openclaw dingtalk
+```
+
+Supports JS/TS bridge plugins, native dynamic .NET plugins (`jit` mode), and standalone `SKILL.md` packages. See [Plugin Compatibility Guide](docs/COMPATIBILITY.md).
+
+### Integrations
+
+| Integration | Description |
+|-------------|-------------|
+| **Tailscale Serve/Funnel** | Zero-config remote access via Tailscale |
+| **Gmail Pub/Sub** | Email event triggers via Google Pub/Sub push notifications |
+| **mDNS/Bonjour** | Local network service discovery |
+| **Semantic Kernel** | Host SK tools/agents behind the gateway |
+| **MAF Orchestrator** | Microsoft Agent Framework backend (optional) |
+| **MCP** | Model Context Protocol facade for tools, resources, prompts |
## Architecture
@@ -43,85 +131,72 @@ If this repo is useful to you, please star it.
flowchart TB
subgraph Clients
-A1[Web UI / CLI / Companion]
-A2[WebSocket / HTTP / SDK Clients]
-A3[Telegram / Twilio / WhatsApp / Teams / Email / Webhooks]
+A1[Web UI / CLI / TUI / Companion]
+A2[WebSocket / HTTP / SDK]
+A3["Channels: Telegram, SMS, WhatsApp,
Teams, Slack, Discord, Signal, Email"]
end
-subgraph OpenClawNET
-B1[Gateway]
-B2[Agent Runtime]
-B3[Tool Execution Layer]
-B4[Policy / Approval / Hardening]
-B5[Observability]
+subgraph "OpenClaw.NET Gateway"
+B1[HTTP / WebSocket Endpoints]
+B2[Message Pipeline]
+B3[Session Manager]
+B4[Agent Runtime]
+B5[Tool Execution + Presets]
+B6[Policy / Approval / Routing]
+B7[Observability + Metrics]
end
-subgraph ExecutionBackends
-C1[Native C# Tools]
+subgraph "Tool Backends"
+C1["48 Native Tools"]
C2[TS/JS Plugin Bridge]
-C3[Native Dynamic Plugins JIT]
-C4[Optional Sandbox Backend]
+C3["Native Dynamic Plugins (JIT)"]
+C4[Sandbox / Docker / SSH]
end
subgraph Infrastructure
D1[LLM Providers]
-D2[Memory / Sessions]
-D3[External APIs / Local Resources]
+D2[Memory Store + Sessions]
+D3[Cron + Automations]
+D4[External APIs]
end
Clients --> B1
B1 --> B2
B2 --> B3
-B3 --> C1
-B3 --> C2
-B3 --> C3
-B3 --> C4
-B2 --> D1
-B2 --> D2
-C1 --> D3
-C2 --> D3
-C3 --> D3
-C4 --> D3
-B1 --> B4
-B1 --> B5
-B2 --> B5
-B3 --> B5
+B3 --> B4
+B4 --> B5
+B5 --> C1 & C2 & C3 & C4
+B4 --> D1
+B3 --> D2
+B4 --> D3
+C1 & C2 & C3 --> D4
+B1 --> B6
+B1 --> B7
+B4 --> B7
```
-The gateway sits between clients, models, tools, and infrastructure, handling agent execution, tool routing, security/hardening, compatibility diagnostics, and observability.
-
-### Startup flow
-
-1. **`Bootstrap/`** — Loads config, resolves runtime mode and orchestrator, applies validation and hardening, handles early exits (`--doctor`).
-2. **`Composition/` and `Profiles/`** — Registers services and applies the effective runtime lane (`aot` or `jit`).
-3. **`Pipeline/` and `Endpoints/`** — Wires middleware, channel startup, workers, shutdown handling, and HTTP/WebSocket surfaces.
-
### Runtime flow
```mermaid
-graph TD
- Client[Web UI / CLI / Companion / WebSocket Client] <--> Gateway[Gateway]
- Webhooks[Telegram / Twilio / WhatsApp / Generic Webhooks] --> Gateway
-
- subgraph Runtime
- Gateway <--> Agent[Agent Runtime]
- Agent <--> Tools[Native Tools]
- Agent <--> Memory[(Memory + Sessions)]
- Agent <-->|Provider API| LLM{LLM Provider}
- Agent <-->|Bridge transport| Bridge[Node.js Plugin Bridge]
- Bridge <--> JSPlugins[TS / JS Plugins]
- end
+graph LR
+ Inbound["Inbound Message"] --> Route["Route Resolution"]
+ Route --> Session["Session Get/Create"]
+ Session --> Middleware["Middleware Pipeline"]
+ Middleware --> Agent["Agent Runtime"]
+ Agent --> LLM["LLM Call"]
+ LLM --> Tools["Tool Execution"]
+ Tools --> Agent
+ Agent --> Outbound["Outbound Message"]
+ Outbound --> Channel["Channel Adapter"]
```
### Runtime modes
| Mode | Description |
|------|-------------|
-| `aot` | Trim-safe, low-memory lane |
-| `jit` | Expanded bridge surfaces + native dynamic plugins |
-| `auto` | Selects `jit` when dynamic code is available, `aot` otherwise |
-
-For the full startup-module breakdown, see [docs/architecture-startup-refactor.md](docs/architecture-startup-refactor.md).
+| `aot` | Trim-safe, low-memory lane. Native tools and bridge plugins only. |
+| `jit` | Full plugin surfaces: channels, commands, providers, dynamic .NET plugins. |
+| `auto` | Selects `jit` when dynamic code is available, `aot` otherwise. |
## Quickstart
@@ -142,81 +217,45 @@ Then open one of:
| Surface | URL |
|---------|-----|
-| Web UI | `http://127.0.0.1:18789/chat` |
+| Web UI / Live Chat | `http://127.0.0.1:18789/chat` |
| WebSocket | `ws://127.0.0.1:18789/ws` |
+| Live WebSocket | `ws://127.0.0.1:18789/ws/live` |
| Integration API | `http://127.0.0.1:18789/api/integration/status` |
| MCP endpoint | `http://127.0.0.1:18789/mcp` |
| OpenAI-compatible | `http://127.0.0.1:18789/v1/responses` |
-**Optional environment variables:**
-
-| Variable | Default | Purpose |
-|----------|---------|---------|
-| `OPENCLAW_WORKSPACE` | — | Workspace directory for file tools |
-| `OpenClaw__Runtime__Mode` | `auto` | Runtime lane (`aot`, `jit`, or `auto`) |
-| `OPENCLAW_BASE_URL` | `http://127.0.0.1:18789` | CLI base URL |
-| `OPENCLAW_AUTH_TOKEN` | — | Auth token (required for non-loopback) |
-
**Other entry points:**
```bash
# CLI chat
dotnet run --project src/OpenClaw.Cli -c Release -- chat
+# CLI live session
+dotnet run --project src/OpenClaw.Cli -c Release -- live --provider gemini
+
# One-shot CLI run
dotnet run --project src/OpenClaw.Cli -c Release -- run "summarize this README" --file ./README.md
+# Install a plugin
+dotnet run --project src/OpenClaw.Cli -c Release -- plugins install @sliverp/qqbot
+
# Desktop companion (Avalonia)
dotnet run --project src/OpenClaw.Companion -c Release
-# Admin commands
-dotnet run --project src/OpenClaw.Cli -c Release -- admin posture
-dotnet run --project src/OpenClaw.Cli -c Release -- admin approvals simulate --tool shell --sender user1 --approval-tool shell
-dotnet run --project src/OpenClaw.Cli -c Release -- admin incident export
+# Terminal UI
+dotnet run --project src/OpenClaw.Cli -c Release -- tui
```
-See the full [Quickstart Guide](docs/QUICKSTART.md) for runtime mode selection and deployment notes.
-
-## Ecosystem Compatibility
+**Key environment variables:**
-OpenClaw.NET targets **practical compatibility**, especially around the mainstream tool and skill path. Compatibility is mode-specific and intentionally explicit.
-
-| Surface | `aot` | `jit` | Notes |
-| --- | --- | --- | --- |
-| Standalone `SKILL.md` packages | yes | yes | Native skill loading; no JS bridge required. |
-| `api.registerTool()` / `api.registerService()` | yes | yes | Core bridge path supported in both lanes. |
-| `api.registerChannel()` / `registerCommand()` / `registerProvider()` / `api.on(...)` | — | yes | Dynamic plugin surfaces are JIT-only. |
-| Standalone `.js` / `.mjs` / `.ts` plugins | yes | yes | `.ts` plugins require `jiti`. |
-| Native dynamic .NET plugins | — | yes | Enabled through `OpenClaw:Plugins:DynamicNative`. |
-| Unsupported bridge surfaces | fail fast | fail fast | Explicit diagnostics instead of partial load. |
-
-Pinned public smoke coverage includes `peekaboo`, `@agentseo/openclaw-plugin`, and `openclaw-tavily`, plus expected-fail cases such as `@supermemory/openclaw-supermemory`.
-
-For the detailed matrix, see [Plugin Compatibility Guide](docs/COMPATIBILITY.md).
-
-## Typed Integration API and MCP
-
-The gateway exposes three complementary remote surfaces:
-
-| Surface | Path | Purpose |
-|---------|------|---------|
-| OpenAI-compatible | `/v1/*` | Drop-in for OpenAI clients |
-| Typed integration API | `/api/integration/*` | Status, dashboard, approvals, providers, plugins, sessions, events, message enqueueing |
-| MCP facade | `/mcp` | JSON-RPC facade (`initialize`, `tools/*`, `resources/*`, `prompts/*`) |
-
-The shared `OpenClaw.Client` package exposes matching .NET methods for both surfaces:
-
-```csharp
-using OpenClaw.Client;
-
-using var client = new OpenClawHttpClient("http://127.0.0.1:18789", authToken: null);
+| Variable | Default | Purpose |
+|----------|---------|---------|
+| `MODEL_PROVIDER_KEY` | — | LLM provider API key |
+| `OPENCLAW_WORKSPACE` | — | Workspace directory for file tools |
+| `OPENCLAW_AUTH_TOKEN` | — | Auth token (required for non-loopback) |
+| `OpenClaw__Runtime__Mode` | `auto` | Runtime lane (`aot`, `jit`, or `auto`) |
-var dashboard = await client.GetIntegrationDashboardAsync(CancellationToken.None);
-var statusTool = await client.CallMcpToolAsync(
- "openclaw.get_status",
- JsonDocument.Parse("{}").RootElement.Clone(),
- CancellationToken.None);
-```
+See the full [Quickstart Guide](docs/QUICKSTART.md) for deployment notes.
## Docker Deployment
@@ -232,97 +271,42 @@ export OPENCLAW_DOMAIN="openclaw.example.com"
docker compose --profile with-tls up -d
```
-### Build from source
-
-```bash
-docker build -t openclaw.net .
-docker run -d -p 18789:18789 \
- -e MODEL_PROVIDER_KEY="sk-..." \
- -e OPENCLAW_AUTH_TOKEN="change-me" \
- -v openclaw-memory:/app/memory \
- openclaw.net
-```
-
### Published images
-Available on all three registries:
-
- `ghcr.io/clawdotnet/openclaw.net:latest`
- `tellikoroma/openclaw.net:latest`
- `public.ecr.aws/u6i5b9b7/openclaw.net:latest`
-### Volumes
-
-| Path | Purpose |
-|------|---------|
+| Volume | Purpose |
+|--------|---------|
| `/app/memory` | Session history + memory notes (persist across restarts) |
| `/app/workspace` | Mounted workspace for file tools (optional) |
-See [Docker Image Notes](docs/DOCKERHUB.md) for multi-arch push commands and image details.
+See [Docker Image Notes](docs/DOCKERHUB.md) for details.
## Security and Hardening
-When binding to a non-loopback address, the gateway **refuses to start** unless dangerous settings are explicitly hardened or opted in:
-
-- Auth token is **required** for non-loopback binds
-- Wildcard tooling roots, shell access, and plugin execution are blocked by default
-- WhatsApp webhooks require signature validation
-- `raw:` secret refs are rejected on public binds
+When binding to a non-loopback address, the gateway **refuses to start** unless dangerous settings are explicitly hardened:
-**Quick checklist:**
-
-- [ ] Set `OPENCLAW_AUTH_TOKEN` to a strong random value
-- [ ] Set `MODEL_PROVIDER_KEY` via environment variable (never in config files)
-- [ ] Use `appsettings.Production.json` (`AllowShell=false`, restricted roots)
-- [ ] Enable TLS (reverse proxy or Kestrel HTTPS)
-- [ ] Set `AllowedOrigins` if serving a web frontend
-- [ ] Configure rate limits (`MaxConnectionsPerIp`, `MessagesPerMinutePerConnection`, `SessionRateLimitPerMinute`)
-- [ ] Monitor `/health` and `/metrics` endpoints
-- [ ] Pin a specific Docker image tag in production
+- Auth token **required** for non-loopback binds
+- Wildcard tooling roots, shell access, and plugin execution blocked by default
+- Webhook signature validation enforced (Slack HMAC-SHA256, Discord Ed25519, WhatsApp X-Hub-Signature-256)
+- `raw:` secret refs rejected on public binds
+- DM policy enforcement per channel (`open`, `pairing`, `closed`)
+- Rate limiting per-IP, per-connection, and per-session
See [Security Guide](SECURITY.md) for full hardening guidance and [Sandboxing Guide](docs/sandboxing.md) for sandbox routing.
-## Channels
-
-OpenClaw.NET supports inbound channels for **Telegram**, **Twilio SMS**, **WhatsApp**, and **generic webhooks**, each with configurable body limits, allowlists, and signature validation.
-
-| Channel | Webhook path | Setup guide |
-|---------|-------------|-------------|
-| Telegram | `/telegram/inbound` | [User Guide](docs/USER_GUIDE.md) |
-| Twilio SMS | `/twilio/sms/inbound` | [User Guide](docs/USER_GUIDE.md) |
-| WhatsApp | configurable | [WhatsApp Setup](docs/WHATSAPP_SETUP.md) |
-| Generic webhooks | `/webhooks/{name}` | [User Guide](docs/USER_GUIDE.md) |
-
## Observability
| Endpoint | Description |
|----------|-------------|
-| `GET /health` | Health check (`status`, `uptime`) |
-| `GET /metrics` | Runtime counters (requests, tokens, tool calls, circuit breaker, retention) |
-| `GET /memory/retention/status` | Retention config + last run state |
-| `POST /memory/retention/sweep` | Trigger manual retention sweep (`?dryRun=true` supported) |
-
-All agent operations emit structured logs and `.NET Activity` traces with correlation IDs, exportable to OTLP collectors (Jaeger, Prometheus, Grafana).
-
-## Optional Integrations
+| `GET /health` | Health check |
+| `GET /metrics` | Runtime counters (requests, tokens, tool calls, circuit breaker) |
+| `GET /memory/retention/status` | Retention config + last sweep |
+| `POST /memory/retention/sweep` | Manual retention sweep (`?dryRun=true`) |
-### Semantic Kernel
-
-OpenClaw.NET can act as the **production gateway host** (auth, rate limits, channels, OTEL, policy) around your existing SK code. See [Semantic Kernel Guide](docs/SEMANTIC_KERNEL.md).
-
-Available: `src/OpenClaw.SemanticKernelAdapter` (adapter library) and `samples/OpenClaw.SemanticKernelInteropHost` (runnable sample).
-
-### MAF Orchestrator
-
-Microsoft Agent Framework is supported as an optional backend in MAF-enabled build artifacts. Set `Runtime.Orchestrator=maf` (default remains `native`). See the [publish script](eng/publish-gateway-artifacts.sh) for artifact details.
-
-### Notion Scratchpad
-
-Optional native Notion integration for shared scratchpads and notes (`notion` / `notion_write` tools). See [Tool Guide](docs/TOOLS_GUIDE.md).
-
-### Memory Retention
-
-Background retention sweeper for sessions and branches (opt-in). Default TTLs: sessions 30 days, branches 14 days. See [User Guide](docs/USER_GUIDE.md).
+All operations emit structured logs and `.NET Activity` traces with correlation IDs, exportable to OTLP collectors.
## Docs
@@ -349,11 +333,10 @@ GitHub Actions (`.github/workflows/ci.yml`):
Looking for:
-- Security review
+- Security review and penetration testing
- NativeAOT trimming improvements
-- Tool sandboxing ideas
+- Tool sandboxing and isolation ideas
+- New channel adapters and integrations
- Performance benchmarks
-If this aligns with your interests, open an issue.
-
-If this project helps your .NET AI work, consider starring it.
+If this aligns with your interests, open an issue. If this project helps your .NET AI work, consider starring it.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 372fc92..4df1395 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -2,6 +2,13 @@
## Recently Completed
+- **Channel expansion**: Discord (Gateway WebSocket + interaction webhook), Slack (Events API + slash commands), Signal (signald/signal-cli bridge) channel adapters with DM policy, allowlists, thread-to-session mapping, and signature validation.
+- **Tool expansion** (34 → 48 native tools): edit_file, apply_patch, message, x_search, memory_get, sessions_history, sessions_send, sessions_spawn, session_status, sessions_yield, agents_list, cron, gateway, profile_write.
+- **Tool presets and groups**: 4 new built-in presets (full, coding, messaging, minimal) and 7 built-in tool groups (group:runtime, group:fs, group:sessions, group:memory, group:web, group:automation, group:messaging).
+- **Chat commands**: /think (reasoning effort), /compact (history compaction), /verbose (tool call/token output).
+- **Multi-agent routing**: per-channel/sender routing with model override, route-scoped prompt instructions, tool presets, and tool allowlist restrictions.
+- **Integrations**: Tailscale Serve/Funnel, Gmail Pub/Sub event bridge, mDNS/Bonjour service discovery.
+- **Plugin installer**: built-in `openclaw plugins install/remove/list/search` for npm/ClawHub packages.
- Security audit closure for plugin IPC hardening, plugin-root containment, browser cancellation recovery, strict session-cap admission, and session-lock disposal.
- Admin/operator tooling:
- posture diagnostics
@@ -12,6 +19,56 @@
- Startup/runtime composition split into explicit service, channel, plugin, and runtime assembly stages.
- Optional native Notion scratchpad integration with scoped read/write tools (`notion`, `notion_write`), allowlists, and write approvals by default.
+## Runtime and Platform Expansion
+
+These are strong candidates for the next roadmap phases because they extend the current runtime, channel, and operator model without fighting the existing architecture.
+
+### Multimodal and Input Expansion
+
+4. **Voice memo transcription**
+ - Detect inbound audio across supported channels and route it through a transcription provider.
+ - Inject transcript text into the runtime before the normal agent turn starts.
+ - Provide clear degraded behavior when transcription is disabled or unavailable.
+
+5. **Checkpoint and resume for long-running tasks**
+ - Persist structured save points during multi-step execution.
+ - Allow interrupted or restarted sessions to resume from the last completed checkpoint.
+ - Start with checkpointing after successful tool batches instead of trying to snapshot every internal runtime state transition.
+
+6. **Mixture-of-agents execution**
+ - Fan out a prompt to multiple providers and synthesize a final answer from their outputs.
+ - Expose this as an optional high-cost/high-confidence runtime mode or explicit tool.
+ - Keep it profile-driven so it can be limited to selected models and use cases.
+
+### Execution and Deployment Options
+
+7. **Daytona execution backend**
+ - Add a remote workspace backend with hibernation and resume support.
+ - Fit it into the existing `IExecutionBackend` and process execution model rather than adding a separate tool path.
+ - Useful for persistent remote development-style sandboxes.
+
+8. **Modal execution backend**
+ - Add a serverless execution backend for short-lived compute-heavy tasks.
+ - Focus on one-shot and bounded process execution first.
+ - Treat GPU-enabled workloads as an optional extension once the base backend is stable.
+
+### Operator Visibility and Safety
+
+9. **CLI/TUI insights**
+ - Add an `openclaw insights` command and matching TUI panel.
+ - Summarize provider usage, token spend, tool frequency, and session counts from existing telemetry.
+ - Prefer operator-readable summaries over introducing a new analytics subsystem.
+
+10. **URL safety validation**
+ - Add SSRF-oriented URL validation in web fetch and browser tooling.
+ - Block loopback/private targets by default and allow optional blocklists.
+ - Keep this configurable, but make the safe path easy to enable globally.
+
+11. **Trajectory export**
+ - Export prompts, tool calls, results, and responses as JSONL for analysis or training pipelines.
+ - Support date-range or session-scoped export plus optional anonymization.
+ - Expose it through admin and CLI surfaces instead of burying it in storage internals.
+
## Security Hardening (Likely Breaking)
These are worthwhile changes, but they can break existing deployments or require new configuration.
diff --git a/src/OpenClaw.Agent/AgentRuntime.cs b/src/OpenClaw.Agent/AgentRuntime.cs
index 087d1b6..819a131 100644
--- a/src/OpenClaw.Agent/AgentRuntime.cs
+++ b/src/OpenClaw.Agent/AgentRuntime.cs
@@ -234,6 +234,12 @@ public async Task RunAsync(
: null
};
+ if (!string.IsNullOrWhiteSpace(session.ReasoningEffort))
+ {
+ chatOptions.AdditionalProperties ??= new AdditionalPropertiesDictionary();
+ chatOptions.AdditionalProperties["reasoning_effort"] = session.ReasoningEffort;
+ }
+
for (var i = 0; i < _maxIterations; i++)
{
// Mid-turn budget check: stop if token budget is exceeded
@@ -392,6 +398,12 @@ public async IAsyncEnumerable RunStreamingAsync(
Tools = _toolExecutor.GetToolDeclarations(session)
};
+ if (!string.IsNullOrWhiteSpace(session.ReasoningEffort))
+ {
+ chatOptions.AdditionalProperties ??= new AdditionalPropertiesDictionary();
+ chatOptions.AdditionalProperties["reasoning_effort"] = session.ReasoningEffort;
+ }
+
for (var i = 0; i < _maxIterations; i++)
{
// Mid-turn budget check: stop if token budget is exceeded
@@ -1089,7 +1101,7 @@ private static bool IsTransient(Exception ex)
/// Compacts session history by summarizing older turns via the LLM.
/// Keeps the most recent turns verbatim and replaces older ones with a summary.
///
- internal async Task CompactHistoryAsync(Session session, CancellationToken ct)
+ public async Task CompactHistoryAsync(Session session, CancellationToken ct)
{
if (session.History.Count <= _compactionThreshold)
{
@@ -1187,15 +1199,9 @@ internal async Task CompactHistoryAsync(Session session, CancellationToken ct)
private List BuildMessages(Session session)
{
- string systemPrompt;
- lock (_skillGate)
- {
- systemPrompt = _systemPrompt;
- }
-
var messages = new List
{
- new(ChatRole.System, systemPrompt)
+ new(ChatRole.System, GetSystemPrompt(session))
};
// Add history (bounded to avoid context overflow)
@@ -1227,6 +1233,20 @@ private List BuildMessages(Session session)
return messages;
}
+ private string GetSystemPrompt(Session session)
+ {
+ string systemPrompt;
+ lock (_skillGate)
+ {
+ systemPrompt = _systemPrompt;
+ }
+
+ if (string.IsNullOrWhiteSpace(session.SystemPromptOverride))
+ return systemPrompt;
+
+ return systemPrompt + "\n\n[Route Instructions]\n" + session.SystemPromptOverride.Trim();
+ }
+
private static IList BuildTurnContents(string content)
{
var (markers, remainingText) = MediaMarkerProtocol.Extract(content);
diff --git a/src/OpenClaw.Agent/OpenClawToolExecutor.cs b/src/OpenClaw.Agent/OpenClawToolExecutor.cs
index 7b303cb..a7f7d75 100644
--- a/src/OpenClaw.Agent/OpenClawToolExecutor.cs
+++ b/src/OpenClaw.Agent/OpenClawToolExecutor.cs
@@ -80,12 +80,9 @@ public OpenClawToolExecutor(
public IList GetToolDeclarations(Session session)
{
- if (_toolPresetResolver is null)
- return _toolDeclarations;
-
- var preset = _toolPresetResolver.Resolve(session, _toolsByName.Keys);
+ var preset = _toolPresetResolver?.Resolve(session, _toolsByName.Keys);
return _toolDeclarations
- .Where(item => preset.AllowedTools.Contains(item.Name))
+ .Where(item => IsToolAllowedForSession(session, item.Name, preset))
.ToArray();
}
@@ -140,9 +137,11 @@ public async Task ExecuteAsync(
}
var preset = _toolPresetResolver?.Resolve(session, _toolsByName.Keys);
- if (preset is not null && !preset.AllowedTools.Contains(tool.Name))
+ if (!IsToolAllowedForSession(session, tool.Name, preset))
{
- var deniedByPreset = $"Tool '{tool.Name}' is not allowed for preset '{preset.PresetId}'.";
+ var deniedByPreset = preset is not null
+ ? $"Tool '{tool.Name}' is not allowed for preset '{preset.PresetId}'."
+ : $"Tool '{tool.Name}' is not allowed for this session.";
_logger?.LogInformation("[{CorrelationId}] {Message}", turnCtx.CorrelationId, deniedByPreset);
return CreateImmediateResult(toolName, argsJson, deniedByPreset);
}
@@ -308,6 +307,17 @@ private static ToolExecutionResult CreateImmediateResult(string toolName, string
};
}
+ private static bool IsToolAllowedForSession(Session session, string toolName, ResolvedToolPreset? preset)
+ {
+ if (preset is not null && !preset.AllowedTools.Contains(toolName))
+ return false;
+
+ if (session.RouteAllowedTools is { Length: > 0 })
+ return session.RouteAllowedTools.Contains(toolName, StringComparer.OrdinalIgnoreCase);
+
+ return true;
+ }
+
private async Task ExecuteStreamingToolCollectAsync(
IStreamingTool tool,
string argsJson,
diff --git a/src/OpenClaw.Agent/Tools/ApplyPatchTool.cs b/src/OpenClaw.Agent/Tools/ApplyPatchTool.cs
new file mode 100644
index 0000000..6b8adf5
--- /dev/null
+++ b/src/OpenClaw.Agent/Tools/ApplyPatchTool.cs
@@ -0,0 +1,151 @@
+using OpenClaw.Core.Abstractions;
+using OpenClaw.Core.Models;
+
+namespace OpenClaw.Agent.Tools;
+
+///
+/// Apply a unified diff patch to a file. Supports multi-hunk patches.
+///
+public sealed class ApplyPatchTool : ITool
+{
+ private readonly ToolingConfig _config;
+
+ public ApplyPatchTool(ToolingConfig config) => _config = config;
+
+ public string Name => "apply_patch";
+ public string Description => "Apply a unified diff patch to a file. Supports multi-hunk patches for complex edits.";
+ public string ParameterSchema => """{"type":"object","properties":{"path":{"type":"string","description":"File path to patch"},"patch":{"type":"string","description":"Unified diff patch content (lines starting with +/- and @@ hunk headers)"}},"required":["path","patch"]}""";
+
+ public async ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct)
+ {
+ if (_config.ReadOnlyMode)
+ return "Error: apply_patch is disabled because Tooling.ReadOnlyMode is enabled.";
+
+ using var args = System.Text.Json.JsonDocument.Parse(
+ string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
+ var root = args.RootElement;
+
+ var path = GetString(root, "path");
+ if (string.IsNullOrWhiteSpace(path))
+ return "Error: 'path' is required.";
+
+ var patch = GetString(root, "patch");
+ if (string.IsNullOrWhiteSpace(patch))
+ return "Error: 'patch' is required.";
+
+ var resolvedPath = ToolPathPolicy.ResolveRealPath(path);
+
+ if (!ToolPathPolicy.IsWriteAllowed(_config, resolvedPath))
+ return $"Error: Write access denied for path: {path}";
+
+ if (!File.Exists(resolvedPath))
+ return $"Error: File not found: {path}";
+
+ var originalLines = await File.ReadAllLinesAsync(resolvedPath, ct);
+ var hunks = ParseHunks(patch);
+
+ if (hunks.Count == 0)
+ return "Error: No valid hunks found in patch. Use @@ -start,count +start,count @@ headers.";
+
+ var result = new List(originalLines);
+ var offset = 0;
+
+ foreach (var hunk in hunks)
+ {
+ var startLine = hunk.OriginalStart - 1 + offset;
+ if (startLine < 0 || startLine > result.Count)
+ return $"Error: Hunk at line {hunk.OriginalStart} is out of range (file has {result.Count} lines).";
+
+ // Validate removed lines match file content
+ if (startLine + hunk.RemoveLines.Count > result.Count)
+ return $"Error: Hunk at line {hunk.OriginalStart} expects {hunk.RemoveLines.Count} lines to remove, but only {result.Count - startLine} lines remain.";
+
+ for (var i = 0; i < hunk.RemoveLines.Count; i++)
+ {
+ var expected = hunk.RemoveLines[i];
+ var actual = result[startLine + i];
+ if (!string.Equals(expected.TrimEnd(), actual.TrimEnd(), StringComparison.Ordinal))
+ return $"Error: Hunk at line {hunk.OriginalStart + i} mismatch. Expected: \"{Truncate(expected, 60)}\" Got: \"{Truncate(actual, 60)}\"";
+ }
+
+ // Remove old lines (validated above)
+ for (var i = 0; i < hunk.RemoveLines.Count; i++)
+ result.RemoveAt(startLine);
+
+ // Insert new lines
+ for (var i = hunk.AddLines.Count - 1; i >= 0; i--)
+ result.Insert(startLine, hunk.AddLines[i]);
+
+ offset += hunk.AddLines.Count - hunk.RemoveLines.Count;
+ }
+
+ var tmp = resolvedPath + ".tmp";
+ try
+ {
+ await File.WriteAllLinesAsync(tmp, result, ct);
+ File.Move(tmp, resolvedPath, overwrite: true);
+ }
+ catch
+ {
+ try { File.Delete(tmp); } catch { /* best-effort cleanup */ }
+ throw;
+ }
+
+ return $"Applied {hunks.Count} hunk(s) to {path}.";
+ }
+
+ private sealed record Hunk(int OriginalStart, List RemoveLines, List AddLines);
+
+ private static List ParseHunks(string patch)
+ {
+ var hunks = new List();
+ var lines = patch.Split('\n');
+ Hunk? current = null;
+
+ foreach (var rawLine in lines)
+ {
+ var line = rawLine.TrimEnd('\r');
+
+ if (line.StartsWith("@@", StringComparison.Ordinal))
+ {
+ if (current is not null)
+ hunks.Add(current);
+
+ var origStart = ParseHunkStart(line);
+ current = new Hunk(origStart, [], []);
+ }
+ else if (current is not null)
+ {
+ if (line.StartsWith('-'))
+ current.RemoveLines.Add(line[1..]);
+ else if (line.StartsWith('+'))
+ current.AddLines.Add(line[1..]);
+ // Context lines (starting with space) are skipped — we trust line numbers
+ }
+ }
+
+ if (current is not null)
+ hunks.Add(current);
+
+ return hunks;
+ }
+
+ private static int ParseHunkStart(string header)
+ {
+ // Parse @@ -start,count +start,count @@
+ var idx = header.IndexOf('-', 3);
+ if (idx < 0) return 1;
+ var comma = header.IndexOf(',', idx);
+ var end = comma > 0 ? comma : header.IndexOf(' ', idx + 1);
+ if (end < 0) end = header.Length;
+ return int.TryParse(header.AsSpan(idx + 1, end - idx - 1), out var start) ? start : 1;
+ }
+
+ private static string Truncate(string s, int maxLen)
+ => s.Length <= maxLen ? s : s[..maxLen] + "…";
+
+ private static string? GetString(System.Text.Json.JsonElement root, string property)
+ => root.TryGetProperty(property, out var el) && el.ValueKind == System.Text.Json.JsonValueKind.String
+ ? el.GetString()
+ : null;
+}
diff --git a/src/OpenClaw.Agent/Tools/EditFileTool.cs b/src/OpenClaw.Agent/Tools/EditFileTool.cs
new file mode 100644
index 0000000..f7aff47
--- /dev/null
+++ b/src/OpenClaw.Agent/Tools/EditFileTool.cs
@@ -0,0 +1,107 @@
+using OpenClaw.Core.Abstractions;
+using OpenClaw.Core.Models;
+
+namespace OpenClaw.Agent.Tools;
+
+///
+/// Targeted search-and-replace editing of files. Safer than full write_file for small changes.
+///
+public sealed class EditFileTool : ITool
+{
+ private readonly ToolingConfig _config;
+
+ public EditFileTool(ToolingConfig config) => _config = config;
+
+ public string Name => "edit_file";
+ public string Description => "Edit a file by replacing a specific text string with new text. Safer than write_file for targeted changes.";
+ public string ParameterSchema => """{"type":"object","properties":{"path":{"type":"string","description":"File path to edit"},"old_text":{"type":"string","description":"Exact text to find and replace"},"new_text":{"type":"string","description":"Replacement text"},"replace_all":{"type":"boolean","description":"Replace all occurrences (default: false)"}},"required":["path","old_text","new_text"]}""";
+
+ public async ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct)
+ {
+ if (_config.ReadOnlyMode)
+ return "Error: edit_file is disabled because Tooling.ReadOnlyMode is enabled.";
+
+ using var args = System.Text.Json.JsonDocument.Parse(
+ string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
+ var root = args.RootElement;
+
+ var path = GetString(root, "path");
+ if (string.IsNullOrWhiteSpace(path))
+ return "Error: 'path' is required.";
+
+ var oldText = GetString(root, "old_text");
+ if (oldText is null || oldText.Length == 0)
+ return "Error: 'old_text' is required and must not be empty.";
+
+ var newText = GetString(root, "new_text");
+ if (newText is null)
+ return "Error: 'new_text' is required.";
+
+ var replaceAll = root.TryGetProperty("replace_all", out var ra) &&
+ ra.ValueKind == System.Text.Json.JsonValueKind.True;
+
+ var resolvedPath = ToolPathPolicy.ResolveRealPath(path);
+
+ if (!ToolPathPolicy.IsWriteAllowed(_config, resolvedPath))
+ return $"Error: Write access denied for path: {path}";
+
+ if (!File.Exists(resolvedPath))
+ return $"Error: File not found: {path}";
+
+ var content = await File.ReadAllTextAsync(resolvedPath, ct);
+
+ if (!content.Contains(oldText, StringComparison.Ordinal))
+ return "Error: 'old_text' not found in file.";
+
+ if (!replaceAll)
+ {
+ var firstIdx = content.IndexOf(oldText, StringComparison.Ordinal);
+ var lastIdx = content.LastIndexOf(oldText, StringComparison.Ordinal);
+ if (firstIdx != lastIdx)
+ return "Error: 'old_text' appears multiple times. Set replace_all=true or provide more context to make it unique.";
+ }
+
+ var updated = replaceAll
+ ? content.Replace(oldText, newText, StringComparison.Ordinal)
+ : ReplaceFirst(content, oldText, newText);
+
+ var tmp = resolvedPath + ".tmp";
+ try
+ {
+ await File.WriteAllTextAsync(tmp, updated, ct);
+ File.Move(tmp, resolvedPath, overwrite: true);
+ }
+ catch
+ {
+ try { File.Delete(tmp); } catch { /* best-effort cleanup */ }
+ throw;
+ }
+
+ var count = replaceAll ? CountOccurrences(content, oldText) : 1;
+ return $"Replaced {count} occurrence(s) in {path}.";
+ }
+
+ private static string ReplaceFirst(string source, string oldValue, string newValue)
+ {
+ var idx = source.IndexOf(oldValue, StringComparison.Ordinal);
+ if (idx < 0) return source;
+ return string.Concat(source.AsSpan(0, idx), newValue, source.AsSpan(idx + oldValue.Length));
+ }
+
+ private static int CountOccurrences(string source, string value)
+ {
+ var count = 0;
+ var idx = 0;
+ while ((idx = source.IndexOf(value, idx, StringComparison.Ordinal)) >= 0)
+ {
+ count++;
+ idx += value.Length;
+ }
+ return count;
+ }
+
+ private static string? GetString(System.Text.Json.JsonElement root, string property)
+ => root.TryGetProperty(property, out var el) && el.ValueKind == System.Text.Json.JsonValueKind.String
+ ? el.GetString()
+ : null;
+}
diff --git a/src/OpenClaw.Agent/Tools/MemoryGetTool.cs b/src/OpenClaw.Agent/Tools/MemoryGetTool.cs
new file mode 100644
index 0000000..1c71d2c
--- /dev/null
+++ b/src/OpenClaw.Agent/Tools/MemoryGetTool.cs
@@ -0,0 +1,36 @@
+using OpenClaw.Core.Abstractions;
+
+namespace OpenClaw.Agent.Tools;
+
+///
+/// Retrieve a specific memory note by key. Direct get without action parameter.
+///
+public sealed class MemoryGetTool : ITool
+{
+ private readonly IMemoryStore _store;
+
+ public MemoryGetTool(IMemoryStore store) => _store = store;
+
+ public string Name => "memory_get";
+ public string Description => "Retrieve a specific memory note by its key. Returns the note content or an error if not found.";
+ public string ParameterSchema => """{"type":"object","properties":{"key":{"type":"string","description":"The memory note key to retrieve"}},"required":["key"]}""";
+
+ public async ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct)
+ {
+ using var args = System.Text.Json.JsonDocument.Parse(
+ string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
+ var root = args.RootElement;
+
+ var key = root.TryGetProperty("key", out var k) && k.ValueKind == System.Text.Json.JsonValueKind.String
+ ? k.GetString() : null;
+ if (string.IsNullOrWhiteSpace(key))
+ return "Error: 'key' is required.";
+
+ var keyError = OpenClaw.Core.Security.InputSanitizer.CheckMemoryKey(key);
+ if (keyError is not null)
+ return $"Error: {keyError}";
+
+ var content = await _store.LoadNoteAsync(key, ct);
+ return content ?? $"Note '{key}' not found.";
+ }
+}
diff --git a/src/OpenClaw.Agent/Tools/MessageTool.cs b/src/OpenClaw.Agent/Tools/MessageTool.cs
new file mode 100644
index 0000000..4725b19
--- /dev/null
+++ b/src/OpenClaw.Agent/Tools/MessageTool.cs
@@ -0,0 +1,60 @@
+using System.Threading.Channels;
+using OpenClaw.Core.Abstractions;
+using OpenClaw.Core.Models;
+using OpenClaw.Core.Pipeline;
+
+namespace OpenClaw.Agent.Tools;
+
+///
+/// Send messages across channels. Routes outbound messages through the pipeline.
+///
+public sealed class MessageTool : ITool
+{
+ private readonly ChannelWriter _outbound;
+
+ public MessageTool(MessagePipeline pipeline)
+ {
+ _outbound = pipeline.OutboundWriter;
+ }
+
+ public string Name => "message";
+ public string Description => "Send a message to a specific channel and recipient. Use to communicate across channels.";
+ public string ParameterSchema => """{"type":"object","properties":{"channel_id":{"type":"string","description":"Target channel (e.g. 'telegram', 'slack', 'discord', 'sms', 'email', 'websocket')"},"recipient_id":{"type":"string","description":"Recipient identifier (chat ID, user ID, phone number, etc.)"},"text":{"type":"string","description":"Message text to send"},"reply_to":{"type":"string","description":"Optional message ID to reply to"}},"required":["channel_id","recipient_id","text"]}""";
+
+ public async ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct)
+ {
+ using var args = System.Text.Json.JsonDocument.Parse(
+ string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
+ var root = args.RootElement;
+
+ var channelId = GetString(root, "channel_id");
+ if (string.IsNullOrWhiteSpace(channelId))
+ return "Error: 'channel_id' is required.";
+
+ var recipientId = GetString(root, "recipient_id");
+ if (string.IsNullOrWhiteSpace(recipientId))
+ return "Error: 'recipient_id' is required.";
+
+ var text = GetString(root, "text");
+ if (string.IsNullOrWhiteSpace(text))
+ return "Error: 'text' is required.";
+
+ var replyTo = GetString(root, "reply_to");
+
+ var message = new OutboundMessage
+ {
+ ChannelId = channelId,
+ RecipientId = recipientId,
+ Text = text,
+ ReplyToMessageId = replyTo,
+ };
+
+ await _outbound.WriteAsync(message, ct);
+ return $"Message queued for delivery to {channelId}:{recipientId}.";
+ }
+
+ private static string? GetString(System.Text.Json.JsonElement root, string property)
+ => root.TryGetProperty(property, out var el) && el.ValueKind == System.Text.Json.JsonValueKind.String
+ ? el.GetString()
+ : null;
+}
diff --git a/src/OpenClaw.Agent/Tools/XSearchTool.cs b/src/OpenClaw.Agent/Tools/XSearchTool.cs
new file mode 100644
index 0000000..0f2f43f
--- /dev/null
+++ b/src/OpenClaw.Agent/Tools/XSearchTool.cs
@@ -0,0 +1,94 @@
+using System.Net.Http;
+using System.Text.Json;
+using OpenClaw.Core.Abstractions;
+using OpenClaw.Core.Http;
+using OpenClaw.Core.Models;
+using OpenClaw.Core.Security;
+
+namespace OpenClaw.Agent.Tools;
+
+///
+/// Search posts on the X (Twitter) platform using the X API v2.
+///
+public sealed class XSearchTool : ITool, IDisposable
+{
+ private readonly HttpClient _http;
+ private readonly string? _bearerToken;
+
+ public XSearchTool()
+ {
+ _http = HttpClientFactory.Create();
+ _bearerToken = SecretResolver.Resolve("env:X_BEARER_TOKEN");
+ }
+
+ public string Name => "x_search";
+ public string Description => "Search posts on the X (Twitter) platform. Requires X_BEARER_TOKEN environment variable.";
+ public string ParameterSchema => """{"type":"object","properties":{"query":{"type":"string","description":"Search query (supports X search operators)"},"max_results":{"type":"integer","description":"Maximum results to return (10-100, default 10)"}},"required":["query"]}""";
+
+ public async ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(_bearerToken))
+ return "Error: X API bearer token not configured. Set X_BEARER_TOKEN environment variable.";
+
+ using var args = JsonDocument.Parse(
+ string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
+ var root = args.RootElement;
+
+ var query = GetString(root, "query");
+ if (string.IsNullOrWhiteSpace(query))
+ return "Error: 'query' is required.";
+
+ var maxResults = 10;
+ if (root.TryGetProperty("max_results", out var mr) && mr.ValueKind == JsonValueKind.Number)
+ maxResults = Math.Clamp(mr.GetInt32(), 10, 100);
+
+ try
+ {
+ var url = $"https://api.x.com/2/tweets/search/recent?query={Uri.EscapeDataString(query)}&max_results={maxResults}&tweet.fields=created_at,author_id,text,public_metrics";
+
+ using var request = new HttpRequestMessage(HttpMethod.Get, url);
+ request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _bearerToken);
+
+ var response = await _http.SendAsync(request, ct);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ var errorBody = await response.Content.ReadAsStringAsync(ct);
+ return $"Error: X API returned {(int)response.StatusCode}. {errorBody}";
+ }
+
+ var body = await response.Content.ReadAsStringAsync(ct);
+ using var doc = JsonDocument.Parse(body);
+ var data = doc.RootElement;
+
+ if (!data.TryGetProperty("data", out var tweets) || tweets.ValueKind != JsonValueKind.Array)
+ return "No results found.";
+
+ var sb = new System.Text.StringBuilder();
+ var count = 0;
+ foreach (var tweet in tweets.EnumerateArray())
+ {
+ count++;
+ var text = tweet.TryGetProperty("text", out var t) ? t.GetString() : "";
+ var authorId = tweet.TryGetProperty("author_id", out var a) ? a.GetString() : "unknown";
+ var createdAt = tweet.TryGetProperty("created_at", out var c) ? c.GetString() : "";
+ sb.AppendLine($"[{count}] @{authorId} ({createdAt})");
+ sb.AppendLine(text);
+ sb.AppendLine();
+ }
+
+ return sb.Length > 0 ? sb.ToString().TrimEnd() : "No results found.";
+ }
+ catch (Exception ex)
+ {
+ return $"Error: X search failed: {ex.Message}";
+ }
+ }
+
+ public void Dispose() => _http.Dispose();
+
+ private static string? GetString(JsonElement root, string property)
+ => root.TryGetProperty(property, out var el) && el.ValueKind == JsonValueKind.String
+ ? el.GetString()
+ : null;
+}
diff --git a/src/OpenClaw.Channels/DiscordChannel.cs b/src/OpenClaw.Channels/DiscordChannel.cs
new file mode 100644
index 0000000..45ff044
--- /dev/null
+++ b/src/OpenClaw.Channels/DiscordChannel.cs
@@ -0,0 +1,503 @@
+using System.Buffers;
+using System.Net.Http;
+using System.Net.Http.Json;
+using System.Net.WebSockets;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.Logging;
+using OpenClaw.Core.Abstractions;
+using OpenClaw.Core.Http;
+using OpenClaw.Core.Models;
+using OpenClaw.Core.Security;
+
+namespace OpenClaw.Channels;
+
+///
+/// A channel adapter for the Discord Bot API.
+/// Uses a Gateway WebSocket for receiving messages and REST API for sending.
+/// Interaction webhooks (slash commands) are handled separately in the gateway.
+///
+public sealed class DiscordChannel : IChannelAdapter
+{
+ private const string ApiBase = "https://discord.com/api/v10";
+ private const string GatewayUrl = "wss://gateway.discord.gg/?v=10&encoding=json";
+ private const int GatewayIntentGuildMessages = 1 << 9;
+ private const int GatewayIntentDirectMessages = 1 << 12;
+ private const int GatewayIntentMessageContent = 1 << 15;
+
+ private readonly DiscordChannelConfig _config;
+ private readonly HttpClient _http;
+ private readonly ILogger _logger;
+ private readonly string _botToken;
+ private readonly string? _applicationId;
+ private readonly bool _ownsHttp;
+
+ private ClientWebSocket? _gateway;
+ private CancellationTokenSource? _cts;
+ private Task? _receiveLoop;
+ private int? _lastSequence;
+ private string? _sessionId;
+ private string? _resumeGatewayUrl;
+
+ public DiscordChannel(DiscordChannelConfig config, ILogger logger)
+ : this(config, logger, http: null)
+ {
+ }
+
+ public DiscordChannel(DiscordChannelConfig config, ILogger logger, HttpClient? http)
+ {
+ _config = config;
+ _logger = logger;
+ _http = http ?? HttpClientFactory.Create();
+ _ownsHttp = http is null;
+
+ var tokenSource = SecretResolver.Resolve(config.BotTokenRef) ?? config.BotToken;
+ _botToken = tokenSource ?? throw new InvalidOperationException("Discord bot token not configured or missing from environment.");
+
+ _applicationId = SecretResolver.Resolve(config.ApplicationIdRef) ?? config.ApplicationId;
+ }
+
+ public string ChannelType => "discord";
+ public string ChannelId => "discord";
+
+ public event Func? OnMessageReceived;
+
+ public async Task StartAsync(CancellationToken ct)
+ {
+ if (_config.RegisterSlashCommands && !string.IsNullOrWhiteSpace(_applicationId))
+ await RegisterSlashCommandsAsync(ct);
+
+ _cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ _receiveLoop = RunGatewayLoopAsync(_cts.Token);
+ }
+
+ public async ValueTask SendAsync(OutboundMessage outbound, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(outbound.Text)) return;
+
+ try
+ {
+ var (markers, remaining) = MediaMarkerProtocol.Extract(outbound.Text);
+ var text = string.IsNullOrWhiteSpace(remaining) ? outbound.Text : remaining;
+
+ // Discord message limit is 2000 chars
+ if (text.Length > 2000)
+ text = text[..2000];
+
+ var payload = new DiscordCreateMessageRequest { Content = text };
+ var response = await SendMessageRequestAsync(outbound.RecipientId, payload, ct);
+
+ // Handle rate limiting
+ if ((int)response.StatusCode == 429)
+ {
+ var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1);
+ _logger.LogWarning("Discord rate limited, retrying after {RetryAfter}ms.", retryAfter.TotalMilliseconds);
+ response.Dispose();
+ await Task.Delay(retryAfter, ct);
+ response = await SendMessageRequestAsync(outbound.RecipientId, payload, ct);
+ }
+
+ response.EnsureSuccessStatusCode();
+ response.Dispose();
+ _logger.LogInformation("Sent Discord message to channel {ChannelId}.", outbound.RecipientId);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to send Discord message to {RecipientId}.", outbound.RecipientId);
+ }
+ }
+
+ private async Task SendMessageRequestAsync(
+ string recipientId,
+ DiscordCreateMessageRequest payload,
+ CancellationToken ct)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Post, $"{ApiBase}/channels/{recipientId}/messages");
+ request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bot", _botToken);
+ request.Content = JsonContent.Create(payload, DiscordJsonContext.Default.DiscordCreateMessageRequest);
+ return await _http.SendAsync(request, ct);
+ }
+
+ private async Task RunGatewayLoopAsync(CancellationToken ct)
+ {
+ var backoff = TimeSpan.FromSeconds(1);
+ const int maxBackoff = 60;
+
+ while (!ct.IsCancellationRequested)
+ {
+ try
+ {
+ _gateway = new ClientWebSocket();
+ var url = _resumeGatewayUrl ?? GatewayUrl;
+ await _gateway.ConnectAsync(new Uri(url), ct);
+ _logger.LogInformation("Connected to Discord Gateway.");
+
+ backoff = TimeSpan.FromSeconds(1);
+ await ProcessGatewayMessagesAsync(ct);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Discord Gateway disconnected. Reconnecting in {Backoff}s.", backoff.TotalSeconds);
+ }
+ finally
+ {
+ if (_gateway?.State == WebSocketState.Open)
+ {
+ try { await _gateway.CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None); }
+ catch { /* best-effort close */ }
+ }
+ _gateway?.Dispose();
+ _gateway = null;
+ }
+
+ await Task.Delay(backoff, ct);
+ backoff = TimeSpan.FromSeconds(Math.Min(backoff.TotalSeconds * 2, maxBackoff));
+ }
+ }
+
+ private async Task ProcessGatewayMessagesAsync(CancellationToken ct)
+ {
+ var buffer = new ArrayBufferWriter(4096);
+ Task? heartbeatTask = null;
+ CancellationTokenSource? heartbeatCts = null;
+
+ try
+ {
+ while (!ct.IsCancellationRequested && _gateway?.State == WebSocketState.Open)
+ {
+ buffer.Clear();
+ ValueWebSocketReceiveResult result;
+ do
+ {
+ var memory = buffer.GetMemory(4096);
+ result = await _gateway.ReceiveAsync(memory, ct);
+ buffer.Advance(result.Count);
+ } while (!result.EndOfMessage);
+
+ if (result.MessageType == WebSocketMessageType.Close)
+ return;
+
+ using var doc = JsonDocument.Parse(buffer.WrittenMemory);
+ var root = doc.RootElement;
+
+ var op = root.GetProperty("op").GetInt32();
+ if (root.TryGetProperty("s", out var seqProp) && seqProp.ValueKind == JsonValueKind.Number)
+ _lastSequence = seqProp.GetInt32();
+
+ switch (op)
+ {
+ case 10: // Hello — start heartbeat and identify
+ var interval = root.GetProperty("d").GetProperty("heartbeat_interval").GetInt32();
+ heartbeatCts?.Cancel();
+ heartbeatCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ heartbeatTask = RunHeartbeatAsync(interval, heartbeatCts.Token);
+
+ if (_sessionId is not null)
+ await SendResumeAsync(ct);
+ else
+ await SendIdentifyAsync(ct);
+ break;
+
+ case 0: // Dispatch
+ var eventName = root.TryGetProperty("t", out var tProp) ? tProp.GetString() : null;
+ if (root.TryGetProperty("d", out var data))
+ await HandleDispatchAsync(eventName, data, ct);
+ break;
+
+ case 7: // Reconnect
+ _logger.LogInformation("Discord Gateway requested reconnect.");
+ return;
+
+ case 9: // Invalid Session — re-identify
+ _sessionId = null;
+ _lastSequence = null;
+ await Task.Delay(Random.Shared.Next(1000, 5000), ct);
+ await SendIdentifyAsync(ct);
+ break;
+
+ case 11: // Heartbeat ACK — no action needed
+ break;
+ }
+ }
+ }
+ finally
+ {
+ heartbeatCts?.Cancel();
+ if (heartbeatTask is not null)
+ {
+ try { await heartbeatTask; }
+ catch (OperationCanceledException) { }
+ }
+ heartbeatCts?.Dispose();
+ }
+ }
+
+ private async Task RunHeartbeatAsync(int intervalMs, CancellationToken ct)
+ {
+ // Jitter the first heartbeat
+ await Task.Delay(Random.Shared.Next(0, intervalMs), ct);
+ while (!ct.IsCancellationRequested)
+ {
+ await SendGatewayPayloadAsync(1, _lastSequence?.ToString() ?? "null", ct);
+ await Task.Delay(intervalMs, ct);
+ }
+ }
+
+ private async Task SendIdentifyAsync(CancellationToken ct)
+ {
+ var intents = GatewayIntentGuildMessages | GatewayIntentDirectMessages | GatewayIntentMessageContent;
+ var payload = "{\"op\":2,\"d\":{\"token\":\"" + _botToken + "\",\"intents\":" + intents +
+ ",\"properties\":{\"os\":\"linux\",\"browser\":\"openclaw\",\"device\":\"openclaw\"}}}";
+ await SendRawAsync(payload, ct);
+ }
+
+ private async Task SendResumeAsync(CancellationToken ct)
+ {
+ var payload = "{\"op\":6,\"d\":{\"token\":\"" + _botToken + "\",\"session_id\":\"" + _sessionId +
+ "\",\"seq\":" + (_lastSequence ?? 0) + "}}";
+ await SendRawAsync(payload, ct);
+ }
+
+ private async Task SendGatewayPayloadAsync(int op, string data, CancellationToken ct)
+ {
+ var payload = $"{{\"op\":{op},\"d\":{data}}}";
+ await SendRawAsync(payload, ct);
+ }
+
+ private async Task SendRawAsync(string payload, CancellationToken ct)
+ {
+ if (_gateway?.State != WebSocketState.Open) return;
+ var bytes = Encoding.UTF8.GetBytes(payload);
+ await _gateway.SendAsync(bytes, WebSocketMessageType.Text, true, ct);
+ }
+
+ private async Task HandleDispatchAsync(string? eventName, JsonElement data, CancellationToken ct)
+ {
+ switch (eventName)
+ {
+ case "READY":
+ _sessionId = data.TryGetProperty("session_id", out var sid) ? sid.GetString() : null;
+ _resumeGatewayUrl = data.TryGetProperty("resume_gateway_url", out var rgu) ? rgu.GetString() : null;
+ _logger.LogInformation("Discord Gateway ready. Session: {SessionId}.", _sessionId);
+ break;
+
+ case "MESSAGE_CREATE":
+ await HandleMessageCreateAsync(data, ct);
+ break;
+ }
+ }
+
+ private async Task HandleMessageCreateAsync(JsonElement data, CancellationToken ct)
+ {
+ // Ignore bot messages
+ if (data.TryGetProperty("author", out var author) &&
+ author.TryGetProperty("bot", out var bot) &&
+ bot.ValueKind == JsonValueKind.True)
+ return;
+
+ var userId = author.TryGetProperty("id", out var uid) ? uid.GetString() : null;
+ var text = data.TryGetProperty("content", out var content) ? content.GetString() : null;
+ var channelId = data.TryGetProperty("channel_id", out var cid) ? cid.GetString() : null;
+ var messageId = data.TryGetProperty("id", out var mid) ? mid.GetString() : null;
+ var guildId = data.TryGetProperty("guild_id", out var gid) ? gid.GetString() : null;
+
+ if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(text))
+ return;
+
+ // Guild allowlist
+ if (_config.AllowedGuildIds.Length > 0 && !string.IsNullOrWhiteSpace(guildId))
+ {
+ if (!Array.Exists(_config.AllowedGuildIds, id => string.Equals(id, guildId, StringComparison.Ordinal)))
+ return;
+ }
+
+ // Channel allowlist
+ if (_config.AllowedChannelIds.Length > 0 && !string.IsNullOrWhiteSpace(channelId))
+ {
+ if (!Array.Exists(_config.AllowedChannelIds, id => string.Equals(id, channelId, StringComparison.Ordinal)))
+ return;
+ }
+
+ // User allowlist
+ if (_config.AllowedFromUserIds.Length > 0)
+ {
+ if (!Array.Exists(_config.AllowedFromUserIds, id => string.Equals(id, userId, StringComparison.Ordinal)))
+ return;
+ }
+
+ if (text.Length > _config.MaxInboundChars)
+ text = text[.._config.MaxInboundChars];
+
+ // Thread detection
+ var isThread = data.TryGetProperty("thread", out _) ||
+ data.TryGetProperty("message_reference", out _) &&
+ data.TryGetProperty("position", out _); // thread messages have position
+ var isDm = guildId is null;
+
+ // Session mapping
+ string? sessionId;
+ if (isThread && channelId is not null)
+ sessionId = $"discord:thread:{channelId}";
+ else if (isDm)
+ sessionId = null; // default: discord:{userId}
+ else if (channelId is not null)
+ sessionId = $"discord:{channelId}:{userId}";
+ else
+ sessionId = null;
+
+ // Reply-to mapping
+ string? replyToId = null;
+ if (data.TryGetProperty("message_reference", out var msgRef) &&
+ msgRef.TryGetProperty("message_id", out var refId))
+ replyToId = refId.GetString();
+
+ var message = new InboundMessage
+ {
+ ChannelId = "discord",
+ SenderId = userId,
+ SessionId = sessionId,
+ Text = text,
+ MessageId = messageId,
+ ReplyToMessageId = replyToId,
+ IsGroup = !isDm,
+ GroupId = guildId,
+ };
+
+ if (OnMessageReceived is not null)
+ await OnMessageReceived(message, ct);
+ }
+
+ private async Task RegisterSlashCommandsAsync(CancellationToken ct)
+ {
+ try
+ {
+ var commandName = _config.SlashCommandPrefix;
+ var payload = $$"""[{"name":"{{commandName}}","description":"Send a message to OpenClaw","type":1,"options":[{"name":"message","description":"Your message","type":3,"required":true}]}]""";
+
+ using var request = new HttpRequestMessage(HttpMethod.Put, $"{ApiBase}/applications/{_applicationId}/commands");
+ request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bot", _botToken);
+ request.Content = new StringContent(payload, Encoding.UTF8, "application/json");
+
+ var response = await _http.SendAsync(request, ct);
+ if (response.IsSuccessStatusCode)
+ _logger.LogInformation("Registered Discord slash command '/{Command}'.", commandName);
+ else
+ _logger.LogWarning("Failed to register Discord slash commands: {Status}.", response.StatusCode);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to register Discord slash commands.");
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ _cts?.Cancel();
+ if (_receiveLoop is not null)
+ {
+ try { await _receiveLoop; }
+ catch (OperationCanceledException) { }
+ }
+ _cts?.Dispose();
+ _gateway?.Dispose();
+ if (_ownsHttp)
+ _http.Dispose();
+ }
+}
+
+public sealed class DiscordCreateMessageRequest
+{
+ [JsonPropertyName("content")]
+ public required string Content { get; set; }
+}
+
+public sealed class DiscordInteraction
+{
+ [JsonPropertyName("id")]
+ public string? Id { get; set; }
+
+ [JsonPropertyName("type")]
+ public int Type { get; set; }
+
+ [JsonPropertyName("data")]
+ public DiscordInteractionData? Data { get; set; }
+
+ [JsonPropertyName("guild_id")]
+ public string? GuildId { get; set; }
+
+ [JsonPropertyName("channel_id")]
+ public string? ChannelId { get; set; }
+
+ [JsonPropertyName("member")]
+ public DiscordInteractionMember? Member { get; set; }
+
+ [JsonPropertyName("user")]
+ public DiscordInteractionUser? User { get; set; }
+
+ [JsonPropertyName("token")]
+ public string? Token { get; set; }
+}
+
+public sealed class DiscordInteractionData
+{
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("options")]
+ public DiscordInteractionOption[]? Options { get; set; }
+}
+
+public sealed class DiscordInteractionOption
+{
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("value")]
+ public JsonElement? Value { get; set; }
+}
+
+public sealed class DiscordInteractionMember
+{
+ [JsonPropertyName("user")]
+ public DiscordInteractionUser? User { get; set; }
+}
+
+public sealed class DiscordInteractionUser
+{
+ [JsonPropertyName("id")]
+ public string? Id { get; set; }
+
+ [JsonPropertyName("username")]
+ public string? Username { get; set; }
+}
+
+public sealed class DiscordInteractionResponse
+{
+ [JsonPropertyName("type")]
+ public int Type { get; set; }
+
+ [JsonPropertyName("data")]
+ public DiscordInteractionResponseData? Data { get; set; }
+}
+
+public sealed class DiscordInteractionResponseData
+{
+ [JsonPropertyName("content")]
+ public string? Content { get; set; }
+}
+
+[JsonSerializable(typeof(DiscordCreateMessageRequest))]
+[JsonSerializable(typeof(DiscordInteraction))]
+[JsonSerializable(typeof(DiscordInteractionData))]
+[JsonSerializable(typeof(DiscordInteractionOption))]
+[JsonSerializable(typeof(DiscordInteractionMember))]
+[JsonSerializable(typeof(DiscordInteractionUser))]
+[JsonSerializable(typeof(DiscordInteractionResponse))]
+[JsonSerializable(typeof(DiscordInteractionResponseData))]
+[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
+public partial class DiscordJsonContext : JsonSerializerContext;
diff --git a/src/OpenClaw.Channels/SignalChannel.cs b/src/OpenClaw.Channels/SignalChannel.cs
new file mode 100644
index 0000000..23c7dc6
--- /dev/null
+++ b/src/OpenClaw.Channels/SignalChannel.cs
@@ -0,0 +1,374 @@
+using System.IO;
+using System.Net.Sockets;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.Logging;
+using OpenClaw.Core.Abstractions;
+using OpenClaw.Core.Models;
+using OpenClaw.Core.Security;
+
+namespace OpenClaw.Channels;
+
+///
+/// A channel adapter for Signal messaging via signald or signal-cli bridge.
+/// Communicates over a Unix domain socket (signald) or subprocess JSON-RPC.
+///
+public sealed class SignalChannel : IChannelAdapter
+{
+ private readonly SignalChannelConfig _config;
+ private readonly ILogger _logger;
+ private readonly string _accountNumber;
+
+ private CancellationTokenSource? _cts;
+ private Task? _receiveLoop;
+
+ public SignalChannel(SignalChannelConfig config, ILogger logger)
+ {
+ _config = config;
+ _logger = logger;
+
+ var phone = SecretResolver.Resolve(config.AccountPhoneNumberRef) ?? config.AccountPhoneNumber;
+ _accountNumber = phone ?? throw new InvalidOperationException("Signal account phone number not configured or missing from environment.");
+ }
+
+ public string ChannelType => "signal";
+ public string ChannelId => "signal";
+
+ public event Func? OnMessageReceived;
+
+ public Task StartAsync(CancellationToken ct)
+ {
+ _cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+
+ if (string.Equals(_config.Driver, "signald", StringComparison.OrdinalIgnoreCase))
+ _receiveLoop = RunSignaldLoopAsync(_cts.Token);
+ else if (string.Equals(_config.Driver, "signal_cli", StringComparison.OrdinalIgnoreCase))
+ _receiveLoop = RunSignalCliLoopAsync(_cts.Token);
+ else
+ throw new InvalidOperationException($"Unknown Signal driver: '{_config.Driver}'. Expected 'signald' or 'signal_cli'.");
+
+ return Task.CompletedTask;
+ }
+
+ public async ValueTask SendAsync(OutboundMessage outbound, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(outbound.Text)) return;
+
+ try
+ {
+ if (string.Equals(_config.Driver, "signald", StringComparison.OrdinalIgnoreCase))
+ await SendViaSignaldAsync(outbound.RecipientId, outbound.Text, ct);
+ else
+ await SendViaSignalCliAsync(outbound.RecipientId, outbound.Text, ct);
+
+ LogSend(outbound.RecipientId);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to send Signal message to {Recipient}.", outbound.RecipientId);
+ }
+ }
+
+ private void LogSend(string recipient)
+ {
+ if (_config.NoContentLogging)
+ _logger.LogInformation("Sent Signal message to {Recipient}. [content redacted]", recipient);
+ else
+ _logger.LogInformation("Sent Signal message to {Recipient}.", recipient);
+ }
+
+ // ── signald driver ──────────────────────────────────────────
+
+ private async Task RunSignaldLoopAsync(CancellationToken ct)
+ {
+ var backoff = TimeSpan.FromSeconds(1);
+ const int maxBackoff = 60;
+
+ while (!ct.IsCancellationRequested)
+ {
+ try
+ {
+ using var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
+ var endpoint = new UnixDomainSocketEndPoint(_config.SocketPath);
+ await socket.ConnectAsync(endpoint, ct);
+ _logger.LogInformation("Connected to signald at {Path}.", _config.SocketPath);
+
+ backoff = TimeSpan.FromSeconds(1);
+
+ // Subscribe to account
+ var subscribe = $$"""{"type":"subscribe","account":"{{_accountNumber}}"}""";
+ await SendToSocketAsync(socket, subscribe, ct);
+
+ if (_config.TrustAllKeys)
+ {
+ var trust = $$"""{"type":"trust","account":"{{_accountNumber}}","trust_level":"TRUSTED_UNVERIFIED"}""";
+ await SendToSocketAsync(socket, trust, ct);
+ }
+
+ using var stream = new NetworkStream(socket, ownsSocket: false);
+ using var reader = new StreamReader(stream, Encoding.UTF8);
+
+ while (!ct.IsCancellationRequested)
+ {
+ var line = await reader.ReadLineAsync(ct);
+ if (line is null) break;
+
+ await ProcessSignaldMessageAsync(line, ct);
+ }
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "signald connection lost. Reconnecting in {Backoff}s.", backoff.TotalSeconds);
+ }
+
+ await Task.Delay(backoff, ct);
+ backoff = TimeSpan.FromSeconds(Math.Min(backoff.TotalSeconds * 2, maxBackoff));
+ }
+ }
+
+ private async Task ProcessSignaldMessageAsync(string json, CancellationToken ct)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ var type = root.TryGetProperty("type", out var t) ? t.GetString() : null;
+ if (!string.Equals(type, "IncomingMessage", StringComparison.OrdinalIgnoreCase))
+ return;
+
+ if (!root.TryGetProperty("data", out var data))
+ return;
+
+ // Skip group messages (DM-only in v1)
+ if (data.TryGetProperty("data_message", out var dataMsg))
+ {
+ if (dataMsg.TryGetProperty("group", out _) || dataMsg.TryGetProperty("groupV2", out _))
+ {
+ _logger.LogDebug("Ignoring Signal group message (DM-only mode).");
+ return;
+ }
+
+ var body = dataMsg.TryGetProperty("body", out var bodyProp) ? bodyProp.GetString() : null;
+ var source = data.TryGetProperty("source", out var src) ? src.GetString() : null;
+
+ if (string.IsNullOrWhiteSpace(body) || string.IsNullOrWhiteSpace(source))
+ return;
+
+ await DispatchInboundAsync(source, body, ct);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to process signald message.");
+ }
+ }
+
+ private async Task SendViaSignaldAsync(string recipient, string text, CancellationToken ct)
+ {
+ using var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
+ var endpoint = new UnixDomainSocketEndPoint(_config.SocketPath);
+ await socket.ConnectAsync(endpoint, ct);
+
+ var payload = $$"""{"type":"send","username":"{{_accountNumber}}","recipientAddress":{"number":"{{recipient}}"},"messageBody":"{{EscapeJson(text)}}"}""";
+ await SendToSocketAsync(socket, payload, ct);
+ }
+
+ private static async Task SendToSocketAsync(Socket socket, string message, CancellationToken ct)
+ {
+ var bytes = Encoding.UTF8.GetBytes(message + "\n");
+ await socket.SendAsync(bytes, SocketFlags.None, ct);
+ }
+
+ // ── signal-cli driver ───────────────────────────────────────
+
+ private async Task RunSignalCliLoopAsync(CancellationToken ct)
+ {
+ var cliPath = _config.SignalCliPath ?? "signal-cli";
+ var backoff = TimeSpan.FromSeconds(1);
+ const int maxBackoff = 60;
+
+ while (!ct.IsCancellationRequested)
+ {
+ try
+ {
+ var psi = new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = cliPath,
+ Arguments = $"-u {_accountNumber} daemon --json",
+ RedirectStandardOutput = true,
+ RedirectStandardInput = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+
+ using var process = System.Diagnostics.Process.Start(psi);
+ if (process is null)
+ {
+ _logger.LogError("Failed to start signal-cli process.");
+ break;
+ }
+
+ _logger.LogInformation("Started signal-cli daemon for {Account}.", _accountNumber);
+ backoff = TimeSpan.FromSeconds(1);
+
+ try
+ {
+ using var reader = process.StandardOutput;
+ while (!ct.IsCancellationRequested)
+ {
+ var line = await reader.ReadLineAsync(ct);
+ if (line is null) break;
+
+ await ProcessSignalCliMessageAsync(line, ct);
+ }
+ }
+ finally
+ {
+ // Ensure the daemon process is terminated to avoid orphans
+ if (!process.HasExited)
+ {
+ try { process.Kill(entireProcessTree: true); }
+ catch { /* best effort */ }
+ }
+ }
+
+ await process.WaitForExitAsync(CancellationToken.None);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "signal-cli process exited. Restarting in {Backoff}s.", backoff.TotalSeconds);
+ }
+
+ await Task.Delay(backoff, ct);
+ backoff = TimeSpan.FromSeconds(Math.Min(backoff.TotalSeconds * 2, maxBackoff));
+ }
+ }
+
+ private async Task ProcessSignalCliMessageAsync(string json, CancellationToken ct)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ if (!root.TryGetProperty("envelope", out var envelope))
+ return;
+
+ var source = envelope.TryGetProperty("sourceNumber", out var src) ? src.GetString() : null;
+
+ if (!envelope.TryGetProperty("dataMessage", out var dataMsg))
+ return;
+
+ // Skip group messages
+ if (dataMsg.TryGetProperty("groupInfo", out _))
+ {
+ _logger.LogDebug("Ignoring Signal group message (DM-only mode).");
+ return;
+ }
+
+ var body = dataMsg.TryGetProperty("message", out var msgProp) ? msgProp.GetString() : null;
+ if (string.IsNullOrWhiteSpace(body) || string.IsNullOrWhiteSpace(source))
+ return;
+
+ await DispatchInboundAsync(source, body, ct);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to process signal-cli message.");
+ }
+ }
+
+ private async Task SendViaSignalCliAsync(string recipient, string text, CancellationToken ct)
+ {
+ var cliPath = _config.SignalCliPath ?? "signal-cli";
+ var psi = new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = cliPath,
+ RedirectStandardOutput = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+
+ psi.ArgumentList.Add("-u");
+ psi.ArgumentList.Add(_accountNumber);
+ psi.ArgumentList.Add("send");
+ psi.ArgumentList.Add("-m");
+ psi.ArgumentList.Add(text);
+ psi.ArgumentList.Add(recipient);
+
+ using var process = System.Diagnostics.Process.Start(psi);
+ if (process is not null)
+ await process.WaitForExitAsync(ct);
+ }
+
+ // ── common ──────────────────────────────────────────────────
+
+ private async Task DispatchInboundAsync(string senderNumber, string text, CancellationToken ct)
+ {
+ // Allowlist check
+ if (_config.AllowedFromNumbers.Length > 0)
+ {
+ if (!Array.Exists(_config.AllowedFromNumbers, n => string.Equals(n, senderNumber, StringComparison.Ordinal)))
+ return;
+ }
+
+ if (text.Length > _config.MaxInboundChars)
+ text = text[.._config.MaxInboundChars];
+
+ if (_config.NoContentLogging)
+ _logger.LogInformation("Received Signal message from {Sender}. [content redacted]", senderNumber);
+ else
+ _logger.LogInformation("Received Signal message from {Sender}.", senderNumber);
+
+ var message = new InboundMessage
+ {
+ ChannelId = "signal",
+ SenderId = senderNumber,
+ Text = text,
+ IsGroup = false,
+ };
+
+ if (OnMessageReceived is not null)
+ await OnMessageReceived(message, ct);
+ }
+
+ private static string EscapeJson(string s) =>
+ s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r");
+
+ public async ValueTask DisposeAsync()
+ {
+ _cts?.Cancel();
+ if (_receiveLoop is not null)
+ {
+ try { await _receiveLoop; }
+ catch (OperationCanceledException) { }
+ }
+ _cts?.Dispose();
+ }
+}
+
+[JsonSerializable(typeof(SignalSendRequest))]
+[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
+public partial class SignalJsonContext : JsonSerializerContext;
+
+public sealed class SignalSendRequest
+{
+ [JsonPropertyName("type")]
+ public string Type { get; set; } = "send";
+
+ [JsonPropertyName("username")]
+ public required string Username { get; set; }
+
+ [JsonPropertyName("messageBody")]
+ public required string MessageBody { get; set; }
+}
diff --git a/src/OpenClaw.Channels/SlackChannel.cs b/src/OpenClaw.Channels/SlackChannel.cs
new file mode 100644
index 0000000..59dc771
--- /dev/null
+++ b/src/OpenClaw.Channels/SlackChannel.cs
@@ -0,0 +1,183 @@
+using System.Net.Http;
+using System.Net.Http.Json;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.Logging;
+using OpenClaw.Core.Abstractions;
+using OpenClaw.Core.Http;
+using OpenClaw.Core.Models;
+using OpenClaw.Core.Security;
+
+namespace OpenClaw.Channels;
+
+///
+/// A channel adapter for the Slack Web API.
+/// Inbound traffic arrives via Slack Events API webhooks handled in the gateway.
+///
+public sealed partial class SlackChannel : IChannelAdapter
+{
+ private readonly SlackChannelConfig _config;
+ private readonly HttpClient _http;
+ private readonly ILogger _logger;
+ private readonly string _botToken;
+
+ public SlackChannel(SlackChannelConfig config, ILogger logger)
+ {
+ _config = config;
+ _logger = logger;
+ _http = HttpClientFactory.Create();
+
+ var tokenSource = SecretResolver.Resolve(config.BotTokenRef) ?? config.BotToken;
+ _botToken = tokenSource ?? throw new InvalidOperationException("Slack bot token not configured or missing from environment.");
+ }
+
+ public string ChannelType => "slack";
+ public string ChannelId => "slack";
+#pragma warning disable CS0067 // Event is never used
+ public event Func? OnMessageReceived;
+#pragma warning restore CS0067
+
+ public Task StartAsync(CancellationToken ct) => Task.CompletedTask;
+
+ public async ValueTask SendAsync(OutboundMessage outbound, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(outbound.Text)) return;
+
+ try
+ {
+ var (markers, remaining) = MediaMarkerProtocol.Extract(outbound.Text);
+ var text = ConvertToMrkdwn(string.IsNullOrWhiteSpace(remaining) ? outbound.Text : remaining);
+
+ var payload = new SlackPostMessageRequest
+ {
+ Channel = outbound.RecipientId,
+ Text = text,
+ ThreadTs = outbound.ReplyToMessageId
+ };
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, "https://slack.com/api/chat.postMessage");
+ request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _botToken);
+ request.Content = JsonContent.Create(payload, SlackJsonContext.Default.SlackPostMessageRequest);
+
+ var response = await _http.SendAsync(request, ct);
+
+ if ((int)response.StatusCode == 429)
+ {
+ var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1);
+ _logger.LogWarning("Slack rate limited for {Channel}. Retry-After: {RetryAfter}s.", outbound.RecipientId, retryAfter.TotalSeconds);
+ return;
+ }
+
+ response.EnsureSuccessStatusCode();
+
+ // Slack returns 200 with ok=false for application-level errors
+ var responseBody = await response.Content.ReadAsStringAsync(ct);
+ using var doc = JsonDocument.Parse(responseBody);
+ if (doc.RootElement.TryGetProperty("ok", out var okProp) && okProp.ValueKind == JsonValueKind.False)
+ {
+ var error = doc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : "unknown";
+ _logger.LogError("Slack API error sending to {Channel}: {Error}", outbound.RecipientId, error);
+ return;
+ }
+
+ _logger.LogInformation("Sent Slack message to {Channel}", outbound.RecipientId);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to send Slack message to {Channel}", outbound.RecipientId);
+ }
+ }
+
+ ///
+ /// Converts basic Markdown to Slack mrkdwn format.
+ ///
+ internal static string ConvertToMrkdwn(string markdown)
+ {
+ // Convert **bold** to *bold*
+ var result = BoldRegex().Replace(markdown, "*$1*");
+ // Convert [text](url) to
+ result = LinkRegex().Replace(result, "<$2|$1>");
+ return result;
+ }
+
+ [GeneratedRegex(@"\*\*(.+?)\*\*")]
+ private static partial Regex BoldRegex();
+
+ [GeneratedRegex(@"\[([^\]]+)\]\(([^)]+)\)")]
+ private static partial Regex LinkRegex();
+
+ public ValueTask DisposeAsync()
+ {
+ _http.Dispose();
+ return ValueTask.CompletedTask;
+ }
+}
+
+public sealed class SlackPostMessageRequest
+{
+ [JsonPropertyName("channel")]
+ public required string Channel { get; set; }
+
+ [JsonPropertyName("text")]
+ public required string Text { get; set; }
+
+ [JsonPropertyName("thread_ts")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? ThreadTs { get; set; }
+}
+
+public sealed class SlackEventWrapper
+{
+ [JsonPropertyName("type")]
+ public string? Type { get; set; }
+
+ [JsonPropertyName("challenge")]
+ public string? Challenge { get; set; }
+
+ [JsonPropertyName("team_id")]
+ public string? TeamId { get; set; }
+
+ [JsonPropertyName("event")]
+ public SlackEvent? Event { get; set; }
+
+ [JsonPropertyName("event_id")]
+ public string? EventId { get; set; }
+}
+
+public sealed class SlackEvent
+{
+ [JsonPropertyName("type")]
+ public string? Type { get; set; }
+
+ [JsonPropertyName("subtype")]
+ public string? Subtype { get; set; }
+
+ [JsonPropertyName("user")]
+ public string? User { get; set; }
+
+ [JsonPropertyName("bot_id")]
+ public string? BotId { get; set; }
+
+ [JsonPropertyName("text")]
+ public string? Text { get; set; }
+
+ [JsonPropertyName("channel")]
+ public string? Channel { get; set; }
+
+ [JsonPropertyName("channel_type")]
+ public string? ChannelType { get; set; }
+
+ [JsonPropertyName("ts")]
+ public string? Ts { get; set; }
+
+ [JsonPropertyName("thread_ts")]
+ public string? ThreadTs { get; set; }
+}
+
+[JsonSerializable(typeof(SlackPostMessageRequest))]
+[JsonSerializable(typeof(SlackEventWrapper))]
+[JsonSerializable(typeof(SlackEvent))]
+[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
+public partial class SlackJsonContext : JsonSerializerContext;
diff --git a/src/OpenClaw.Cli/PluginCommands.cs b/src/OpenClaw.Cli/PluginCommands.cs
new file mode 100644
index 0000000..977092e
--- /dev/null
+++ b/src/OpenClaw.Cli/PluginCommands.cs
@@ -0,0 +1,457 @@
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Text.Json;
+using OpenClaw.Core.Models;
+using OpenClaw.Core.Plugins;
+
+namespace OpenClaw.Cli;
+
+///
+/// Built-in plugin management commands: install, remove, list, search.
+/// Fetches plugins from npm (which also hosts ClawHub packages) and installs
+/// them into the extensions directory for the plugin bridge to discover.
+///
+internal static class PluginCommands
+{
+ private const string EnvWorkspace = "OPENCLAW_WORKSPACE";
+
+ public static async Task RunAsync(string[] args)
+ {
+ if (args.Length == 0 || args[0] is "-h" or "--help")
+ {
+ PrintHelp();
+ return 0;
+ }
+
+ var subcommand = args[0];
+ var rest = args.Skip(1).ToArray();
+
+ return subcommand switch
+ {
+ "install" => await InstallAsync(rest),
+ "remove" or "uninstall" => await RemoveAsync(rest),
+ "list" or "ls" => ListInstalled(rest),
+ "search" => await SearchAsync(rest),
+ _ => UnknownSubcommand(subcommand)
+ };
+ }
+
+ private static async Task InstallAsync(string[] args)
+ {
+ if (args.Length == 0)
+ {
+ Console.Error.WriteLine("Usage: openclaw plugins install ");
+ return 2;
+ }
+
+ var packageSpec = args[0];
+ var global = args.Contains("--global") || args.Contains("-g");
+ var extensionsDir = ResolveExtensionsDir(global);
+
+ Directory.CreateDirectory(extensionsDir);
+
+ // Check if it's a local path
+ if (Directory.Exists(packageSpec) || File.Exists(packageSpec))
+ {
+ return await InstallFromLocalAsync(packageSpec, extensionsDir);
+ }
+
+ // Install from npm/ClawHub
+ return await InstallFromNpmAsync(packageSpec, extensionsDir);
+ }
+
+ private static async Task InstallFromNpmAsync(string packageSpec, string extensionsDir)
+ {
+ Console.WriteLine($"Installing {packageSpec} from npm...");
+
+ // Use npm pack to download the tarball, then extract into extensions dir
+ var tempDir = Path.Combine(Path.GetTempPath(), $"openclaw-install-{Guid.NewGuid():N}"[..24]);
+ Directory.CreateDirectory(tempDir);
+
+ try
+ {
+ // Step 1: npm pack to download tarball
+ var packResult = await RunNpmAsync($"pack {packageSpec} --pack-destination {Quote(tempDir)}", tempDir);
+ if (packResult.ExitCode != 0)
+ {
+ Console.Error.WriteLine($"Failed to download package: {packResult.Stderr}");
+ return 1;
+ }
+
+ // Find the downloaded tarball
+ var tarballs = Directory.GetFiles(tempDir, "*.tgz");
+ if (tarballs.Length == 0)
+ {
+ Console.Error.WriteLine("No tarball downloaded.");
+ return 1;
+ }
+
+ var tarball = tarballs[0];
+
+ // Step 2: Extract tarball into a temp staging directory
+ var stagingDir = Path.Combine(tempDir, "staging");
+ Directory.CreateDirectory(stagingDir);
+
+ var extractResult = await RunProcessAsync("tar", $"xzf {Quote(tarball)} -C {Quote(stagingDir)}", tempDir);
+ if (extractResult.ExitCode != 0)
+ {
+ Console.Error.WriteLine($"Failed to extract package: {extractResult.Stderr}");
+ return 1;
+ }
+
+ // npm pack creates a 'package' directory inside the tarball
+ var packageDir = Path.Combine(stagingDir, "package");
+ if (!Directory.Exists(packageDir))
+ {
+ // Some tarballs use a different root
+ var dirs = Directory.GetDirectories(stagingDir);
+ packageDir = dirs.Length > 0 ? dirs[0] : stagingDir;
+ }
+
+ // Step 3: Determine plugin name from manifest or package.json
+ var pluginName = ResolvePluginName(packageDir) ?? SanitizePackageName(packageSpec);
+
+ // Step 4: Move to extensions directory
+ var targetDir = Path.Combine(extensionsDir, pluginName);
+ if (Directory.Exists(targetDir))
+ {
+ Console.WriteLine($"Replacing existing plugin '{pluginName}'...");
+ Directory.Delete(targetDir, recursive: true);
+ }
+
+ CopyDirectory(packageDir, targetDir);
+
+ // Step 5: Install npm dependencies if package.json exists
+ var packageJson = Path.Combine(targetDir, "package.json");
+ if (File.Exists(packageJson))
+ {
+ Console.WriteLine("Installing dependencies...");
+ var npmInstall = await RunNpmAsync("install --production --no-optional", targetDir);
+ if (npmInstall.ExitCode != 0)
+ Console.Error.WriteLine($"Warning: npm install failed: {npmInstall.Stderr}");
+ }
+
+ Console.WriteLine($"Installed '{pluginName}' to {targetDir}");
+ Console.WriteLine("Restart the gateway to load the plugin.");
+ return 0;
+ }
+ finally
+ {
+ try { Directory.Delete(tempDir, recursive: true); } catch { /* best effort */ }
+ }
+ }
+
+ private static async Task InstallFromLocalAsync(string localPath, string extensionsDir)
+ {
+ var sourcePath = Path.GetFullPath(localPath);
+
+ if (File.Exists(sourcePath) && sourcePath.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase))
+ {
+ // Extract tarball
+ var tempDir = Path.Combine(Path.GetTempPath(), $"openclaw-install-{Guid.NewGuid():N}"[..24]);
+ Directory.CreateDirectory(tempDir);
+
+ try
+ {
+ var extractResult = await RunProcessAsync("tar", $"xzf {Quote(sourcePath)} -C {Quote(tempDir)}", tempDir);
+ if (extractResult.ExitCode != 0)
+ {
+ Console.Error.WriteLine($"Failed to extract: {extractResult.Stderr}");
+ return 1;
+ }
+
+ var packageDir = Path.Combine(tempDir, "package");
+ if (!Directory.Exists(packageDir))
+ {
+ var dirs = Directory.GetDirectories(tempDir);
+ packageDir = dirs.Length > 0 ? dirs[0] : tempDir;
+ }
+
+ var pluginName = ResolvePluginName(packageDir) ?? Path.GetFileNameWithoutExtension(localPath);
+ var targetDir = Path.Combine(extensionsDir, pluginName);
+ if (Directory.Exists(targetDir))
+ Directory.Delete(targetDir, recursive: true);
+
+ CopyDirectory(packageDir, targetDir);
+ Console.WriteLine($"Installed '{pluginName}' from tarball to {targetDir}");
+ return 0;
+ }
+ finally
+ {
+ try { Directory.Delete(tempDir, recursive: true); } catch { /* best effort */ }
+ }
+ }
+
+ if (Directory.Exists(sourcePath))
+ {
+ var pluginName = ResolvePluginName(sourcePath) ?? Path.GetFileName(sourcePath);
+ var targetDir = Path.Combine(extensionsDir, pluginName);
+ if (Directory.Exists(targetDir))
+ Directory.Delete(targetDir, recursive: true);
+
+ CopyDirectory(sourcePath, targetDir);
+ Console.WriteLine($"Installed '{pluginName}' from local directory to {targetDir}");
+ return 0;
+ }
+
+ Console.Error.WriteLine($"Path not found: {localPath}");
+ return 1;
+ }
+
+ private static async Task RemoveAsync(string[] args)
+ {
+ if (args.Length == 0)
+ {
+ Console.Error.WriteLine("Usage: openclaw plugins remove ");
+ return 2;
+ }
+
+ var pluginName = args[0];
+ var global = args.Contains("--global") || args.Contains("-g");
+ var extensionsDir = ResolveExtensionsDir(global);
+
+ var targetDir = Path.Combine(extensionsDir, pluginName);
+ if (!Directory.Exists(targetDir))
+ {
+ // Try sanitized name
+ targetDir = Path.Combine(extensionsDir, SanitizePackageName(pluginName));
+ }
+
+ if (!Directory.Exists(targetDir))
+ {
+ Console.Error.WriteLine($"Plugin '{pluginName}' not found in {extensionsDir}");
+ return 1;
+ }
+
+ Directory.Delete(targetDir, recursive: true);
+ Console.WriteLine($"Removed '{pluginName}' from {extensionsDir}");
+ Console.WriteLine("Restart the gateway to unload the plugin.");
+ return 0;
+ }
+
+ private static int ListInstalled(string[] args)
+ {
+ var global = args.Contains("--global") || args.Contains("-g");
+ var extensionsDir = ResolveExtensionsDir(global);
+
+ if (!Directory.Exists(extensionsDir))
+ {
+ Console.WriteLine("No plugins installed.");
+ return 0;
+ }
+
+ var plugins = PluginDiscovery.Discover(new PluginsConfig
+ {
+ Load = new PluginLoadConfig { Paths = [extensionsDir] }
+ });
+
+ if (plugins.Count == 0)
+ {
+ Console.WriteLine("No plugins installed.");
+ return 0;
+ }
+
+ Console.WriteLine($"Installed plugins ({plugins.Count}):");
+ foreach (var plugin in plugins)
+ {
+ var name = plugin.Manifest.Name ?? plugin.Manifest.Id ?? Path.GetFileName(plugin.RootPath);
+ var version = plugin.Manifest.Version ?? "?";
+ var desc = plugin.Manifest.Description ?? "";
+ Console.WriteLine($" {name} ({version}) - {desc}");
+ Console.WriteLine($" Path: {plugin.RootPath}");
+ }
+
+ return 0;
+ }
+
+ private static async Task SearchAsync(string[] args)
+ {
+ if (args.Length == 0)
+ {
+ Console.Error.WriteLine("Usage: openclaw plugins search ");
+ return 2;
+ }
+
+ var query = string.Join(' ', args);
+ Console.WriteLine($"Searching npm for '{query}'...");
+
+ var result = await RunNpmAsync($"search openclaw-plugin {query} --json", Directory.GetCurrentDirectory());
+ if (result.ExitCode != 0)
+ {
+ // Fallback to non-JSON search
+ var textResult = await RunNpmAsync($"search openclaw {query}", Directory.GetCurrentDirectory());
+ Console.WriteLine(textResult.Stdout);
+ return textResult.ExitCode;
+ }
+
+ try
+ {
+ using var doc = JsonDocument.Parse(result.Stdout);
+ var packages = doc.RootElement;
+ if (packages.ValueKind != JsonValueKind.Array || packages.GetArrayLength() == 0)
+ {
+ Console.WriteLine("No packages found.");
+ return 0;
+ }
+
+ Console.WriteLine($"Found {packages.GetArrayLength()} package(s):");
+ foreach (var pkg in packages.EnumerateArray())
+ {
+ var name = pkg.TryGetProperty("name", out var n) ? n.GetString() : "?";
+ var desc = pkg.TryGetProperty("description", out var d) ? d.GetString() : "";
+ var version = pkg.TryGetProperty("version", out var v) ? v.GetString() : "";
+ Console.WriteLine($" {name}@{version} - {desc}");
+ }
+ }
+ catch
+ {
+ Console.WriteLine(result.Stdout);
+ }
+
+ return 0;
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────
+
+ private static string ResolveExtensionsDir(bool global)
+ {
+ if (global)
+ {
+ var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ return Path.Combine(home, ".openclaw", "extensions");
+ }
+
+ var workspace = Environment.GetEnvironmentVariable(EnvWorkspace);
+ if (!string.IsNullOrWhiteSpace(workspace))
+ return Path.Combine(workspace, ".openclaw", "extensions");
+
+ var home2 = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ return Path.Combine(home2, ".openclaw", "extensions");
+ }
+
+ private static string? ResolvePluginName(string packageDir)
+ {
+ // Try manifest
+ var manifestPath = Path.Combine(packageDir, "openclaw.plugin.json");
+ if (File.Exists(manifestPath))
+ {
+ try
+ {
+ var json = File.ReadAllText(manifestPath);
+ using var doc = JsonDocument.Parse(json);
+ if (doc.RootElement.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.String)
+ return id.GetString();
+ if (doc.RootElement.TryGetProperty("name", out var name) && name.ValueKind == JsonValueKind.String)
+ return SanitizePackageName(name.GetString()!);
+ }
+ catch { /* fall through */ }
+ }
+
+ // Try package.json
+ var packageJsonPath = Path.Combine(packageDir, "package.json");
+ if (File.Exists(packageJsonPath))
+ {
+ try
+ {
+ var json = File.ReadAllText(packageJsonPath);
+ using var doc = JsonDocument.Parse(json);
+ if (doc.RootElement.TryGetProperty("name", out var name) && name.ValueKind == JsonValueKind.String)
+ return SanitizePackageName(name.GetString()!);
+ }
+ catch { /* fall through */ }
+ }
+
+ return null;
+ }
+
+ private static string SanitizePackageName(string name)
+ {
+ // @scope/package → scope-package
+ return name.Replace('@', ' ').Replace('/', '-').Trim().Replace(' ', '-');
+ }
+
+ private static async Task<(int ExitCode, string Stdout, string Stderr)> RunNpmAsync(string arguments, string workingDirectory)
+ {
+ var npmCmd = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "npm.cmd" : "npm";
+ return await RunProcessAsync(npmCmd, arguments, workingDirectory);
+ }
+
+ private static async Task<(int ExitCode, string Stdout, string Stderr)> RunProcessAsync(
+ string fileName, string arguments, string workingDirectory)
+ {
+ try
+ {
+ using var process = new Process();
+ process.StartInfo = new ProcessStartInfo
+ {
+ FileName = fileName,
+ Arguments = arguments,
+ WorkingDirectory = workingDirectory,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+
+ process.Start();
+ var stdout = await process.StandardOutput.ReadToEndAsync();
+ var stderr = await process.StandardError.ReadToEndAsync();
+ await process.WaitForExitAsync();
+ return (process.ExitCode, stdout, stderr);
+ }
+ catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 2)
+ {
+ return (127, "", $"Command not found: {fileName}. Ensure npm is installed.");
+ }
+ }
+
+ private static void CopyDirectory(string source, string destination)
+ {
+ Directory.CreateDirectory(destination);
+ foreach (var file in Directory.GetFiles(source))
+ File.Copy(file, Path.Combine(destination, Path.GetFileName(file)));
+
+ foreach (var dir in Directory.GetDirectories(source))
+ {
+ var dirName = Path.GetFileName(dir);
+ if (dirName is "node_modules" or ".git")
+ continue;
+ CopyDirectory(dir, Path.Combine(destination, dirName));
+ }
+ }
+
+ private static string Quote(string path)
+ => path.Contains(' ') ? $"\"{path}\"" : path;
+
+ private static int UnknownSubcommand(string subcommand)
+ {
+ Console.Error.WriteLine($"Unknown subcommand: {subcommand}");
+ PrintHelp();
+ return 2;
+ }
+
+ private static void PrintHelp()
+ {
+ Console.WriteLine("""
+ openclaw plugins — Manage OpenClaw plugins
+
+ Usage:
+ openclaw plugins install Install a plugin from npm/ClawHub or local source
+ openclaw plugins remove Remove an installed plugin
+ openclaw plugins list List installed plugins
+ openclaw plugins search Search npm for OpenClaw plugins
+
+ Options:
+ -g, --global Use global extensions directory (~/.openclaw/extensions)
+
+ Examples:
+ openclaw plugins install @sliverp/qqbot
+ openclaw plugins install @opik/opik-openclaw
+ openclaw plugins install ./my-local-plugin
+ openclaw plugins install ./my-plugin.tgz
+ openclaw plugins remove qqbot
+ openclaw plugins list
+ openclaw plugins search openclaw dingtalk
+ """);
+ }
+}
diff --git a/src/OpenClaw.Cli/Program.cs b/src/OpenClaw.Cli/Program.cs
index 0a10479..bad66b5 100644
--- a/src/OpenClaw.Cli/Program.cs
+++ b/src/OpenClaw.Cli/Program.cs
@@ -35,6 +35,7 @@ public static async Task Main(string[] args)
"migrate" => await MigrateAsync(rest),
"heartbeat" => await HeartbeatAsync(rest),
"admin" => await AdminAsync(rest),
+ "plugins" => await PluginCommands.RunAsync(rest),
"clawhub" => await ClawHubCommand.RunAsync(rest),
"version" or "--version" or "-v" => PrintVersion(),
_ => UnknownCommand(command)
@@ -115,6 +116,13 @@ openclaw admin posture
openclaw admin approvals simulate --tool shell --args "{\"command\":\"pwd\"}"
openclaw admin incident export
+ Plugin management:
+ openclaw plugins install Install from npm/ClawHub
+ openclaw plugins install ./local-plugin Install from local path
+ openclaw plugins remove Remove a plugin
+ openclaw plugins list List installed plugins
+ openclaw plugins search Search npm for plugins
+
ClawHub wrapper:
# Forward --help to ClawHub itself:
openclaw clawhub -- --help
diff --git a/src/OpenClaw.Core/Models/GatewayConfig.cs b/src/OpenClaw.Core/Models/GatewayConfig.cs
index ec1c284..5ab2b61 100644
--- a/src/OpenClaw.Core/Models/GatewayConfig.cs
+++ b/src/OpenClaw.Core/Models/GatewayConfig.cs
@@ -29,6 +29,10 @@ public sealed class GatewayConfig
public ProfilesConfig Profiles { get; set; } = new();
public LearningConfig Learning { get; set; } = new();
public WebhooksConfig Webhooks { get; set; } = new();
+ public RoutingConfig Routing { get; set; } = new();
+ public TailscaleConfig Tailscale { get; set; } = new();
+ public GmailPubSubConfig GmailPubSub { get; set; } = new();
+ public MdnsConfig Mdns { get; set; } = new();
public string UsageFooter { get; set; } = "off"; // "off", "tokens", "full"
public int MaxConcurrentSessions { get; set; } = 64;
@@ -255,6 +259,9 @@ public sealed class ChannelsConfig
public TelegramChannelConfig Telegram { get; set; } = new();
public WhatsAppChannelConfig WhatsApp { get; set; } = new();
public TeamsChannelConfig Teams { get; set; } = new();
+ public SlackChannelConfig Slack { get; set; } = new();
+ public DiscordChannelConfig Discord { get; set; } = new();
+ public SignalChannelConfig Signal { get; set; } = new();
}
public sealed class WhatsAppChannelConfig
@@ -442,6 +449,60 @@ public sealed class TelegramChannelConfig
public string WebhookSecretTokenRef { get; set; } = "env:TELEGRAM_WEBHOOK_SECRET";
}
+public sealed class SlackChannelConfig
+{
+ public bool Enabled { get; set; } = false;
+ public string DmPolicy { get; set; } = "pairing"; // open, pairing, closed
+ public string? BotToken { get; set; }
+ public string BotTokenRef { get; set; } = "env:SLACK_BOT_TOKEN";
+ public string? SigningSecret { get; set; }
+ public string SigningSecretRef { get; set; } = "env:SLACK_SIGNING_SECRET";
+ public string WebhookPath { get; set; } = "/slack/events";
+ public string SlashCommandPath { get; set; } = "/slack/commands";
+ public string[] AllowedWorkspaceIds { get; set; } = [];
+ public string[] AllowedFromUserIds { get; set; } = [];
+ public string[] AllowedChannelIds { get; set; } = [];
+ public int MaxInboundChars { get; set; } = 4096;
+ public int MaxRequestBytes { get; set; } = 64 * 1024;
+ public bool ValidateSignature { get; set; } = true;
+}
+
+public sealed class DiscordChannelConfig
+{
+ public bool Enabled { get; set; } = false;
+ public string DmPolicy { get; set; } = "pairing"; // open, pairing, closed
+ public string? BotToken { get; set; }
+ public string BotTokenRef { get; set; } = "env:DISCORD_BOT_TOKEN";
+ public string? ApplicationId { get; set; }
+ public string ApplicationIdRef { get; set; } = "env:DISCORD_APPLICATION_ID";
+ public string? PublicKey { get; set; }
+ public string PublicKeyRef { get; set; } = "env:DISCORD_PUBLIC_KEY";
+ public string WebhookPath { get; set; } = "/discord/interactions";
+ public string[] AllowedGuildIds { get; set; } = [];
+ public string[] AllowedFromUserIds { get; set; } = [];
+ public string[] AllowedChannelIds { get; set; } = [];
+ public int MaxInboundChars { get; set; } = 4096;
+ public int MaxRequestBytes { get; set; } = 64 * 1024;
+ public bool ValidateSignature { get; set; } = true;
+ public bool RegisterSlashCommands { get; set; } = true;
+ public string SlashCommandPrefix { get; set; } = "claw";
+}
+
+public sealed class SignalChannelConfig
+{
+ public bool Enabled { get; set; } = false;
+ public string DmPolicy { get; set; } = "pairing"; // open, pairing, closed
+ public string Driver { get; set; } = "signald"; // "signald" or "signal_cli"
+ public string SocketPath { get; set; } = "/var/run/signald/signald.sock";
+ public string? SignalCliPath { get; set; }
+ public string? AccountPhoneNumber { get; set; }
+ public string AccountPhoneNumberRef { get; set; } = "env:SIGNAL_PHONE_NUMBER";
+ public string[] AllowedFromNumbers { get; set; } = [];
+ public int MaxInboundChars { get; set; } = 4096;
+ public bool NoContentLogging { get; set; } = false;
+ public bool TrustAllKeys { get; set; } = true;
+}
+
public sealed class CronConfig
{
public bool Enabled { get; set; } = false;
@@ -481,3 +542,59 @@ public sealed class WebhookEndpointConfig
/// Maximum webhook body length in characters before truncation. Limits prompt injection surface.
public int MaxBodyLength { get; set; } = 10_240;
}
+
+// ── Multi-Agent Routing ─────────────────────────────────────────
+
+public sealed class RoutingConfig
+{
+ public bool Enabled { get; set; } = false;
+ public Dictionary Routes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
+}
+
+public sealed class AgentRouteConfig
+{
+ public string? ChannelId { get; set; }
+ public string? SenderId { get; set; }
+ public string? SystemPrompt { get; set; }
+ public string? ModelOverride { get; set; }
+ public string? PresetId { get; set; }
+ public string[] AllowedTools { get; set; } = [];
+}
+
+// ── Tailscale ───────────────────────────────────────────────────
+
+public sealed class TailscaleConfig
+{
+ public bool Enabled { get; set; } = false;
+ public string Mode { get; set; } = "off"; // "off", "serve", "funnel"
+ public int Port { get; set; } = 443;
+ public string? Hostname { get; set; }
+}
+
+// ── Gmail Pub/Sub ───────────────────────────────────────────────
+
+public sealed class GmailPubSubConfig
+{
+ public bool Enabled { get; set; } = false;
+ public string? CredentialsPath { get; set; }
+ public string CredentialsPathRef { get; set; } = "env:GOOGLE_APPLICATION_CREDENTIALS";
+ public string? TopicName { get; set; }
+ public string? SubscriptionName { get; set; }
+ public string WebhookPath { get; set; } = "/gmail/push";
+ public string? SessionId { get; set; }
+ public string Prompt { get; set; } = "A new email notification was received. Check inbox and triage.";
+
+ /// Shared secret token for authenticating push requests. Set as a query param or header.
+ public string? WebhookSecret { get; set; }
+ public string WebhookSecretRef { get; set; } = "env:GMAIL_PUBSUB_SECRET";
+}
+
+// ── mDNS/Bonjour Discovery ─────────────────────────────────────
+
+public sealed class MdnsConfig
+{
+ public bool Enabled { get; set; } = false;
+ public string ServiceType { get; set; } = "_openclaw._tcp";
+ public string? InstanceName { get; set; }
+ public int Port { get; set; } = 0; // 0 = use gateway port
+}
diff --git a/src/OpenClaw.Core/Models/Session.cs b/src/OpenClaw.Core/Models/Session.cs
index 3829d04..9b8d582 100644
--- a/src/OpenClaw.Core/Models/Session.cs
+++ b/src/OpenClaw.Core/Models/Session.cs
@@ -24,6 +24,21 @@ public sealed class Session
/// Optional model override for this specific session (set via /model command).
public string? ModelOverride { get; set; }
+ /// Optional route-scoped system prompt appended by gateway routing before runtime execution.
+ public string? SystemPromptOverride { get; set; }
+
+ /// Optional route-scoped tool preset that overrides the default preset resolution.
+ public string? RoutePresetId { get; set; }
+
+ /// Optional route-scoped tool allowlist applied in addition to preset filtering.
+ public string[] RouteAllowedTools { get; set; } = [];
+
+ /// Reasoning effort level for extended thinking (null/off, low, medium, high). Set via /think command.
+ public string? ReasoningEffort { get; set; }
+
+ /// When true, shows tool calls and token counts in responses. Set via /verbose command.
+ public bool VerboseMode { get; set; }
+
/// Total input tokens consumed across all turns in this session.
public long TotalInputTokens { get; set; }
@@ -429,6 +444,14 @@ public sealed record ToolInvocation
[JsonSerializable(typeof(ChannelAuthStatusItem))]
[JsonSerializable(typeof(WhatsAppSetupRequest))]
[JsonSerializable(typeof(WhatsAppSetupResponse))]
+[JsonSerializable(typeof(SlackChannelConfig))]
+[JsonSerializable(typeof(DiscordChannelConfig))]
+[JsonSerializable(typeof(SignalChannelConfig))]
+[JsonSerializable(typeof(RoutingConfig))]
+[JsonSerializable(typeof(AgentRouteConfig))]
+[JsonSerializable(typeof(TailscaleConfig))]
+[JsonSerializable(typeof(GmailPubSubConfig))]
+[JsonSerializable(typeof(MdnsConfig))]
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
diff --git a/src/OpenClaw.Core/Pipeline/ChatCommandProcessor.cs b/src/OpenClaw.Core/Pipeline/ChatCommandProcessor.cs
index dfc6d6e..838b1d5 100644
--- a/src/OpenClaw.Core/Pipeline/ChatCommandProcessor.cs
+++ b/src/OpenClaw.Core/Pipeline/ChatCommandProcessor.cs
@@ -24,17 +24,27 @@ public sealed class ChatCommandProcessor
"/reset",
"/model",
"/usage",
+ "/think",
+ "/compact",
+ "/verbose",
"/help"
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
private readonly SessionManager _sessionManager;
private readonly ConcurrentDictionary>> _dynamicCommands = new(StringComparer.OrdinalIgnoreCase);
+ private Func>? _compactCallback;
public ChatCommandProcessor(SessionManager sessionManager)
{
_sessionManager = sessionManager;
}
+ ///
+ /// Sets the callback for LLM-powered history compaction (injected from gateway setup).
+ ///
+ public void SetCompactCallback(Func> callback)
+ => _compactCallback = callback;
+
///
/// Registers a dynamic command handler (e.g. from a plugin).
///
@@ -95,8 +105,61 @@ public DynamicCommandRegistrationResult RegisterDynamic(string command, Func 0)
+ session.History.RemoveRange(0, removeCount);
+ await _sessionManager.PersistAsync(session, ct);
+ return (true, $"Trimmed: {turnsBefore} turns → {session.History.Count} turns (kept last {keepRecent}).");
+
+ case "/verbose":
+ if (string.IsNullOrWhiteSpace(args))
+ return (true, $"Verbose mode: {(session.VerboseMode ? "on" : "off")}\nUsage: /verbose on|off");
+
+ if (args.Equals("on", StringComparison.OrdinalIgnoreCase))
+ {
+ session.VerboseMode = true;
+ await _sessionManager.PersistAsync(session, ct);
+ return (true, "Verbose mode enabled. Tool calls and token counts will be shown.");
+ }
+ if (args.Equals("off", StringComparison.OrdinalIgnoreCase))
+ {
+ session.VerboseMode = false;
+ await _sessionManager.PersistAsync(session, ct);
+ return (true, "Verbose mode disabled.");
+ }
+ return (true, "Usage: /verbose on|off");
+
case "/help":
- return (true, "Available commands:\n/status - Show session details\n/new (or /reset) - Clear conversation history\n/model - Override the LLM model for this session\n/model reset - Clear model override\n/usage - Show token counts\n/help - Show this message");
+ return (true, "Available commands:\n/status - Show session details\n/new (or /reset) - Clear conversation history\n/model - Override the LLM model for this session\n/model reset - Clear model override\n/usage - Show token counts\n/think - Set reasoning effort (off/low/medium/high)\n/compact - Compact conversation history\n/verbose on|off - Toggle verbose output\n/help - Show this message");
default:
if (_dynamicCommands.TryGetValue(command, out var dynamicHandler))
diff --git a/src/OpenClaw.Core/Validation/ConfigValidator.cs b/src/OpenClaw.Core/Validation/ConfigValidator.cs
index bf0e822..a1789ce 100644
--- a/src/OpenClaw.Core/Validation/ConfigValidator.cs
+++ b/src/OpenClaw.Core/Validation/ConfigValidator.cs
@@ -288,6 +288,9 @@ public static IReadOnlyList Validate(Models.GatewayConfig config)
ValidateDmPolicy("Channels.Telegram.DmPolicy", config.Channels.Telegram.DmPolicy, errors);
ValidateDmPolicy("Channels.WhatsApp.DmPolicy", config.Channels.WhatsApp.DmPolicy, errors);
ValidateDmPolicy("Channels.Teams.DmPolicy", config.Channels.Teams.DmPolicy, errors);
+ ValidateDmPolicy("Channels.Slack.DmPolicy", config.Channels.Slack.DmPolicy, errors);
+ ValidateDmPolicy("Channels.Discord.DmPolicy", config.Channels.Discord.DmPolicy, errors);
+ ValidateDmPolicy("Channels.Signal.DmPolicy", config.Channels.Signal.DmPolicy, errors);
// Cron
if (config.Cron.Enabled)
diff --git a/src/OpenClaw.Gateway/Composition/ChannelServicesExtensions.cs b/src/OpenClaw.Gateway/Composition/ChannelServicesExtensions.cs
index a692e78..73b0951 100644
--- a/src/OpenClaw.Gateway/Composition/ChannelServicesExtensions.cs
+++ b/src/OpenClaw.Gateway/Composition/ChannelServicesExtensions.cs
@@ -53,6 +53,32 @@ public static IServiceCollection AddOpenClawChannelServices(this IServiceCollect
sp.GetRequiredService>()));
}
+ if (config.Channels.Slack.Enabled)
+ {
+ services.AddSingleton(config.Channels.Slack);
+ services.AddSingleton(sp =>
+ new SlackWebhookHandler(
+ config.Channels.Slack,
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService>()));
+ services.AddSingleton();
+ }
+
+ if (config.Channels.Discord.Enabled)
+ {
+ services.AddSingleton(config.Channels.Discord);
+ services.AddSingleton();
+ services.AddSingleton();
+ }
+
+ if (config.Channels.Signal.Enabled)
+ {
+ services.AddSingleton(config.Channels.Signal);
+ services.AddSingleton();
+ }
+
return services;
}
}
diff --git a/src/OpenClaw.Gateway/Composition/CoreServicesExtensions.cs b/src/OpenClaw.Gateway/Composition/CoreServicesExtensions.cs
index c12b782..c2c7533 100644
--- a/src/OpenClaw.Gateway/Composition/CoreServicesExtensions.cs
+++ b/src/OpenClaw.Gateway/Composition/CoreServicesExtensions.cs
@@ -22,6 +22,7 @@ public static IServiceCollection AddOpenClawCoreServices(this IServiceCollection
var config = startup.Config;
services.AddSingleton(config);
+ services.AddSingleton(config.Learning);
services.AddSingleton(typeof(AllowlistSemantics), AllowlistPolicy.ParseSemantics(config.Channels.AllowlistSemantics));
services.AddSingleton(sp =>
new RecentSendersStore(config.Memory.StoragePath, sp.GetRequiredService>()));
diff --git a/src/OpenClaw.Gateway/Composition/RuntimeInitializationExtensions.cs b/src/OpenClaw.Gateway/Composition/RuntimeInitializationExtensions.cs
index 581f66c..29b34cf 100644
--- a/src/OpenClaw.Gateway/Composition/RuntimeInitializationExtensions.cs
+++ b/src/OpenClaw.Gateway/Composition/RuntimeInitializationExtensions.cs
@@ -119,6 +119,18 @@ public static async Task InitializeOpenClawRuntimeAsync(
effectiveApprovalRequiredTools,
services.ToolSandbox);
+ // Wire compact callback so /compact command can trigger LLM-powered compaction
+ if (agentRuntime is AgentRuntime concreteRuntime)
+ {
+ services.CommandProcessor.SetCompactCallback(async (session, ct) =>
+ {
+ var countBefore = session.History.Count;
+ await concreteRuntime.CompactHistoryAsync(session, ct);
+ var countAfter = session.History.Count;
+ return countAfter; // Return actual remaining turn count
+ });
+ }
+
var middlewarePipeline = CreateMiddlewarePipeline(config, loggerFactory, services.ContractGovernance, services.SessionManager);
var skillWatcher = new SkillWatcherService(
config.Skills,
@@ -153,6 +165,28 @@ public static async Task InitializeOpenClawRuntimeAsync(
pluginComposition.NativeDynamicPluginHost);
await profile.OnRuntimeInitializedAsync(app, startup, runtime);
+
+ // Start integration services
+ if (config.Tailscale.Enabled)
+ {
+ var tailscale = new Integrations.TailscaleService(
+ config.Tailscale,
+ config.Port,
+ loggerFactory.CreateLogger());
+ _ = tailscale.StartAsync(app.Lifetime.ApplicationStopping);
+ app.Lifetime.ApplicationStopping.Register(() => tailscale.DisposeAsync().AsTask().GetAwaiter().GetResult());
+ }
+
+ if (config.Mdns.Enabled)
+ {
+ var mdns = new Integrations.MdnsDiscoveryService(
+ config.Mdns,
+ config.Port,
+ loggerFactory.CreateLogger());
+ mdns.Start(app.Lifetime.ApplicationStopping);
+ app.Lifetime.ApplicationStopping.Register(() => mdns.DisposeAsync().AsTask().GetAwaiter().GetResult());
+ }
+
return runtime;
}
@@ -225,6 +259,15 @@ private static async Task BuildChannelCompositionAsync(
if (config.Channels.Teams.Enabled)
channelAdapters["teams"] = app.Services.GetRequiredService();
+ if (config.Channels.Slack.Enabled)
+ channelAdapters["slack"] = app.Services.GetRequiredService();
+
+ if (config.Channels.Discord.Enabled)
+ channelAdapters["discord"] = app.Services.GetRequiredService();
+
+ if (config.Channels.Signal.Enabled)
+ channelAdapters["signal"] = app.Services.GetRequiredService();
+
var whatsAppWorkerHost = await CreateWhatsAppChannelAsync(app, startup, services, loggerFactory, channelAdapters);
if (config.Plugins.Native.Email.Enabled)
@@ -452,7 +495,29 @@ private static IReadOnlyList CreateBuiltInTools(
new TodoTool(services.SessionMetadataStore),
new AutomationTool(services.AutomationService, services.Pipeline),
new VisionAnalyzeTool(services.GeminiMultimodalService),
- new TextToSpeechTool(services.TextToSpeechService)
+ new TextToSpeechTool(services.TextToSpeechService),
+
+ // Core dev tools
+ new EditFileTool(config.Tooling),
+ new ApplyPatchTool(config.Tooling),
+
+ // Session management tools
+ new SessionsHistoryTool(services.SessionManager, services.MemoryStore),
+ new SessionsSendTool(services.SessionManager, services.Pipeline),
+ new SessionsSpawnTool(services.SessionManager, services.Pipeline),
+ new SessionStatusTool(services.SessionManager),
+ new AgentsListTool(config.Delegation),
+
+ // System management tools
+ new CronTool(services.CronJobSource, services.Pipeline),
+ new GatewayTool(services.RuntimeMetrics, services.SessionManager, config),
+
+ // Communication & data tools
+ new MessageTool(services.Pipeline),
+ new XSearchTool(),
+ new MemoryGetTool(services.MemoryStore),
+ new ProfileWriteTool(services.UserProfileStore),
+ new SessionsYieldTool(services.SessionManager, services.Pipeline, services.MemoryStore),
};
if (config.Tooling.EnableBrowserTool)
diff --git a/src/OpenClaw.Gateway/DiscordWebhookHandler.cs b/src/OpenClaw.Gateway/DiscordWebhookHandler.cs
new file mode 100644
index 0000000..bab71ea
--- /dev/null
+++ b/src/OpenClaw.Gateway/DiscordWebhookHandler.cs
@@ -0,0 +1,184 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using OpenClaw.Channels;
+using OpenClaw.Core.Models;
+using OpenClaw.Core.Security;
+
+namespace OpenClaw.Gateway;
+
+///
+/// Handles Discord Interaction endpoint webhooks (slash commands).
+/// Regular messages arrive via the Gateway WebSocket in DiscordChannel.
+///
+internal sealed class DiscordWebhookHandler
+{
+ private readonly DiscordChannelConfig _config;
+ private readonly byte[]? _publicKeyBytes;
+ private readonly ILogger _logger;
+
+ public DiscordWebhookHandler(
+ DiscordChannelConfig config,
+ ILogger logger)
+ {
+ _config = config;
+ _logger = logger;
+
+ var publicKeyHex = SecretResolver.Resolve(config.PublicKeyRef) ?? config.PublicKey;
+ if (!string.IsNullOrWhiteSpace(publicKeyHex))
+ _publicKeyBytes = Convert.FromHexString(publicKeyHex);
+
+ if (config.ValidateSignature && !Ed25519Verify.IsSupported)
+ logger.LogWarning("Discord signature validation is enabled but Ed25519 is not supported on this platform. " +
+ "Signature checks will reject all requests. Disable ValidateSignature or add an Ed25519 provider.");
+ }
+
+ public readonly record struct WebhookResponse(int StatusCode, string? Body = null, string ContentType = "application/json");
+
+ ///
+ /// Handles an inbound Discord interaction webhook.
+ ///
+ public async ValueTask HandleAsync(
+ string bodyText,
+ string? signatureHeader,
+ string? timestampHeader,
+ Func enqueue,
+ CancellationToken ct)
+ {
+ // Validate Ed25519 signature
+ if (_config.ValidateSignature)
+ {
+ if (!ValidateSignature(bodyText, signatureHeader, timestampHeader))
+ {
+ _logger.LogWarning("Rejected Discord interaction due to invalid signature.");
+ return new WebhookResponse(401, "invalid request signature");
+ }
+ }
+
+ using var doc = JsonDocument.Parse(bodyText);
+ var root = doc.RootElement;
+
+ var type = root.TryGetProperty("type", out var typeProp) ? typeProp.GetInt32() : 0;
+
+ // Type 1: Ping — required for Discord endpoint verification
+ if (type == 1)
+ return new WebhookResponse(200, """{"type":1}""");
+
+ // Type 2: Application Command
+ if (type == 2)
+ {
+ var interaction = JsonSerializer.Deserialize(bodyText, DiscordJsonContext.Default.DiscordInteraction);
+ if (interaction is null)
+ return new WebhookResponse(400, """{"error":"invalid interaction"}""");
+
+ var userId = interaction.Member?.User?.Id ?? interaction.User?.Id;
+ var username = interaction.Member?.User?.Username ?? interaction.User?.Username;
+ var guildId = interaction.GuildId;
+ var channelId = interaction.ChannelId;
+
+ if (string.IsNullOrWhiteSpace(userId))
+ return new WebhookResponse(400, """{"error":"missing user"}""");
+
+ if (_config.AllowedGuildIds.Length > 0 && !string.IsNullOrWhiteSpace(guildId))
+ {
+ if (!Array.Exists(_config.AllowedGuildIds, id => string.Equals(id, guildId, StringComparison.Ordinal)))
+ return new WebhookResponse(403, """{"error":"guild not allowed"}""");
+ }
+
+ if (_config.AllowedChannelIds.Length > 0 && !string.IsNullOrWhiteSpace(channelId))
+ {
+ if (!Array.Exists(_config.AllowedChannelIds, id => string.Equals(id, channelId, StringComparison.Ordinal)))
+ return new WebhookResponse(403, """{"error":"channel not allowed"}""");
+ }
+
+ if (_config.AllowedFromUserIds.Length > 0)
+ {
+ if (!Array.Exists(_config.AllowedFromUserIds, id => string.Equals(id, userId, StringComparison.Ordinal)))
+ return new WebhookResponse(403, """{"error":"user not allowed"}""");
+ }
+
+ // Extract command text from options
+ var text = "";
+ if (interaction.Data?.Options is { Length: > 0 } options)
+ {
+ foreach (var opt in options)
+ {
+ if (string.Equals(opt.Name, "message", StringComparison.Ordinal) && opt.Value.HasValue)
+ {
+ text = opt.Value.Value.GetString() ?? "";
+ break;
+ }
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(text))
+ text = $"/{interaction.Data?.Name ?? "claw"}";
+
+ if (text.Length > _config.MaxInboundChars)
+ text = text[.._config.MaxInboundChars];
+
+ var isDm = guildId is null;
+ string? sessionId = null;
+ if (!isDm && channelId is not null)
+ sessionId = $"discord:{channelId}:{userId}";
+
+ var message = new InboundMessage
+ {
+ ChannelId = "discord",
+ SenderId = userId,
+ SenderName = username,
+ SessionId = sessionId,
+ Text = text,
+ MessageId = interaction.Id,
+ IsGroup = !isDm,
+ GroupId = guildId,
+ };
+
+ await enqueue(message, ct);
+
+ // Respond with deferred message (type 5) so the user sees "thinking..."
+ return new WebhookResponse(200, """{"type":5}""");
+ }
+
+ return new WebhookResponse(200, """{"type":1}""");
+ }
+
+ ///
+ /// Validates the Discord Ed25519 signature.
+ /// The signed message is: timestamp + body.
+ /// Uses BouncyCastle Ed25519 verification.
+ ///
+ private bool ValidateSignature(string body, string? signature, string? timestamp)
+ {
+ if (_publicKeyBytes is null || _publicKeyBytes.Length != 32 ||
+ string.IsNullOrWhiteSpace(signature) || string.IsNullOrWhiteSpace(timestamp))
+ return false;
+
+ // Reject stale timestamps (5-minute window) to prevent replay attacks
+ if (long.TryParse(timestamp, out var ts))
+ {
+ var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ if (Math.Abs(now - ts) > 300)
+ {
+ _logger.LogWarning("Rejected Discord interaction with stale timestamp ({Timestamp}).", timestamp);
+ return false;
+ }
+ }
+
+ try
+ {
+ var signatureBytes = Convert.FromHexString(signature);
+ if (signatureBytes.Length != 64)
+ return false;
+
+ var message = Encoding.UTF8.GetBytes(timestamp + body);
+ return Ed25519Verify.Verify(signatureBytes, message, _publicKeyBytes);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Discord Ed25519 signature verification failed.");
+ return false;
+ }
+ }
+}
diff --git a/src/OpenClaw.Gateway/Ed25519Verify.cs b/src/OpenClaw.Gateway/Ed25519Verify.cs
new file mode 100644
index 0000000..f399ef2
--- /dev/null
+++ b/src/OpenClaw.Gateway/Ed25519Verify.cs
@@ -0,0 +1,40 @@
+using System.Security.Cryptography;
+using Org.BouncyCastle.Crypto.Parameters;
+using Org.BouncyCastle.Crypto.Signers;
+
+namespace OpenClaw.Gateway;
+
+///
+/// Ed25519 signature verification for Discord interaction webhooks.
+/// Uses BouncyCastle so verification is correct and consistent across runtime targets.
+///
+internal static class Ed25519Verify
+{
+ ///
+ /// Verifies an Ed25519 signature.
+ /// Returns false if verification fails.
+ ///
+ public static bool Verify(ReadOnlySpan signature, ReadOnlySpan message, ReadOnlySpan publicKey)
+ {
+ if (signature.Length != 64 || publicKey.Length != 32)
+ return false;
+
+ try
+ {
+ var verifier = new Ed25519Signer();
+ verifier.Init(forSigning: false, new Ed25519PublicKeyParameters(publicKey.ToArray()));
+ var payload = message.ToArray();
+ verifier.BlockUpdate(payload, 0, payload.Length);
+ return verifier.VerifySignature(signature.ToArray());
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Returns true if the runtime supports Ed25519 verification.
+ ///
+ public static bool IsSupported => true;
+}
diff --git a/src/OpenClaw.Gateway/Endpoints/WebhookEndpoints.cs b/src/OpenClaw.Gateway/Endpoints/WebhookEndpoints.cs
index 203f001..e56cd98 100644
--- a/src/OpenClaw.Gateway/Endpoints/WebhookEndpoints.cs
+++ b/src/OpenClaw.Gateway/Endpoints/WebhookEndpoints.cs
@@ -7,6 +7,7 @@
using OpenClaw.Gateway;
using OpenClaw.Gateway.Bootstrap;
using OpenClaw.Gateway.Composition;
+using OpenClaw.Gateway.Integrations;
namespace OpenClaw.Gateway.Endpoints;
@@ -369,6 +370,229 @@ public static void MapOpenClawWebhookEndpoints(
});
}
+ if (startup.Config.Channels.Discord.Enabled)
+ {
+ var discordHandler = app.Services.GetRequiredService();
+
+ app.MapPost(startup.Config.Channels.Discord.WebhookPath, async (HttpContext ctx) =>
+ {
+ var maxRequestSize = Math.Max(4 * 1024, startup.Config.Channels.Discord.MaxRequestBytes);
+ var (bodyOk, bodyText) = await EndpointHelpers.TryReadBodyTextAsync(ctx, maxRequestSize, ctx.RequestAborted);
+ if (!bodyOk)
+ {
+ ctx.Response.StatusCode = StatusCodes.Status413PayloadTooLarge;
+ await ctx.Response.WriteAsync("Request too large.", ctx.RequestAborted);
+ return;
+ }
+
+ var signature = ctx.Request.Headers["X-Signature-Ed25519"].ToString();
+ var timestamp = ctx.Request.Headers["X-Signature-Timestamp"].ToString();
+
+ InboundMessage? replayMessage = null;
+ try
+ {
+ var result = await discordHandler.HandleAsync(
+ bodyText,
+ signature,
+ timestamp,
+ (msg, ct) =>
+ {
+ replayMessage = msg;
+ return runtime.Pipeline.InboundWriter.WriteAsync(msg, ct);
+ },
+ ctx.RequestAborted);
+
+ ctx.Response.StatusCode = result.StatusCode;
+ ctx.Response.ContentType = result.ContentType;
+ if (result.Body is not null)
+ await ctx.Response.WriteAsync(result.Body, ctx.RequestAborted);
+ }
+ catch (Exception ex)
+ {
+ deliveries.RecordDeadLetter(new WebhookDeadLetterRecord
+ {
+ Entry = new WebhookDeadLetterEntry
+ {
+ Id = $"whdl_{Guid.NewGuid():N}"[..20],
+ Source = "discord",
+ DeliveryKey = "",
+ ChannelId = "discord",
+ SenderId = replayMessage?.SenderId,
+ SessionId = replayMessage?.SessionId,
+ Error = ex.Message,
+ PayloadPreview = bodyText.Length <= 500 ? bodyText : bodyText[..500] + "…"
+ },
+ ReplayMessage = replayMessage
+ });
+ ctx.Response.StatusCode = StatusCodes.Status500InternalServerError;
+ await ctx.Response.WriteAsync("Webhook processing failed.", ctx.RequestAborted);
+ }
+ });
+ }
+
+ if (startup.Config.Channels.Slack.Enabled)
+ {
+ var slackHandler = app.Services.GetRequiredService();
+
+ app.MapPost(startup.Config.Channels.Slack.WebhookPath, async (HttpContext ctx) =>
+ {
+ var maxRequestSize = Math.Max(4 * 1024, startup.Config.Channels.Slack.MaxRequestBytes);
+ var (bodyOk, bodyText) = await EndpointHelpers.TryReadBodyTextAsync(ctx, maxRequestSize, ctx.RequestAborted);
+ if (!bodyOk)
+ {
+ ctx.Response.StatusCode = StatusCodes.Status413PayloadTooLarge;
+ await ctx.Response.WriteAsync("Request too large.", ctx.RequestAborted);
+ return;
+ }
+
+ var timestamp = ctx.Request.Headers["X-Slack-Request-Timestamp"].ToString();
+ var signature = ctx.Request.Headers["X-Slack-Signature"].ToString();
+
+ // Check for url_verification before dedup (challenge must always respond)
+ if (bodyText.Contains("\"url_verification\"", StringComparison.Ordinal))
+ {
+ var result = await slackHandler.HandleEventAsync(bodyText, timestamp, signature, (_, _) => ValueTask.CompletedTask, ctx.RequestAborted);
+ ctx.Response.StatusCode = result.StatusCode;
+ ctx.Response.ContentType = result.ContentType;
+ if (result.Body is not null)
+ await ctx.Response.WriteAsync(result.Body, ctx.RequestAborted);
+ return;
+ }
+
+ // Use event_id from payload for stable dedup (falls back to timestamp+prefix)
+ string deliveryKey;
+ try
+ {
+ using var dedupDoc = System.Text.Json.JsonDocument.Parse(bodyText);
+ var dedupRoot = dedupDoc.RootElement;
+ if (dedupRoot.TryGetProperty("event_id", out var eventIdProp) &&
+ eventIdProp.ValueKind == System.Text.Json.JsonValueKind.String)
+ deliveryKey = eventIdProp.GetString()!;
+ else
+ deliveryKey = timestamp + ":" + (bodyText.Length > 64 ? bodyText[..64] : bodyText);
+ }
+ catch
+ {
+ deliveryKey = timestamp + ":" + (bodyText.Length > 64 ? bodyText[..64] : bodyText);
+ }
+ var hashedKey = WebhookDeliveryStore.HashDeliveryKey(deliveryKey);
+ if (!deliveries.TryBegin("slack", hashedKey, TimeSpan.FromHours(6)))
+ {
+ ctx.Response.StatusCode = StatusCodes.Status200OK;
+ await ctx.Response.WriteAsync("Duplicate ignored.", ctx.RequestAborted);
+ return;
+ }
+
+ InboundMessage? replayMessage = null;
+ try
+ {
+ var result = await slackHandler.HandleEventAsync(
+ bodyText,
+ timestamp,
+ signature,
+ (msg, ct) =>
+ {
+ replayMessage = msg;
+ return runtime.Pipeline.InboundWriter.WriteAsync(msg, ct);
+ },
+ ctx.RequestAborted);
+
+ ctx.Response.StatusCode = result.StatusCode;
+ ctx.Response.ContentType = result.ContentType;
+ if (result.Body is not null)
+ await ctx.Response.WriteAsync(result.Body, ctx.RequestAborted);
+ }
+ catch (Exception ex)
+ {
+ deliveries.RecordDeadLetter(new WebhookDeadLetterRecord
+ {
+ Entry = new WebhookDeadLetterEntry
+ {
+ Id = $"whdl_{Guid.NewGuid():N}"[..20],
+ Source = "slack",
+ DeliveryKey = hashedKey,
+ ChannelId = "slack",
+ SenderId = replayMessage?.SenderId,
+ SessionId = replayMessage?.SessionId,
+ Error = ex.Message,
+ PayloadPreview = bodyText.Length <= 500 ? bodyText : bodyText[..500] + "…"
+ },
+ ReplayMessage = replayMessage
+ });
+ ctx.Response.StatusCode = StatusCodes.Status500InternalServerError;
+ await ctx.Response.WriteAsync("Webhook processing failed.", ctx.RequestAborted);
+ }
+ });
+
+ app.MapPost(startup.Config.Channels.Slack.SlashCommandPath, async (HttpContext ctx) =>
+ {
+ if (!ctx.Request.HasFormContentType)
+ {
+ ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
+ await ctx.Response.WriteAsync("Expected form content.", ctx.RequestAborted);
+ return;
+ }
+
+ var maxRequestSize = Math.Max(4 * 1024, startup.Config.Channels.Slack.MaxRequestBytes);
+ var (bodyOk, bodyText) = await EndpointHelpers.TryReadBodyTextAsync(ctx, maxRequestSize, ctx.RequestAborted);
+ if (!bodyOk)
+ {
+ ctx.Response.StatusCode = StatusCodes.Status413PayloadTooLarge;
+ await ctx.Response.WriteAsync("Request too large.", ctx.RequestAborted);
+ return;
+ }
+
+ var timestamp = ctx.Request.Headers["X-Slack-Request-Timestamp"].ToString();
+ var signature = ctx.Request.Headers["X-Slack-Signature"].ToString();
+
+ var parsed = QueryHelpers.ParseQuery(bodyText);
+ var dict = new Dictionary(StringComparer.Ordinal);
+ foreach (var kvp in parsed)
+ dict[kvp.Key] = kvp.Value.ToString();
+
+ InboundMessage? replayMessage = null;
+ try
+ {
+ var result = await slackHandler.HandleSlashCommandAsync(
+ dict,
+ timestamp,
+ signature,
+ bodyText,
+ (msg, ct) =>
+ {
+ replayMessage = msg;
+ return runtime.Pipeline.InboundWriter.WriteAsync(msg, ct);
+ },
+ ctx.RequestAborted);
+
+ ctx.Response.StatusCode = result.StatusCode;
+ ctx.Response.ContentType = result.ContentType;
+ if (result.Body is not null)
+ await ctx.Response.WriteAsync(result.Body, ctx.RequestAborted);
+ }
+ catch (Exception ex)
+ {
+ deliveries.RecordDeadLetter(new WebhookDeadLetterRecord
+ {
+ Entry = new WebhookDeadLetterEntry
+ {
+ Id = $"whdl_{Guid.NewGuid():N}"[..20],
+ Source = "slack_command",
+ DeliveryKey = "",
+ ChannelId = "slack",
+ SenderId = replayMessage?.SenderId,
+ SessionId = replayMessage?.SessionId,
+ Error = ex.Message,
+ PayloadPreview = bodyText.Length <= 500 ? bodyText : bodyText[..500] + "…"
+ },
+ ReplayMessage = replayMessage
+ });
+ ctx.Response.StatusCode = StatusCodes.Status500InternalServerError;
+ await ctx.Response.WriteAsync("Webhook processing failed.", ctx.RequestAborted);
+ }
+ });
+ }
+
if (startup.Config.Webhooks.Enabled)
{
app.MapPost("/webhooks/{name}", async (HttpContext ctx, string name) =>
@@ -459,6 +683,46 @@ public static void MapOpenClawWebhookEndpoints(
}
});
}
+
+ if (startup.Config.GmailPubSub.Enabled)
+ {
+ var gmailBridge = new GmailPubSubBridge(
+ startup.Config.GmailPubSub,
+ runtime.Pipeline,
+ app.Services.GetRequiredService>());
+
+ var gmailSecret = SecretResolver.Resolve(startup.Config.GmailPubSub.WebhookSecretRef)
+ ?? startup.Config.GmailPubSub.WebhookSecret;
+
+ app.MapPost(startup.Config.GmailPubSub.WebhookPath, async (HttpContext ctx) =>
+ {
+ // Verify shared secret if configured
+ if (!string.IsNullOrWhiteSpace(gmailSecret))
+ {
+ var providedToken = ctx.Request.Query["token"].ToString();
+ if (string.IsNullOrWhiteSpace(providedToken))
+ providedToken = ctx.Request.Headers["X-OpenClaw-Secret"].ToString();
+
+ if (!string.Equals(providedToken, gmailSecret, StringComparison.Ordinal))
+ {
+ ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
+ return;
+ }
+ }
+
+ var (bodyOk, bodyText) = await EndpointHelpers.TryReadBodyTextAsync(ctx, 64 * 1024, ctx.RequestAborted);
+ if (!bodyOk)
+ {
+ ctx.Response.StatusCode = StatusCodes.Status413PayloadTooLarge;
+ await ctx.Response.WriteAsync("Request too large.", ctx.RequestAborted);
+ return;
+ }
+
+ var (status, body) = await gmailBridge.HandlePushAsync(bodyText, ctx.RequestAborted);
+ ctx.Response.StatusCode = status;
+ await ctx.Response.WriteAsync(body, ctx.RequestAborted);
+ });
+ }
}
private static string TryResolveWhatsAppDeliveryKey(string bodyText)
diff --git a/src/OpenClaw.Gateway/Extensions/GatewayWorkers.cs b/src/OpenClaw.Gateway/Extensions/GatewayWorkers.cs
index bb9b9bd..bc530ce 100644
--- a/src/OpenClaw.Gateway/Extensions/GatewayWorkers.cs
+++ b/src/OpenClaw.Gateway/Extensions/GatewayWorkers.cs
@@ -184,6 +184,10 @@ private static void StartInboundWorkers(
LearningService? learningService,
GatewayAutomationService? automationService)
{
+ var routeResolver = config.Routing.Enabled
+ ? new OpenClaw.Gateway.Integrations.AgentRouteResolver(config.Routing)
+ : null;
+
for (var i = 0; i < workerCount; i++)
{
_ = Task.Run(async () =>
@@ -370,6 +374,9 @@ await pipeline.OutboundWriter.WriteAsync(new OutboundMessage
if (msg.ChannelId == "telegram") policy = config.Channels.Telegram.DmPolicy;
if (msg.ChannelId == "whatsapp") policy = config.Channels.WhatsApp.DmPolicy;
if (msg.ChannelId == "teams") policy = config.Channels.Teams.DmPolicy;
+ if (msg.ChannelId == "slack") policy = config.Channels.Slack.DmPolicy;
+ if (msg.ChannelId == "discord") policy = config.Channels.Discord.DmPolicy;
+ if (msg.ChannelId == "signal") policy = config.Channels.Signal.DmPolicy;
if (policy is "closed")
continue; // Silently drop all inbound messages
@@ -390,12 +397,40 @@ await pipeline.OutboundWriter.WriteAsync(new OutboundMessage
continue; // Drop the inbound request after sending pairing code
}
+ // ── Multi-Agent Route Resolution ─────────────────
+ var resolvedRoute = routeResolver?.Resolve(msg.ChannelId, msg.SenderId);
+
session = msg.SessionId is not null
? await sessionManager.GetOrCreateByIdAsync(msg.SessionId, msg.ChannelId, conversationRecipientId, lifetime.ApplicationStopping)
: await sessionManager.GetOrCreateAsync(msg.ChannelId, conversationRecipientId, lifetime.ApplicationStopping);
if (session is null)
throw new InvalidOperationException("Session manager returned null session.");
+ // Apply route overrides to session
+ if (resolvedRoute is not null)
+ {
+ session.ModelOverride = string.IsNullOrWhiteSpace(resolvedRoute.ModelOverride)
+ ? session.ModelOverride
+ : resolvedRoute.ModelOverride.Trim();
+ session.SystemPromptOverride = string.IsNullOrWhiteSpace(resolvedRoute.SystemPrompt)
+ ? null
+ : resolvedRoute.SystemPrompt.Trim();
+ session.RoutePresetId = string.IsNullOrWhiteSpace(resolvedRoute.PresetId)
+ ? null
+ : resolvedRoute.PresetId.Trim();
+ session.RouteAllowedTools = resolvedRoute.AllowedTools
+ .Where(static item => !string.IsNullOrWhiteSpace(item))
+ .Select(static item => item.Trim())
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ }
+ else
+ {
+ session.SystemPromptOverride = null;
+ session.RoutePresetId = null;
+ session.RouteAllowedTools = [];
+ }
+
initialInputTokens = session.TotalInputTokens;
initialOutputTokens = session.TotalOutputTokens;
@@ -575,9 +610,16 @@ await pipeline.OutboundWriter.WriteAsync(new OutboundMessage
{
await wsChannel.SendStreamEventAsync(msg.SenderId, "typing_start", "", msg.MessageId, lifetime.ApplicationStopping);
+ AgentStreamEvent? doneEvent = null;
await foreach (var evt in agentRuntime.RunStreamingAsync(
session, messageText, lifetime.ApplicationStopping, approvalCallback: ApprovalCallback))
{
+ if (string.Equals(evt.EnvelopeType, "assistant_done", StringComparison.Ordinal))
+ {
+ doneEvent = evt;
+ continue;
+ }
+
await wsChannel.SendStreamEventAsync(
msg.SenderId, evt.EnvelopeType, evt.Content, msg.MessageId,
lifetime.ApplicationStopping);
@@ -585,6 +627,35 @@ await wsChannel.SendStreamEventAsync(
await sessionManager.PersistAsync(session, lifetime.ApplicationStopping);
if (learningService is not null)
await learningService.ObserveSessionAsync(session, lifetime.ApplicationStopping);
+
+ // Send verbose footer via stream for streaming sessions
+ if (session.VerboseMode)
+ {
+ var streamInputDelta = session.TotalInputTokens - initialInputTokens;
+ var streamOutputDelta = session.TotalOutputTokens - initialOutputTokens;
+ var streamToolCalls = 0;
+ for (var ti = session.History.Count - 1; ti >= 0; ti--)
+ {
+ var turn = session.History[ti];
+ if (turn.ToolCalls is { Count: > 0 })
+ streamToolCalls += turn.ToolCalls.Count;
+ if (string.Equals(turn.Role, "user", StringComparison.Ordinal))
+ break;
+ }
+ var verboseFooter = $"\n\n---\n{streamToolCalls} tool call(s) | {streamInputDelta} in / {streamOutputDelta} out tokens (this turn)";
+ await wsChannel.SendStreamEventAsync(msg.SenderId, "text_delta", verboseFooter, msg.MessageId, lifetime.ApplicationStopping);
+ }
+
+ if (doneEvent is AgentStreamEvent completedEvent)
+ {
+ await wsChannel.SendStreamEventAsync(
+ msg.SenderId,
+ completedEvent.EnvelopeType,
+ completedEvent.Content,
+ msg.MessageId,
+ lifetime.ApplicationStopping);
+ }
+
await wsChannel.SendStreamEventAsync(msg.SenderId, "typing_stop", "", msg.MessageId, lifetime.ApplicationStopping);
}
else
@@ -618,6 +689,23 @@ await wsChannel.SendStreamEventAsync(
if (heartbeatService.IsManagedHeartbeatJob(msg.CronJobName))
heartbeatService.RecordResult(session, responseText, suppressHeartbeatDelivery, inputTokenDelta, outputTokenDelta);
+ // Append verbose mode footer (tool calls and token delta)
+ if (session.VerboseMode)
+ {
+ // Tool calls may be spread across multiple turns added during this run
+ var turnToolCalls = 0;
+ for (var ti = session.History.Count - 1; ti >= 0; ti--)
+ {
+ var turn = session.History[ti];
+ if (turn.ToolCalls is { Count: > 0 })
+ turnToolCalls += turn.ToolCalls.Count;
+ // Stop once we reach a user turn (start of this interaction)
+ if (string.Equals(turn.Role, "user", StringComparison.Ordinal))
+ break;
+ }
+ responseText += $"\n\n---\n{turnToolCalls} tool call(s) | {inputTokenDelta} in / {outputTokenDelta} out tokens (this turn)";
+ }
+
// Append Usage Tracking string if configured
if (config.UsageFooter is "tokens")
responseText += $"\n\n---\n↑ {session.TotalInputTokens} in / {session.TotalOutputTokens} out tokens";
diff --git a/src/OpenClaw.Gateway/Integrations/AgentRouteResolver.cs b/src/OpenClaw.Gateway/Integrations/AgentRouteResolver.cs
new file mode 100644
index 0000000..3c432da
--- /dev/null
+++ b/src/OpenClaw.Gateway/Integrations/AgentRouteResolver.cs
@@ -0,0 +1,63 @@
+using OpenClaw.Core.Models;
+
+namespace OpenClaw.Gateway.Integrations;
+
+///
+/// Resolves agent routing for inbound messages based on channel/sender matching.
+///
+internal sealed class AgentRouteResolver
+{
+ private readonly RoutingConfig _config;
+
+ public AgentRouteResolver(RoutingConfig config)
+ {
+ _config = config;
+ }
+
+ ///
+ /// Resolves a route for the given channel and sender.
+ /// Returns null if no route matches or routing is disabled.
+ ///
+ public AgentRouteConfig? Resolve(string channelId, string senderId)
+ {
+ if (!_config.Enabled || _config.Routes.Count == 0)
+ return null;
+
+ // Try exact match: channel:sender
+ var exactKey = $"{channelId}:{senderId}";
+ if (_config.Routes.TryGetValue(exactKey, out var exactRoute))
+ return exactRoute;
+
+ // Try channel-only match
+ foreach (var (_, route) in _config.Routes)
+ {
+ if (route.ChannelId is null)
+ continue;
+
+ if (!string.Equals(route.ChannelId, channelId, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ // If sender filter specified, must match
+ if (route.SenderId is not null &&
+ !string.Equals(route.SenderId, senderId, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ return route;
+ }
+
+ // Try wildcard routes (no channel filter)
+ foreach (var (_, route) in _config.Routes)
+ {
+ if (route.ChannelId is not null)
+ continue;
+
+ if (route.SenderId is not null &&
+ !string.Equals(route.SenderId, senderId, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ return route;
+ }
+
+ return null;
+ }
+}
diff --git a/src/OpenClaw.Gateway/Integrations/GmailPubSubBridge.cs b/src/OpenClaw.Gateway/Integrations/GmailPubSubBridge.cs
new file mode 100644
index 0000000..615c244
--- /dev/null
+++ b/src/OpenClaw.Gateway/Integrations/GmailPubSubBridge.cs
@@ -0,0 +1,89 @@
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using OpenClaw.Core.Models;
+using OpenClaw.Core.Pipeline;
+
+namespace OpenClaw.Gateway.Integrations;
+
+///
+/// Handles Gmail Pub/Sub push notifications and injects messages into the pipeline.
+/// Google Pub/Sub sends a POST with a base64-encoded notification when new emails arrive.
+///
+internal sealed class GmailPubSubBridge
+{
+ private readonly GmailPubSubConfig _config;
+ private readonly MessagePipeline _pipeline;
+ private readonly ILogger _logger;
+
+ public GmailPubSubBridge(GmailPubSubConfig config, MessagePipeline pipeline, ILogger logger)
+ {
+ _config = config;
+ _pipeline = pipeline;
+ _logger = logger;
+ }
+
+ ///
+ /// Handles the incoming Pub/Sub push notification webhook.
+ ///
+ public async ValueTask<(int StatusCode, string Body)> HandlePushAsync(string bodyText, CancellationToken ct)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(bodyText);
+ var root = doc.RootElement;
+
+ // Extract the Pub/Sub message
+ if (!root.TryGetProperty("message", out var message))
+ return (400, "Missing 'message' field.");
+
+ string? emailAddress = null;
+ string? historyId = null;
+
+ if (message.TryGetProperty("data", out var dataEl) && dataEl.ValueKind == JsonValueKind.String)
+ {
+ var dataStr = dataEl.GetString();
+ if (!string.IsNullOrWhiteSpace(dataStr))
+ {
+ try
+ {
+ var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(dataStr));
+ using var innerDoc = JsonDocument.Parse(decoded);
+ var inner = innerDoc.RootElement;
+ emailAddress = inner.TryGetProperty("emailAddress", out var ea) ? ea.GetString() : null;
+ historyId = inner.TryGetProperty("historyId", out var hi) ? hi.ToString() : null;
+ }
+ catch
+ {
+ // Data may not be JSON — just use the raw notification
+ }
+ }
+ }
+
+ var promptText = _config.Prompt;
+ if (!string.IsNullOrWhiteSpace(emailAddress))
+ promptText += $"\nEmail: {emailAddress}";
+ if (!string.IsNullOrWhiteSpace(historyId))
+ promptText += $"\nHistory ID: {historyId}";
+
+ var inbound = new InboundMessage
+ {
+ ChannelId = "gmail",
+ SenderId = emailAddress ?? "gmail-pubsub",
+ SessionId = _config.SessionId ?? "gmail:triage",
+ Text = promptText,
+ IsSystem = true,
+ };
+
+ await _pipeline.InboundWriter.WriteAsync(inbound, ct);
+ _logger.LogInformation("Gmail Pub/Sub notification processed for {Email}.", emailAddress ?? "unknown");
+
+ return (200, "OK");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to process Gmail Pub/Sub notification.");
+ return (500, "Processing failed.");
+ }
+ }
+}
diff --git a/src/OpenClaw.Gateway/Integrations/MdnsDiscoveryService.cs b/src/OpenClaw.Gateway/Integrations/MdnsDiscoveryService.cs
new file mode 100644
index 0000000..ea48837
--- /dev/null
+++ b/src/OpenClaw.Gateway/Integrations/MdnsDiscoveryService.cs
@@ -0,0 +1,138 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using Microsoft.Extensions.Logging;
+using OpenClaw.Core.Models;
+
+namespace OpenClaw.Gateway.Integrations;
+
+///
+/// Advertises the OpenClaw gateway on the local network via mDNS/DNS-SD.
+/// Enables automatic discovery by companion apps and nodes.
+///
+internal sealed class MdnsDiscoveryService : IAsyncDisposable
+{
+ private static readonly IPAddress MulticastAddress = IPAddress.Parse("224.0.0.251");
+ private const int MdnsPort = 5353;
+
+ private readonly MdnsConfig _config;
+ private readonly int _gatewayPort;
+ private readonly ILogger _logger;
+ private CancellationTokenSource? _cts;
+ private Task? _listenTask;
+ private UdpClient? _udp;
+
+ public MdnsDiscoveryService(MdnsConfig config, int gatewayPort, ILogger logger)
+ {
+ _config = config;
+ _gatewayPort = gatewayPort;
+ _logger = logger;
+ }
+
+ public void Start(CancellationToken ct)
+ {
+ if (!_config.Enabled)
+ return;
+
+ _cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+
+ try
+ {
+ _udp = new UdpClient();
+ _udp.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
+ _udp.Client.Bind(new IPEndPoint(IPAddress.Any, MdnsPort));
+ _udp.JoinMulticastGroup(MulticastAddress);
+
+ _listenTask = ListenLoopAsync(_cts.Token);
+
+ var instanceName = _config.InstanceName ?? Environment.MachineName;
+ var port = _config.Port > 0 ? _config.Port : _gatewayPort;
+ _logger.LogInformation("mDNS discovery started: {Instance}.{ServiceType}.local on port {Port}.",
+ instanceName, _config.ServiceType, port);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to start mDNS discovery. Service advertisement disabled.");
+ }
+ }
+
+ private async Task ListenLoopAsync(CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ try
+ {
+ var result = await _udp!.ReceiveAsync(ct);
+ // Check if the query is for our service type
+ var query = Encoding.UTF8.GetString(result.Buffer);
+ if (query.Contains(_config.ServiceType, StringComparison.OrdinalIgnoreCase))
+ await SendResponseAsync(ct);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "mDNS receive error (non-fatal).");
+ }
+ }
+ }
+
+ private async Task SendResponseAsync(CancellationToken ct)
+ {
+ try
+ {
+ var instanceName = _config.InstanceName ?? Environment.MachineName;
+ var port = _config.Port > 0 ? _config.Port : _gatewayPort;
+
+ // Build a minimal DNS-SD TXT record response
+ var txt = $"version=1.0\nport={port}\nauth={(!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("OPENCLAW_AUTH_TOKEN")) ? "required" : "none")}";
+ var response = BuildSimpleMdnsResponse(instanceName, _config.ServiceType, port, txt);
+
+ var endpoint = new IPEndPoint(MulticastAddress, MdnsPort);
+ await _udp!.SendAsync(response, endpoint, ct);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Failed to send mDNS response.");
+ }
+ }
+
+ ///
+ /// Builds a minimal mDNS response packet. This is a simplified implementation
+ /// for basic service advertisement. For full DNS-SD compliance, consider a
+ /// dedicated mDNS library.
+ ///
+ private static byte[] BuildSimpleMdnsResponse(string instanceName, string serviceType, int port, string txt)
+ {
+ // Simplified: encode as a TXT record with service info
+ var payload = $"{instanceName}.{serviceType}.local\tport={port}\t{txt}";
+ var data = Encoding.UTF8.GetBytes(payload);
+
+ // Wrap in a minimal DNS response packet
+ var packet = new byte[12 + data.Length];
+ // Transaction ID = 0 (mDNS)
+ // Flags: 0x8400 (response, authoritative)
+ packet[2] = 0x84;
+ packet[3] = 0x00;
+ // Answer count = 1
+ packet[7] = 0x01;
+ // Copy payload
+ Buffer.BlockCopy(data, 0, packet, 12, data.Length);
+
+ return packet;
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ _cts?.Cancel();
+ if (_listenTask is not null)
+ {
+ try { await _listenTask; }
+ catch (OperationCanceledException) { }
+ }
+ _udp?.Dispose();
+ _cts?.Dispose();
+ }
+}
diff --git a/src/OpenClaw.Gateway/Integrations/TailscaleService.cs b/src/OpenClaw.Gateway/Integrations/TailscaleService.cs
new file mode 100644
index 0000000..27573ce
--- /dev/null
+++ b/src/OpenClaw.Gateway/Integrations/TailscaleService.cs
@@ -0,0 +1,106 @@
+using System.Diagnostics;
+using Microsoft.Extensions.Logging;
+using OpenClaw.Core.Models;
+
+namespace OpenClaw.Gateway.Integrations;
+
+///
+/// Manages Tailscale Serve/Funnel for zero-config remote access to the gateway.
+///
+internal sealed class TailscaleService : IAsyncDisposable
+{
+ private readonly TailscaleConfig _config;
+ private readonly int _gatewayPort;
+ private readonly ILogger _logger;
+ private bool _started;
+
+ public TailscaleService(TailscaleConfig config, int gatewayPort, ILogger logger)
+ {
+ _config = config;
+ _gatewayPort = gatewayPort;
+ _logger = logger;
+ }
+
+ public async Task StartAsync(CancellationToken ct)
+ {
+ if (!_config.Enabled || string.Equals(_config.Mode, "off", StringComparison.OrdinalIgnoreCase))
+ return;
+
+ if (!await IsTailscaleAvailableAsync(ct))
+ {
+ _logger.LogWarning("Tailscale is not available on this system. Skipping Serve/Funnel setup.");
+ return;
+ }
+
+ var mode = _config.Mode.ToLowerInvariant();
+ var target = $"https+insecure://localhost:{_gatewayPort}";
+ var port = _config.Port > 0 ? _config.Port : 443;
+
+ try
+ {
+ if (mode == "funnel")
+ {
+ await RunTailscaleAsync($"funnel --bg --https={port} {target}", ct);
+ _logger.LogInformation("Tailscale Funnel started on port {Port} → localhost:{GatewayPort}.", port, _gatewayPort);
+ }
+ else
+ {
+ await RunTailscaleAsync($"serve --bg --https={port} {target}", ct);
+ _logger.LogInformation("Tailscale Serve started on port {Port} → localhost:{GatewayPort}.", port, _gatewayPort);
+ }
+
+ _started = true;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to start Tailscale {Mode}.", mode);
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (!_started) return;
+
+ try
+ {
+ await RunTailscaleAsync("serve off", CancellationToken.None);
+ _logger.LogInformation("Tailscale Serve/Funnel stopped.");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to stop Tailscale Serve/Funnel.");
+ }
+ }
+
+ private static async Task IsTailscaleAvailableAsync(CancellationToken ct)
+ {
+ try
+ {
+ var result = await RunTailscaleAsync("status --json", ct);
+ return result.ExitCode == 0;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static async Task<(int ExitCode, string Output)> RunTailscaleAsync(string arguments, CancellationToken ct)
+ {
+ using var process = new Process();
+ process.StartInfo = new ProcessStartInfo
+ {
+ FileName = "tailscale",
+ Arguments = arguments,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+
+ process.Start();
+ var output = await process.StandardOutput.ReadToEndAsync(ct);
+ await process.WaitForExitAsync(ct);
+ return (process.ExitCode, output);
+ }
+}
diff --git a/src/OpenClaw.Gateway/OpenClaw.Gateway.csproj b/src/OpenClaw.Gateway/OpenClaw.Gateway.csproj
index f078f86..471f8b3 100644
--- a/src/OpenClaw.Gateway/OpenClaw.Gateway.csproj
+++ b/src/OpenClaw.Gateway/OpenClaw.Gateway.csproj
@@ -40,6 +40,7 @@
$(DefineConstants);OPENCLAW_ENABLE_OPENSANDBOX
+
diff --git a/src/OpenClaw.Gateway/SlackWebhookHandler.cs b/src/OpenClaw.Gateway/SlackWebhookHandler.cs
new file mode 100644
index 0000000..ae33781
--- /dev/null
+++ b/src/OpenClaw.Gateway/SlackWebhookHandler.cs
@@ -0,0 +1,275 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using OpenClaw.Channels;
+using OpenClaw.Core.Models;
+using OpenClaw.Core.Pipeline;
+using OpenClaw.Core.Security;
+
+namespace OpenClaw.Gateway;
+
+internal sealed class SlackWebhookHandler
+{
+ private readonly SlackChannelConfig _config;
+ private readonly string? _signingSecret;
+ private readonly AllowlistManager _allowlists;
+ private readonly RecentSendersStore _recentSenders;
+ private readonly AllowlistSemantics _semantics;
+ private readonly ILogger _logger;
+
+ public SlackWebhookHandler(
+ SlackChannelConfig config,
+ AllowlistManager allowlists,
+ RecentSendersStore recentSenders,
+ AllowlistSemantics allowlistSemantics,
+ ILogger logger)
+ {
+ _config = config;
+ _allowlists = allowlists;
+ _recentSenders = recentSenders;
+ _semantics = allowlistSemantics;
+ _logger = logger;
+
+ _signingSecret = SecretResolver.Resolve(config.SigningSecretRef) ?? config.SigningSecret;
+ }
+
+ public readonly record struct WebhookResponse(int StatusCode, string? Body = null, string ContentType = "text/plain");
+
+ ///
+ /// Handles an inbound Slack Events API webhook.
+ ///
+ public async ValueTask HandleEventAsync(
+ string bodyText,
+ string? timestampHeader,
+ string? signatureHeader,
+ Func enqueue,
+ CancellationToken ct)
+ {
+ if (_config.ValidateSignature)
+ {
+ if (!ValidateSlackSignature(bodyText, timestampHeader, signatureHeader))
+ {
+ _logger.LogWarning("Rejected Slack webhook due to invalid signature.");
+ return new WebhookResponse(401);
+ }
+ }
+
+ var wrapper = JsonSerializer.Deserialize(bodyText, SlackJsonContext.Default.SlackEventWrapper);
+ if (wrapper is null)
+ return new WebhookResponse(400, "Invalid payload.");
+
+ // URL verification challenge
+ if (string.Equals(wrapper.Type, "url_verification", StringComparison.Ordinal))
+ {
+ return new WebhookResponse(200, wrapper.Challenge, "text/plain");
+ }
+
+ if (!string.Equals(wrapper.Type, "event_callback", StringComparison.Ordinal))
+ return new WebhookResponse(200, "OK");
+
+ var evt = wrapper.Event;
+ if (evt is null)
+ return new WebhookResponse(200, "OK");
+
+ // Filter bot messages to prevent loops
+ if (!string.IsNullOrEmpty(evt.BotId) || string.Equals(evt.Subtype, "bot_message", StringComparison.Ordinal))
+ return new WebhookResponse(200, "OK");
+
+ // Only handle message and app_mention events
+ if (!string.Equals(evt.Type, "message", StringComparison.Ordinal) &&
+ !string.Equals(evt.Type, "app_mention", StringComparison.Ordinal))
+ return new WebhookResponse(200, "OK");
+
+ var userId = evt.User;
+ var text = evt.Text;
+ var channel = evt.Channel;
+
+ if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(text))
+ return new WebhookResponse(200, "OK");
+
+ // Workspace allowlist
+ if (_config.AllowedWorkspaceIds.Length > 0)
+ {
+ if (string.IsNullOrWhiteSpace(wrapper.TeamId))
+ return new WebhookResponse(400, "Missing team_id.");
+
+ if (!Array.Exists(_config.AllowedWorkspaceIds, id => string.Equals(id, wrapper.TeamId, StringComparison.Ordinal)))
+ {
+ _logger.LogWarning("Rejected Slack message from disallowed workspace {TeamId}.", wrapper.TeamId);
+ return new WebhookResponse(403);
+ }
+ }
+
+ // Channel allowlist
+ if (_config.AllowedChannelIds.Length > 0 && !string.IsNullOrWhiteSpace(channel))
+ {
+ if (!Array.Exists(_config.AllowedChannelIds, id => string.Equals(id, channel, StringComparison.Ordinal)))
+ return new WebhookResponse(200, "OK");
+ }
+
+ await _recentSenders.RecordAsync("slack", userId, senderName: null, ct);
+
+ // User allowlist
+ var effective = _allowlists.GetEffective("slack", new ChannelAllowlistFile
+ {
+ AllowedFrom = _config.AllowedFromUserIds
+ });
+
+ if (!AllowlistPolicy.IsAllowed(effective.AllowedFrom, userId, _semantics))
+ {
+ _logger.LogWarning("Rejected Slack message from disallowed user {UserId}.", userId);
+ return new WebhookResponse(403);
+ }
+
+ if (text.Length > _config.MaxInboundChars)
+ text = text[.._config.MaxInboundChars];
+
+ // Determine session ID based on thread mapping
+ var isDm = string.Equals(evt.ChannelType, "im", StringComparison.Ordinal);
+ string? sessionId;
+ if (evt.ThreadTs is not null && channel is not null)
+ sessionId = $"slack:thread:{channel}:{evt.ThreadTs}";
+ else if (isDm)
+ sessionId = null; // default: slack:{userId}
+ else if (channel is not null)
+ sessionId = $"slack:{channel}:{userId}";
+ else
+ sessionId = null;
+
+ var message = new InboundMessage
+ {
+ ChannelId = "slack",
+ SenderId = userId,
+ SessionId = sessionId,
+ Text = text,
+ MessageId = evt.Ts,
+ ReplyToMessageId = evt.ThreadTs,
+ IsGroup = !isDm,
+ GroupId = isDm ? null : channel,
+ };
+
+ await enqueue(message, ct);
+ return new WebhookResponse(200, "OK");
+ }
+
+ ///
+ /// Handles an inbound Slack slash command (form-encoded POST).
+ ///
+ public async ValueTask HandleSlashCommandAsync(
+ Dictionary form,
+ string? timestampHeader,
+ string? signatureHeader,
+ string rawBody,
+ Func enqueue,
+ CancellationToken ct)
+ {
+ if (_config.ValidateSignature)
+ {
+ if (!ValidateSlackSignature(rawBody, timestampHeader, signatureHeader))
+ {
+ _logger.LogWarning("Rejected Slack slash command due to invalid signature.");
+ return new WebhookResponse(401);
+ }
+ }
+
+ var userId = form.GetValueOrDefault("user_id");
+ var commandText = form.GetValueOrDefault("text") ?? "";
+ var command = form.GetValueOrDefault("command") ?? "";
+ var channel = form.GetValueOrDefault("channel_id");
+ var teamId = form.GetValueOrDefault("team_id");
+ var channelName = form.GetValueOrDefault("channel_name");
+
+ if (string.IsNullOrWhiteSpace(userId))
+ return new WebhookResponse(400, "Missing user_id.");
+
+ if (_config.AllowedWorkspaceIds.Length > 0)
+ {
+ if (string.IsNullOrWhiteSpace(teamId))
+ return new WebhookResponse(400, "Missing team_id.");
+ if (!Array.Exists(_config.AllowedWorkspaceIds, id => string.Equals(id, teamId, StringComparison.Ordinal)))
+ return new WebhookResponse(403);
+ }
+
+ if (_config.AllowedChannelIds.Length > 0 && !string.IsNullOrWhiteSpace(channel))
+ {
+ if (!Array.Exists(_config.AllowedChannelIds, id => string.Equals(id, channel, StringComparison.Ordinal)))
+ return new WebhookResponse(403);
+ }
+
+ await _recentSenders.RecordAsync("slack", userId, senderName: form.GetValueOrDefault("user_name"), ct);
+
+ var effective = _allowlists.GetEffective("slack", new ChannelAllowlistFile
+ {
+ AllowedFrom = _config.AllowedFromUserIds
+ });
+
+ if (!AllowlistPolicy.IsAllowed(effective.AllowedFrom, userId, _semantics))
+ return new WebhookResponse(403);
+
+ var text = string.IsNullOrWhiteSpace(commandText) ? command : $"{command} {commandText}";
+
+ if (text.Length > _config.MaxInboundChars)
+ text = text[.._config.MaxInboundChars];
+
+ var isDm =
+ string.Equals(channelName, "directmessage", StringComparison.OrdinalIgnoreCase) ||
+ (!string.IsNullOrWhiteSpace(channel) && channel.StartsWith('D'));
+ string? sessionId;
+ if (isDm)
+ sessionId = null;
+ else if (!string.IsNullOrWhiteSpace(channel))
+ sessionId = $"slack:{channel}:{userId}";
+ else
+ sessionId = null;
+
+ var message = new InboundMessage
+ {
+ ChannelId = "slack",
+ SenderId = userId,
+ SessionId = sessionId,
+ Text = text,
+ GroupId = isDm ? null : channel,
+ IsGroup = !isDm && !string.IsNullOrWhiteSpace(channel),
+ };
+
+ await enqueue(message, ct);
+ return new WebhookResponse(200, "Processing...");
+ }
+
+ ///
+ /// Validates the Slack request signature using HMAC-SHA256.
+ /// Slack signs requests with: v0=HMAC-SHA256(signing_secret, "v0:{timestamp}:{body}")
+ ///
+ private bool ValidateSlackSignature(string body, string? timestamp, string? providedSignature)
+ {
+ if (string.IsNullOrWhiteSpace(_signingSecret) ||
+ string.IsNullOrWhiteSpace(timestamp) ||
+ string.IsNullOrWhiteSpace(providedSignature))
+ return false;
+
+ // Prevent replay attacks: reject requests with invalid or stale timestamps
+ if (!long.TryParse(timestamp, out var ts))
+ {
+ _logger.LogWarning("Rejected Slack webhook with invalid timestamp format ({Timestamp}).", timestamp);
+ return false;
+ }
+
+ var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ if (Math.Abs(now - ts) > 300)
+ {
+ _logger.LogWarning("Rejected Slack webhook with stale timestamp ({Timestamp}).", timestamp);
+ return false;
+ }
+
+ var baseString = $"v0:{timestamp}:{body}";
+ var secretBytes = Encoding.UTF8.GetBytes(_signingSecret);
+ var baseBytes = Encoding.UTF8.GetBytes(baseString);
+ var hash = HMACSHA256.HashData(secretBytes, baseBytes);
+ var expected = $"v0={Convert.ToHexStringLower(hash)}";
+
+ return CryptographicOperations.FixedTimeEquals(
+ Encoding.UTF8.GetBytes(expected),
+ Encoding.UTF8.GetBytes(providedSignature));
+ }
+}
diff --git a/src/OpenClaw.Gateway/ToolPresetResolver.cs b/src/OpenClaw.Gateway/ToolPresetResolver.cs
index 2348e40..84c33a3 100644
--- a/src/OpenClaw.Gateway/ToolPresetResolver.cs
+++ b/src/OpenClaw.Gateway/ToolPresetResolver.cs
@@ -30,6 +30,32 @@ internal sealed class ToolPresetResolver : IToolPresetResolver
"notion_write"
];
+ private static readonly string[] CodingPresetAllow =
+ [
+ "shell", "read_file", "write_file", "edit_file", "apply_patch", "process", "git",
+ "code_exec", "browser", "memory", "memory_search", "memory_get", "project_memory",
+ "sessions", "session_search", "session_status", "delegate_agent",
+ "web_search", "web_fetch", "pdf_read", "image_gen", "vision_analyze"
+ ];
+
+ private static readonly string[] MessagingPresetAllow =
+ [
+ "message", "sessions", "sessions_send", "sessions_history", "sessions_spawn",
+ "session_status", "session_search", "memory", "memory_search", "memory_get",
+ "profile_read", "todo"
+ ];
+
+ private static readonly Dictionary BuiltInToolsets = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["group:runtime"] = new() { AllowTools = ["shell", "process", "code_exec"] },
+ ["group:fs"] = new() { AllowTools = ["read_file", "write_file", "edit_file", "apply_patch"] },
+ ["group:sessions"] = new() { AllowTools = ["sessions", "sessions_history", "sessions_send", "sessions_spawn", "session_status", "session_search", "agents_list"] },
+ ["group:memory"] = new() { AllowTools = ["memory", "memory_search", "memory_get", "project_memory"] },
+ ["group:web"] = new() { AllowTools = ["web_search", "web_fetch", "x_search", "browser"] },
+ ["group:automation"] = new() { AllowTools = ["cron", "automation", "gateway", "todo"] },
+ ["group:messaging"] = new() { AllowTools = ["message"] },
+ };
+
private readonly GatewayConfig _config;
private readonly SessionMetadataStore _metadataStore;
@@ -46,9 +72,11 @@ public ResolvedToolPreset Resolve(Session session, IEnumerable available
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
- var metadata = _metadataStore.Get(session.Id);
- var requestedPresetId = metadata.ActivePresetId;
var surface = InferSurface(session);
+ var metadata = _metadataStore.Get(session.Id);
+ var requestedPresetId = !string.IsNullOrWhiteSpace(session.RoutePresetId)
+ ? session.RoutePresetId
+ : metadata.ActivePresetId;
var presetId = string.IsNullOrWhiteSpace(requestedPresetId)
? ResolvePresetIdForSurface(surface)
: requestedPresetId!.Trim();
@@ -64,7 +92,7 @@ public IReadOnlyList ListPresets(IEnumerable availab
var names = availableToolNames.ToArray();
var configured = _config.Tooling.Presets.Keys
.Select(presetId => ResolveConfiguredPreset(presetId, surface: "", _config.Tooling.Presets[presetId], names));
- var builtInIds = new[] { "cli", "web", "telegram", "automation", "readonly" }
+ var builtInIds = new[] { "cli", "full", "coding", "messaging", "minimal", "web", "telegram", "automation", "readonly" }
.Where(id => !_config.Tooling.Presets.ContainsKey(id))
.Select(id => ResolveBuiltInPreset(id, surface: "", names));
return configured.Concat(builtInIds)
@@ -121,7 +149,11 @@ private ResolvedToolPreset ResolveConfiguredPreset(
foreach (var toolsetId in preset.Toolsets)
{
if (!_config.Tooling.Toolsets.TryGetValue(toolsetId, out var toolset))
- continue;
+ {
+ // Fall back to built-in toolsets (e.g. "group:runtime")
+ if (!BuiltInToolsets.TryGetValue(toolsetId, out toolset))
+ continue;
+ }
ApplyToolset(allowed, availableToolNames, toolset);
}
@@ -179,6 +211,18 @@ private ResolvedToolPreset ResolveBuiltInPreset(string presetId, string surface,
switch (presetId.ToLowerInvariant())
{
+ case "full":
+ // All tools allowed — no denies
+ break;
+ case "coding":
+ allowed.IntersectWith(CodingPresetAllow);
+ break;
+ case "messaging":
+ allowed.IntersectWith(MessagingPresetAllow);
+ break;
+ case "minimal":
+ allowed.IntersectWith(["session_status"]);
+ break;
case "web":
foreach (var tool in DefaultWebDeny)
allowed.Remove(tool);
diff --git a/src/OpenClaw.Gateway/Tools/AgentsListTool.cs b/src/OpenClaw.Gateway/Tools/AgentsListTool.cs
new file mode 100644
index 0000000..2ff87f2
--- /dev/null
+++ b/src/OpenClaw.Gateway/Tools/AgentsListTool.cs
@@ -0,0 +1,52 @@
+using System.Text;
+using OpenClaw.Core.Abstractions;
+using OpenClaw.Core.Models;
+
+namespace OpenClaw.Gateway.Tools;
+
+///
+/// List available sub-agent profiles that can be delegated to.
+///
+internal sealed class AgentsListTool : ITool
+{
+ private readonly DelegationConfig _delegation;
+
+ public AgentsListTool(DelegationConfig delegation)
+ {
+ _delegation = delegation;
+ }
+
+ public string Name => "agents_list";
+ public string Description => "List available sub-agent profiles. Shows configured delegation profiles that can be used with delegate_agent.";
+ public string ParameterSchema => """{"type":"object","properties":{}}""";
+
+ public ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct)
+ {
+ if (!_delegation.Enabled || _delegation.Profiles.Count == 0)
+ return ValueTask.FromResult("No agent profiles configured. Enable Delegation and add profiles to gateway config.");
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"Available agents ({_delegation.Profiles.Count}):");
+ sb.AppendLine($" Max delegation depth: {_delegation.MaxDepth}");
+ sb.AppendLine();
+
+ foreach (var (name, profile) in _delegation.Profiles)
+ {
+ sb.AppendLine($" [{name}]");
+ if (!string.IsNullOrWhiteSpace(profile.SystemPrompt))
+ {
+ var preview = profile.SystemPrompt.Length > 80
+ ? profile.SystemPrompt[..80] + "…"
+ : profile.SystemPrompt;
+ sb.AppendLine($" Prompt: {preview}");
+ }
+ if (profile.AllowedTools.Length > 0)
+ sb.AppendLine($" Tools: {string.Join(", ", profile.AllowedTools)}");
+ if (profile.MaxIterations > 0)
+ sb.AppendLine($" Max iterations: {profile.MaxIterations}");
+ sb.AppendLine();
+ }
+
+ return ValueTask.FromResult(sb.ToString().TrimEnd());
+ }
+}
diff --git a/src/OpenClaw.Gateway/Tools/CronTool.cs b/src/OpenClaw.Gateway/Tools/CronTool.cs
new file mode 100644
index 0000000..48aacfd
--- /dev/null
+++ b/src/OpenClaw.Gateway/Tools/CronTool.cs
@@ -0,0 +1,119 @@
+using System.Text;
+using System.Text.Json;
+using OpenClaw.Core.Abstractions;
+using OpenClaw.Core.Models;
+using OpenClaw.Core.Pipeline;
+
+namespace OpenClaw.Gateway.Tools;
+
+///
+/// Manage scheduled cron jobs. List, inspect, and trigger jobs.
+///
+internal sealed class CronTool : IToolWithContext
+{
+ private readonly ICronJobSource _cronSource;
+ private readonly MessagePipeline _pipeline;
+
+ public CronTool(ICronJobSource cronSource, MessagePipeline pipeline)
+ {
+ _cronSource = cronSource;
+ _pipeline = pipeline;
+ }
+
+ public string Name => "cron";
+ public string Description => "Manage scheduled cron jobs. List configured jobs, get details, or trigger immediate execution.";
+ public string ParameterSchema => """{"type":"object","properties":{"action":{"type":"string","enum":["list","get","run"],"description":"Action to perform"},"name":{"type":"string","description":"Job name (required for get/run)"}},"required":["action"]}""";
+
+ public ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct)
+ => ValueTask.FromResult("Error: cron requires execution context.");
+
+ public async ValueTask ExecuteAsync(string argumentsJson, ToolExecutionContext context, CancellationToken ct)
+ {
+ using var args = JsonDocument.Parse(
+ string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
+ var root = args.RootElement;
+
+ var action = GetString(root, "action") ?? "list";
+
+ return action switch
+ {
+ "list" => ListJobs(),
+ "get" => GetJob(root),
+ "run" => await RunJobAsync(root, ct),
+ _ => $"Error: Unknown action '{action}'. Use list, get, or run."
+ };
+ }
+
+ private string ListJobs()
+ {
+ var jobs = _cronSource.GetJobs();
+ if (jobs.Count == 0)
+ return "No cron jobs configured.";
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"Cron jobs ({jobs.Count}):");
+ foreach (var job in jobs)
+ {
+ sb.AppendLine($" [{job.Name}] {job.CronExpression}");
+ var promptPreview = job.Prompt.Length > 60 ? job.Prompt[..60] + "…" : job.Prompt;
+ sb.AppendLine($" Prompt: {promptPreview}");
+ if (job.RunOnStartup)
+ sb.AppendLine(" RunOnStartup: true");
+ }
+ return sb.ToString().TrimEnd();
+ }
+
+ private string GetJob(JsonElement root)
+ {
+ var name = GetString(root, "name");
+ if (string.IsNullOrWhiteSpace(name))
+ return "Error: 'name' is required for get action.";
+
+ var jobs = _cronSource.GetJobs();
+ var job = jobs.FirstOrDefault(j => string.Equals(j.Name, name, StringComparison.OrdinalIgnoreCase));
+ if (job is null)
+ return $"Job '{name}' not found.";
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"Job: {job.Name}");
+ sb.AppendLine($" Schedule: {job.CronExpression}");
+ sb.AppendLine($" Prompt: {job.Prompt}");
+ sb.AppendLine($" RunOnStartup: {job.RunOnStartup}");
+ if (job.SessionId is not null) sb.AppendLine($" SessionId: {job.SessionId}");
+ if (job.ChannelId is not null) sb.AppendLine($" ChannelId: {job.ChannelId}");
+ if (job.RecipientId is not null) sb.AppendLine($" RecipientId: {job.RecipientId}");
+ if (job.Timezone is not null) sb.AppendLine($" Timezone: {job.Timezone}");
+ return sb.ToString().TrimEnd();
+ }
+
+ private async Task RunJobAsync(JsonElement root, CancellationToken ct)
+ {
+ var name = GetString(root, "name");
+ if (string.IsNullOrWhiteSpace(name))
+ return "Error: 'name' is required for run action.";
+
+ var jobs = _cronSource.GetJobs();
+ var job = jobs.FirstOrDefault(j => string.Equals(j.Name, name, StringComparison.OrdinalIgnoreCase));
+ if (job is null)
+ return $"Job '{name}' not found.";
+
+ var message = new InboundMessage
+ {
+ ChannelId = job.ChannelId ?? "cron",
+ SenderId = "cron",
+ SessionId = job.SessionId ?? $"cron:{job.Name}",
+ CronJobName = job.Name,
+ Text = job.Prompt,
+ Subject = job.Subject,
+ IsSystem = true,
+ };
+
+ await _pipeline.InboundWriter.WriteAsync(message, ct);
+ return $"Job '{name}' triggered for immediate execution.";
+ }
+
+ private static string? GetString(JsonElement root, string property)
+ => root.TryGetProperty(property, out var el) && el.ValueKind == JsonValueKind.String
+ ? el.GetString()
+ : null;
+}
diff --git a/src/OpenClaw.Gateway/Tools/GatewayTool.cs b/src/OpenClaw.Gateway/Tools/GatewayTool.cs
new file mode 100644
index 0000000..de22e51
--- /dev/null
+++ b/src/OpenClaw.Gateway/Tools/GatewayTool.cs
@@ -0,0 +1,79 @@
+using System.Text;
+using System.Text.Json;
+using OpenClaw.Core.Abstractions;
+using OpenClaw.Core.Models;
+using OpenClaw.Core.Observability;
+using OpenClaw.Core.Sessions;
+
+namespace OpenClaw.Gateway.Tools;
+
+///
+/// Gateway management and status operations.
+///
+internal sealed class GatewayTool : ITool
+{
+ private readonly RuntimeMetrics _metrics;
+ private readonly SessionManager _sessions;
+ private readonly GatewayConfig _config;
+
+ public GatewayTool(RuntimeMetrics metrics, SessionManager sessions, GatewayConfig config)
+ {
+ _metrics = metrics;
+ _sessions = sessions;
+ _config = config;
+ }
+
+ public string Name => "gateway";
+ public string Description => "Get gateway status, metrics, and configuration summary.";
+ public string ParameterSchema => """{"type":"object","properties":{"action":{"type":"string","enum":["status","config"],"description":"Action: status (runtime metrics) or config (configuration summary)"}}}""";
+
+ public ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct)
+ {
+ using var args = JsonDocument.Parse(
+ string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
+ var root = args.RootElement;
+
+ var action = root.TryGetProperty("action", out var a) && a.ValueKind == JsonValueKind.String
+ ? a.GetString() : "status";
+
+ return ValueTask.FromResult(action switch
+ {
+ "config" => GetConfig(),
+ _ => GetStatus()
+ });
+ }
+
+ private string GetStatus()
+ {
+ var snapshot = _metrics.Snapshot();
+ var sb = new StringBuilder();
+ sb.AppendLine("Gateway Status:");
+ sb.AppendLine($" Active sessions: {_sessions.ActiveCount}");
+ sb.AppendLine($" Total requests: {snapshot.TotalRequests}");
+ sb.AppendLine($" Total LLM calls: {snapshot.TotalLlmCalls}");
+ sb.AppendLine($" Total tokens (in/out): {snapshot.TotalInputTokens}/{snapshot.TotalOutputTokens}");
+ sb.AppendLine($" Tool calls: {snapshot.TotalToolCalls}");
+ sb.AppendLine($" Tool failures: {snapshot.TotalToolFailures}");
+ sb.AppendLine($" LLM errors: {snapshot.TotalLlmErrors}");
+ sb.AppendLine($" LLM retries: {snapshot.TotalLlmRetries}");
+ return sb.ToString().TrimEnd();
+ }
+
+ private string GetConfig()
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("Gateway Configuration:");
+ sb.AppendLine($" Bind: {_config.BindAddress}:{_config.Port}");
+ sb.AppendLine($" LLM Provider: {_config.Llm.Provider}");
+ sb.AppendLine($" Model: {_config.Llm.Model}");
+ sb.AppendLine($" Memory: {_config.Memory.Provider}");
+ sb.AppendLine($" Max sessions: {_config.MaxConcurrentSessions}");
+ sb.AppendLine($" Session timeout: {_config.SessionTimeoutMinutes}m");
+ sb.AppendLine($" Autonomy: {_config.Tooling.AutonomyMode}");
+ sb.AppendLine($" Tool approval: {_config.Tooling.RequireToolApproval}");
+ sb.AppendLine($" Browser: {_config.Tooling.EnableBrowserTool}");
+ sb.AppendLine($" Shell: {_config.Tooling.AllowShell}");
+ sb.AppendLine($" Cron: {_config.Cron.Enabled} ({_config.Cron.Jobs.Count} jobs)");
+ return sb.ToString().TrimEnd();
+ }
+}
diff --git a/src/OpenClaw.Gateway/Tools/ProfileWriteTool.cs b/src/OpenClaw.Gateway/Tools/ProfileWriteTool.cs
new file mode 100644
index 0000000..d53fbea
--- /dev/null
+++ b/src/OpenClaw.Gateway/Tools/ProfileWriteTool.cs
@@ -0,0 +1,75 @@
+using System.Text.Json;
+using OpenClaw.Core.Abstractions;
+using OpenClaw.Core.Models;
+
+namespace OpenClaw.Gateway.Tools;
+
+///
+/// Write or update user profile data. Complements profile_read.
+///
+internal sealed class ProfileWriteTool : IToolWithContext
+{
+ private readonly IUserProfileStore _store;
+
+ public ProfileWriteTool(IUserProfileStore store) => _store = store;
+
+ public string Name => "profile_write";
+ public string Description => "Create or update a user profile. Set summary, tone, preferences, and other profile fields.";
+ public string ParameterSchema => """{"type":"object","properties":{"actor_id":{"type":"string","description":"Actor ID (defaults to current session's channel:sender)"},"summary":{"type":"string","description":"Brief user summary"},"tone":{"type":"string","description":"Preferred communication tone"},"preferences":{"type":"array","items":{"type":"string"},"description":"User preferences list"},"active_projects":{"type":"array","items":{"type":"string"},"description":"Active project names"}},"required":[]}""";
+
+ public ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct)
+ => ValueTask.FromResult("Error: profile_write requires execution context.");
+
+ public async ValueTask ExecuteAsync(string argumentsJson, ToolExecutionContext context, CancellationToken ct)
+ {
+ using var args = JsonDocument.Parse(
+ string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
+ var root = args.RootElement;
+
+ var actorId = GetString(root, "actor_id") ?? $"{context.Session.ChannelId}:{context.Session.SenderId}";
+
+ // Load existing profile to merge
+ var existing = await _store.GetProfileAsync(actorId, ct);
+
+ var summary = GetString(root, "summary") ?? existing?.Summary ?? "";
+ var tone = GetString(root, "tone") ?? existing?.Tone ?? "";
+ var preferences = GetStringArray(root, "preferences") ?? existing?.Preferences ?? [];
+ var activeProjects = GetStringArray(root, "active_projects") ?? existing?.ActiveProjects ?? [];
+
+ var profile = new UserProfile
+ {
+ ActorId = actorId,
+ ChannelId = existing?.ChannelId ?? context.Session.ChannelId,
+ SenderId = existing?.SenderId ?? context.Session.SenderId,
+ Summary = summary,
+ Tone = tone,
+ Preferences = preferences,
+ ActiveProjects = activeProjects,
+ Facts = existing?.Facts ?? [],
+ RecentIntents = existing?.RecentIntents ?? [],
+ UpdatedAtUtc = DateTimeOffset.UtcNow,
+ };
+
+ await _store.SaveProfileAsync(profile, ct);
+ return $"Profile updated for '{actorId}'.";
+ }
+
+ private static string? GetString(JsonElement root, string property)
+ => root.TryGetProperty(property, out var el) && el.ValueKind == JsonValueKind.String
+ ? el.GetString()
+ : null;
+
+ private static IReadOnlyList? GetStringArray(JsonElement root, string property)
+ {
+ if (!root.TryGetProperty(property, out var el) || el.ValueKind != JsonValueKind.Array)
+ return null;
+
+ var list = new List();
+ foreach (var item in el.EnumerateArray())
+ {
+ if (item.ValueKind == JsonValueKind.String)
+ list.Add(item.GetString()!);
+ }
+ return list;
+ }
+}
diff --git a/src/OpenClaw.Gateway/Tools/SessionStatusTool.cs b/src/OpenClaw.Gateway/Tools/SessionStatusTool.cs
new file mode 100644
index 0000000..3a82a16
--- /dev/null
+++ b/src/OpenClaw.Gateway/Tools/SessionStatusTool.cs
@@ -0,0 +1,62 @@
+using System.Text;
+using System.Text.Json;
+using OpenClaw.Core.Abstractions;
+using OpenClaw.Core.Models;
+using OpenClaw.Core.Sessions;
+
+namespace OpenClaw.Gateway.Tools;
+
+///
+/// Get compact status of a session including state, token usage, and duration.
+///
+internal sealed class SessionStatusTool : IToolWithContext
+{
+ private readonly SessionManager _sessions;
+
+ public SessionStatusTool(SessionManager sessions)
+ {
+ _sessions = sessions;
+ }
+
+ public string Name => "session_status";
+ public string Description => "Get compact status of a session. Shows state, token usage, turn count, and active duration.";
+ public string ParameterSchema => """{"type":"object","properties":{"session_id":{"type":"string","description":"Session ID (defaults to current session if omitted)"}}}""";
+
+ public ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct)
+ => ValueTask.FromResult("Error: session_status requires execution context.");
+
+ public ValueTask ExecuteAsync(string argumentsJson, ToolExecutionContext context, CancellationToken ct)
+ {
+ using var args = JsonDocument.Parse(
+ string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
+ var root = args.RootElement;
+
+ var sessionId = GetString(root, "session_id") ?? context.Session.Id;
+ var session = _sessions.TryGetActiveById(sessionId);
+
+ if (session is null)
+ return ValueTask.FromResult($"Session '{sessionId}' not found or not active.");
+
+ var duration = DateTimeOffset.UtcNow - session.CreatedAt;
+ var sb = new StringBuilder();
+ sb.AppendLine($"Session: {session.Id}");
+ sb.AppendLine($" State: {session.State}");
+ sb.AppendLine($" Channel: {session.ChannelId}");
+ sb.AppendLine($" Sender: {session.SenderId}");
+ sb.AppendLine($" Turns: {session.History.Count}");
+ sb.AppendLine($" Tokens (in/out): {session.TotalInputTokens}/{session.TotalOutputTokens}");
+ sb.AppendLine($" Created: {session.CreatedAt:u}");
+ sb.AppendLine($" Last Active: {session.LastActiveAt:u}");
+ sb.AppendLine($" Duration: {(int)duration.TotalHours}h {duration.Minutes}m");
+
+ if (session.ModelOverride is not null)
+ sb.AppendLine($" Model Override: {session.ModelOverride}");
+
+ return ValueTask.FromResult(sb.ToString().TrimEnd());
+ }
+
+ private static string? GetString(JsonElement root, string property)
+ => root.TryGetProperty(property, out var el) && el.ValueKind == JsonValueKind.String
+ ? el.GetString()
+ : null;
+}
diff --git a/src/OpenClaw.Gateway/Tools/SessionsHistoryTool.cs b/src/OpenClaw.Gateway/Tools/SessionsHistoryTool.cs
new file mode 100644
index 0000000..c320685
--- /dev/null
+++ b/src/OpenClaw.Gateway/Tools/SessionsHistoryTool.cs
@@ -0,0 +1,76 @@
+using System.Text;
+using System.Text.Json;
+using OpenClaw.Core.Abstractions;
+using OpenClaw.Core.Models;
+using OpenClaw.Core.Sessions;
+
+namespace OpenClaw.Gateway.Tools;
+
+///
+/// Fetch conversation history/transcript for a session.
+///
+internal sealed class SessionsHistoryTool : IToolWithContext
+{
+ private readonly SessionManager _sessions;
+ private readonly IMemoryStore _store;
+
+ public SessionsHistoryTool(SessionManager sessions, IMemoryStore store)
+ {
+ _sessions = sessions;
+ _store = store;
+ }
+
+ public string Name => "sessions_history";
+ public string Description => "Fetch the conversation history for a session. Returns recent turns with role and content.";
+ public string ParameterSchema => """{"type":"object","properties":{"session_id":{"type":"string","description":"Session ID to fetch history for"},"limit":{"type":"integer","description":"Max turns to return (default 20, max 100)"}},"required":["session_id"]}""";
+
+ public ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct)
+ => ValueTask.FromResult("Error: sessions_history requires execution context.");
+
+ public async ValueTask ExecuteAsync(string argumentsJson, ToolExecutionContext context, CancellationToken ct)
+ {
+ using var args = JsonDocument.Parse(
+ string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
+ var root = args.RootElement;
+
+ var sessionId = GetString(root, "session_id");
+ if (string.IsNullOrWhiteSpace(sessionId))
+ return "Error: 'session_id' is required.";
+
+ var limit = 20;
+ if (root.TryGetProperty("limit", out var l) && l.ValueKind == JsonValueKind.Number)
+ limit = Math.Clamp(l.GetInt32(), 1, 100);
+
+ // Try active session first, then load from store
+ var session = _sessions.TryGetActiveById(sessionId);
+ if (session is null)
+ session = await _store.GetSessionAsync(sessionId, ct);
+
+ if (session is null)
+ return $"Session '{sessionId}' not found.";
+
+ var history = session.History;
+ if (history.Count == 0)
+ return "Session has no conversation history.";
+
+ var start = Math.Max(0, history.Count - limit);
+ var sb = new StringBuilder();
+ sb.AppendLine($"Session: {sessionId} ({history.Count} total turns, showing last {history.Count - start})");
+ sb.AppendLine();
+
+ for (var i = start; i < history.Count; i++)
+ {
+ var turn = history[i];
+ sb.AppendLine($"[{turn.Role}] ({turn.Timestamp:HH:mm:ss})");
+ sb.AppendLine(turn.Content);
+ sb.AppendLine();
+ }
+
+ return sb.ToString().TrimEnd();
+ }
+
+ private static string? GetString(JsonElement root, string property)
+ => root.TryGetProperty(property, out var el) && el.ValueKind == JsonValueKind.String
+ ? el.GetString()
+ : null;
+}
diff --git a/src/OpenClaw.Gateway/Tools/SessionsSendTool.cs b/src/OpenClaw.Gateway/Tools/SessionsSendTool.cs
new file mode 100644
index 0000000..2d0a8a4
--- /dev/null
+++ b/src/OpenClaw.Gateway/Tools/SessionsSendTool.cs
@@ -0,0 +1,65 @@
+using System.Text.Json;
+using OpenClaw.Core.Abstractions;
+using OpenClaw.Core.Models;
+using OpenClaw.Core.Pipeline;
+using OpenClaw.Core.Sessions;
+
+namespace OpenClaw.Gateway.Tools;
+
+///
+/// Send a message to another session. Enables cross-session communication.
+///
+internal sealed class SessionsSendTool : IToolWithContext
+{
+ private readonly SessionManager _sessions;
+ private readonly MessagePipeline _pipeline;
+
+ public SessionsSendTool(SessionManager sessions, MessagePipeline pipeline)
+ {
+ _sessions = sessions;
+ _pipeline = pipeline;
+ }
+
+ public string Name => "sessions_send";
+ public string Description => "Send a message to another active session. The message will be processed as if it came from the system.";
+ public string ParameterSchema => """{"type":"object","properties":{"session_id":{"type":"string","description":"Target session ID to send message to"},"message":{"type":"string","description":"Message text to send"}},"required":["session_id","message"]}""";
+
+ public ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct)
+ => ValueTask.FromResult("Error: sessions_send requires execution context.");
+
+ public async ValueTask ExecuteAsync(string argumentsJson, ToolExecutionContext context, CancellationToken ct)
+ {
+ using var args = JsonDocument.Parse(
+ string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
+ var root = args.RootElement;
+
+ var sessionId = GetString(root, "session_id");
+ if (string.IsNullOrWhiteSpace(sessionId))
+ return "Error: 'session_id' is required.";
+
+ var message = GetString(root, "message");
+ if (string.IsNullOrWhiteSpace(message))
+ return "Error: 'message' is required.";
+
+ var target = _sessions.TryGetActiveById(sessionId);
+ if (target is null)
+ return $"Error: Session '{sessionId}' not found or not active.";
+
+ var inbound = new InboundMessage
+ {
+ ChannelId = target.ChannelId,
+ SenderId = "system",
+ SessionId = sessionId,
+ Text = message,
+ IsSystem = true,
+ };
+
+ await _pipeline.InboundWriter.WriteAsync(inbound, ct);
+ return $"Message sent to session '{sessionId}'.";
+ }
+
+ private static string? GetString(JsonElement root, string property)
+ => root.TryGetProperty(property, out var el) && el.ValueKind == JsonValueKind.String
+ ? el.GetString()
+ : null;
+}
diff --git a/src/OpenClaw.Gateway/Tools/SessionsSpawnTool.cs b/src/OpenClaw.Gateway/Tools/SessionsSpawnTool.cs
new file mode 100644
index 0000000..0d848c1
--- /dev/null
+++ b/src/OpenClaw.Gateway/Tools/SessionsSpawnTool.cs
@@ -0,0 +1,62 @@
+using System.Text.Json;
+using OpenClaw.Core.Abstractions;
+using OpenClaw.Core.Models;
+using OpenClaw.Core.Pipeline;
+using OpenClaw.Core.Sessions;
+
+namespace OpenClaw.Gateway.Tools;
+
+///
+/// Spawn a new sub-agent session with an initial prompt.
+///
+internal sealed class SessionsSpawnTool : IToolWithContext
+{
+ private readonly SessionManager _sessions;
+ private readonly MessagePipeline _pipeline;
+
+ public SessionsSpawnTool(SessionManager sessions, MessagePipeline pipeline)
+ {
+ _sessions = sessions;
+ _pipeline = pipeline;
+ }
+
+ public string Name => "sessions_spawn";
+ public string Description => "Spawn a new agent session with an initial prompt. Returns the new session ID.";
+ public string ParameterSchema => """{"type":"object","properties":{"prompt":{"type":"string","description":"Initial prompt for the new session"},"session_id":{"type":"string","description":"Optional explicit session ID (auto-generated if omitted)"},"channel_id":{"type":"string","description":"Channel to associate (default: 'agent')"}},"required":["prompt"]}""";
+
+ public ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct)
+ => ValueTask.FromResult("Error: sessions_spawn requires execution context.");
+
+ public async ValueTask ExecuteAsync(string argumentsJson, ToolExecutionContext context, CancellationToken ct)
+ {
+ using var args = JsonDocument.Parse(
+ string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
+ var root = args.RootElement;
+
+ var prompt = GetString(root, "prompt");
+ if (string.IsNullOrWhiteSpace(prompt))
+ return "Error: 'prompt' is required.";
+
+ var channelId = GetString(root, "channel_id") ?? "agent";
+ var sessionId = GetString(root, "session_id") ?? $"spawn_{Guid.NewGuid():N}"[..20];
+
+ var session = await _sessions.GetOrCreateByIdAsync(sessionId, channelId, "system", ct);
+
+ var inbound = new InboundMessage
+ {
+ ChannelId = channelId,
+ SenderId = "system",
+ SessionId = session.Id,
+ Text = prompt,
+ IsSystem = true,
+ };
+
+ await _pipeline.InboundWriter.WriteAsync(inbound, ct);
+ return $"Spawned session '{session.Id}' on channel '{channelId}'.";
+ }
+
+ private static string? GetString(JsonElement root, string property)
+ => root.TryGetProperty(property, out var el) && el.ValueKind == JsonValueKind.String
+ ? el.GetString()
+ : null;
+}
diff --git a/src/OpenClaw.Gateway/Tools/SessionsYieldTool.cs b/src/OpenClaw.Gateway/Tools/SessionsYieldTool.cs
new file mode 100644
index 0000000..334f3d2
--- /dev/null
+++ b/src/OpenClaw.Gateway/Tools/SessionsYieldTool.cs
@@ -0,0 +1,119 @@
+using System.Text.Json;
+using OpenClaw.Core.Abstractions;
+using OpenClaw.Core.Models;
+using OpenClaw.Core.Pipeline;
+using OpenClaw.Core.Sessions;
+
+namespace OpenClaw.Gateway.Tools;
+
+///
+/// Yield execution to another session. Sends a message and waits for the target session's response.
+///
+internal sealed class SessionsYieldTool : IToolWithContext
+{
+ private readonly SessionManager _sessions;
+ private readonly MessagePipeline _pipeline;
+ private readonly IMemoryStore _store;
+
+ public SessionsYieldTool(SessionManager sessions, MessagePipeline pipeline, IMemoryStore store)
+ {
+ _sessions = sessions;
+ _pipeline = pipeline;
+ _store = store;
+ }
+
+ public string Name => "sessions_yield";
+ public string Description => "Yield execution to another session. Sends a message and waits for the target to respond, then returns the response.";
+ public string ParameterSchema => """{"type":"object","properties":{"session_id":{"type":"string","description":"Target session ID to yield to"},"message":{"type":"string","description":"Message to send to the target session"},"timeout_seconds":{"type":"integer","description":"Max seconds to wait for response (default 60, max 300)"}},"required":["session_id","message"]}""";
+
+ public ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct)
+ => ValueTask.FromResult("Error: sessions_yield requires execution context.");
+
+ public async ValueTask ExecuteAsync(string argumentsJson, ToolExecutionContext context, CancellationToken ct)
+ {
+ using var args = JsonDocument.Parse(
+ string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
+ var root = args.RootElement;
+
+ var sessionId = GetString(root, "session_id");
+ if (string.IsNullOrWhiteSpace(sessionId))
+ return "Error: 'session_id' is required.";
+
+ // Prevent self-yield deadlock
+ if (string.Equals(sessionId, context.Session.Id, StringComparison.Ordinal))
+ return "Error: Cannot yield to the current session (would deadlock).";
+
+ var message = GetString(root, "message");
+ if (string.IsNullOrWhiteSpace(message))
+ return "Error: 'message' is required.";
+
+ var timeoutSeconds = 60;
+ if (root.TryGetProperty("timeout_seconds", out var ts) && ts.ValueKind == JsonValueKind.Number)
+ timeoutSeconds = Math.Clamp(ts.GetInt32(), 5, 300);
+
+ // Verify target session exists
+ var target = _sessions.TryGetActiveById(sessionId);
+ if (target is null)
+ return $"Error: Session '{sessionId}' not found or not active.";
+
+ // Record the target's current turn count to detect new responses
+ var turnCountBefore = target.History.Count;
+
+ // Send message to target session
+ var inbound = new InboundMessage
+ {
+ ChannelId = target.ChannelId,
+ SenderId = "system",
+ SessionId = sessionId,
+ Text = message,
+ IsSystem = true,
+ };
+
+ await _pipeline.InboundWriter.WriteAsync(inbound, ct);
+
+ // Poll for a new assistant turn on the target session
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ timeoutCts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
+
+ try
+ {
+ var pollDelay = 500;
+ var storeFallbackDone = false;
+ while (!timeoutCts.Token.IsCancellationRequested)
+ {
+ await Task.Delay(pollDelay, timeoutCts.Token);
+ pollDelay = Math.Min(pollDelay + 250, 2000); // backoff to 2s max
+
+ // Check active sessions first (O(n) but avoids disk I/O)
+ var updated = _sessions.TryGetActiveById(sessionId);
+
+ // Only try store once if session was evicted from active cache
+ if (updated is null && !storeFallbackDone)
+ {
+ updated = await _store.GetSessionAsync(sessionId, timeoutCts.Token);
+ storeFallbackDone = true;
+ }
+
+ if (updated is not null && updated.History.Count > turnCountBefore)
+ {
+ for (var i = updated.History.Count - 1; i >= turnCountBefore; i--)
+ {
+ if (string.Equals(updated.History[i].Role, "assistant", StringComparison.Ordinal))
+ return $"[Session {sessionId} responded]:\n{updated.History[i].Content}";
+ }
+ }
+ }
+ }
+ catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested)
+ {
+ // Timeout — not cancellation of parent
+ }
+
+ return $"Timeout: Session '{sessionId}' did not respond within {timeoutSeconds} seconds.";
+ }
+
+ private static string? GetString(JsonElement root, string property)
+ => root.TryGetProperty(property, out var el) && el.ValueKind == JsonValueKind.String
+ ? el.GetString()
+ : null;
+}
diff --git a/src/OpenClaw.MicrosoftAgentFrameworkAdapter/MafAgentRuntime.cs b/src/OpenClaw.MicrosoftAgentFrameworkAdapter/MafAgentRuntime.cs
index fa694b1..3da5bb2 100644
--- a/src/OpenClaw.MicrosoftAgentFrameworkAdapter/MafAgentRuntime.cs
+++ b/src/OpenClaw.MicrosoftAgentFrameworkAdapter/MafAgentRuntime.cs
@@ -157,7 +157,7 @@ public async Task RunAsync(
return "You've reached the token limit for this session. Please start a new conversation.";
}
- ChatClientAgent agent = CreateAgent();
+ ChatClientAgent agent = CreateAgent(session);
AgentSession mafSession = await _sessionStateStore.LoadAsync(agent, session, ct);
var toolInvocations = new List();
@@ -177,7 +177,7 @@ public async Task RunAsync(
{
Session = session,
TurnContext = turnCtx,
- SystemPromptLength = _systemPromptLength,
+ SystemPromptLength = GetSystemPromptLength(session),
SkillPromptLength = _skillPromptLength,
SessionTokenBudget = _sessionTokenBudget,
ToolInvocations = toolInvocations,
@@ -258,7 +258,7 @@ public async IAsyncEnumerable RunStreamingAsync(
yield break;
}
- ChatClientAgent agent = CreateAgent();
+ ChatClientAgent agent = CreateAgent(session);
AgentSession mafSession = await _sessionStateStore.LoadAsync(agent, session, ct);
var eventChannel = Channel.CreateBounded(new BoundedChannelOptions(256)
{
@@ -293,15 +293,9 @@ public async IAsyncEnumerable RunStreamingAsync(
await producer;
}
- private ChatClientAgent CreateAgent()
+ private ChatClientAgent CreateAgent(Session session)
{
- string systemPrompt;
- lock (_skillGate)
- {
- systemPrompt = _systemPrompt;
- }
-
- return _agentFactory.Create(_chatClient, systemPrompt, _mafTools);
+ return _agentFactory.Create(_chatClient, GetSystemPrompt(session), _mafTools);
}
private async Task ProduceStreamingRunAsync(
@@ -326,7 +320,7 @@ ValueTask WriteStreamEventAsync(AgentStreamEvent evt, CancellationToken token)
{
Session = session,
TurnContext = turnCtx,
- SystemPromptLength = _systemPromptLength,
+ SystemPromptLength = GetSystemPromptLength(session),
SkillPromptLength = _skillPromptLength,
SessionTokenBudget = _sessionTokenBudget,
ToolInvocations = toolInvocations,
@@ -400,7 +394,8 @@ await writer.WriteAsync(
}
private ChatOptions CreateChatOptions(Session session, System.Text.Json.JsonElement? responseSchema)
- => new()
+ {
+ var options = new ChatOptions
{
ModelId = session.ModelOverride ?? _config.Model,
MaxOutputTokens = _config.MaxTokens,
@@ -410,6 +405,32 @@ private ChatOptions CreateChatOptions(Session session, System.Text.Json.JsonElem
: null
};
+ if (!string.IsNullOrWhiteSpace(session.ReasoningEffort))
+ {
+ options.AdditionalProperties ??= new AdditionalPropertiesDictionary();
+ options.AdditionalProperties["reasoning_effort"] = session.ReasoningEffort;
+ }
+
+ return options;
+ }
+
+ private string GetSystemPrompt(Session session)
+ {
+ string systemPrompt;
+ lock (_skillGate)
+ {
+ systemPrompt = _systemPrompt;
+ }
+
+ if (string.IsNullOrWhiteSpace(session.SystemPromptOverride))
+ return systemPrompt;
+
+ return systemPrompt + "\n\n[Route Instructions]\n" + session.SystemPromptOverride.Trim();
+ }
+
+ private int GetSystemPromptLength(Session session)
+ => GetSystemPrompt(session).Length;
+
private async ValueTask TryInjectRecallAsync(List messages, string userMessage, CancellationToken ct)
{
if (_recall is null || !_recall.Enabled)
diff --git a/src/OpenClaw.MicrosoftAgentFrameworkAdapter/MafSessionStateStore.cs b/src/OpenClaw.MicrosoftAgentFrameworkAdapter/MafSessionStateStore.cs
index 4faa234..34e753c 100644
--- a/src/OpenClaw.MicrosoftAgentFrameworkAdapter/MafSessionStateStore.cs
+++ b/src/OpenClaw.MicrosoftAgentFrameworkAdapter/MafSessionStateStore.cs
@@ -149,7 +149,12 @@ internal static string ComputeHistoryHash(Session session)
{
var historyJson = JsonSerializer.Serialize(session.History, CoreJsonContext.Default.ListChatTurn);
var modelOverride = session.ModelOverride ?? string.Empty;
- var payload = $"{modelOverride}\n{historyJson}";
+ var systemPromptOverride = session.SystemPromptOverride ?? string.Empty;
+ var routePresetId = session.RoutePresetId ?? string.Empty;
+ var routeAllowedTools = session.RouteAllowedTools.Length == 0
+ ? string.Empty
+ : string.Join(",", session.RouteAllowedTools.OrderBy(static item => item, StringComparer.OrdinalIgnoreCase));
+ var payload = $"{modelOverride}\n{systemPromptOverride}\n{routePresetId}\n{routeAllowedTools}\n{historyJson}";
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload)));
}
}
diff --git a/src/OpenClaw.Tests/ChannelAdapterSecurityTests.cs b/src/OpenClaw.Tests/ChannelAdapterSecurityTests.cs
new file mode 100644
index 0000000..4ec3239
--- /dev/null
+++ b/src/OpenClaw.Tests/ChannelAdapterSecurityTests.cs
@@ -0,0 +1,151 @@
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using Microsoft.Extensions.Logging.Abstractions;
+using OpenClaw.Channels;
+using OpenClaw.Core.Models;
+using OpenClaw.Core.Pipeline;
+using OpenClaw.Core.Security;
+using OpenClaw.Gateway;
+using Org.BouncyCastle.Crypto.Parameters;
+using Org.BouncyCastle.Crypto.Signers;
+using Xunit;
+
+namespace OpenClaw.Tests;
+
+public sealed class ChannelAdapterSecurityTests
+{
+ [Fact]
+ public void Ed25519Verify_AcceptsValidSignature()
+ {
+ var privateKey = Enumerable.Range(0, 32).Select(static i => (byte)(i + 1)).ToArray();
+ var publicKey = new Ed25519PrivateKeyParameters(privateKey, 0).GeneratePublicKey().GetEncoded();
+ var message = Encoding.UTF8.GetBytes("1234567890{\"type\":1}");
+
+ var signer = new Ed25519Signer();
+ signer.Init(forSigning: true, new Ed25519PrivateKeyParameters(privateKey, 0));
+ signer.BlockUpdate(message, 0, message.Length);
+ var signature = signer.GenerateSignature();
+
+ Assert.True(Ed25519Verify.Verify(signature, message, publicKey));
+ }
+
+ [Fact]
+ public async Task DiscordWebhookHandler_RejectsDisallowedGuild()
+ {
+ var handler = new DiscordWebhookHandler(
+ new DiscordChannelConfig
+ {
+ ValidateSignature = false,
+ AllowedGuildIds = ["allowed-guild"]
+ },
+ NullLogger.Instance);
+
+ var payload = """
+ {
+ "id":"1",
+ "type":2,
+ "guild_id":"blocked-guild",
+ "channel_id":"channel-1",
+ "member":{"user":{"id":"user-1","username":"tester"}},
+ "data":{"name":"claw","options":[{"name":"message","value":"hello"}]}
+ }
+ """;
+ var enqueued = false;
+
+ var result = await handler.HandleAsync(
+ payload,
+ signatureHeader: null,
+ timestampHeader: null,
+ (msg, ct) =>
+ {
+ enqueued = true;
+ return ValueTask.CompletedTask;
+ },
+ CancellationToken.None);
+
+ Assert.Equal(403, result.StatusCode);
+ Assert.False(enqueued);
+ }
+
+ [Fact]
+ public async Task SlackWebhookHandler_SlashCommand_RejectsDisallowedWorkspace()
+ {
+ var handler = new SlackWebhookHandler(
+ new SlackChannelConfig
+ {
+ ValidateSignature = false,
+ AllowedWorkspaceIds = ["allowed-workspace"]
+ },
+ new AllowlistManager(
+ Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")),
+ NullLogger.Instance),
+ new RecentSendersStore(
+ Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")),
+ NullLogger.Instance),
+ AllowlistSemantics.Legacy,
+ NullLogger.Instance);
+
+ var result = await handler.HandleSlashCommandAsync(
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["user_id"] = "user-1",
+ ["team_id"] = "blocked-workspace",
+ ["channel_id"] = "C123",
+ ["command"] = "/claw",
+ ["text"] = "hello"
+ },
+ timestampHeader: null,
+ signatureHeader: null,
+ rawBody: "user_id=user-1",
+ (msg, ct) => ValueTask.CompletedTask,
+ CancellationToken.None);
+
+ Assert.Equal(403, result.StatusCode);
+ }
+
+ [Fact]
+ public async Task DiscordChannel_SendAsync_RecreatesRequestAfterRateLimit()
+ {
+ var responses = new Queue(
+ [
+ new HttpResponseMessage((HttpStatusCode)429)
+ {
+ Headers = { RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(TimeSpan.Zero) }
+ },
+ new HttpResponseMessage(HttpStatusCode.OK)
+ ]);
+ var requestCount = 0;
+ using var http = new HttpClient(new CallbackHandler(request =>
+ {
+ Interlocked.Increment(ref requestCount);
+ return responses.Dequeue();
+ }));
+
+ var channel = new DiscordChannel(
+ new DiscordChannelConfig
+ {
+ BotToken = "token",
+ RegisterSlashCommands = false
+ },
+ NullLogger.Instance,
+ http);
+
+ await channel.SendAsync(
+ new OutboundMessage
+ {
+ ChannelId = "discord",
+ RecipientId = "123",
+ Text = "hello"
+ },
+ CancellationToken.None);
+
+ Assert.Equal(2, requestCount);
+ }
+
+ private sealed class CallbackHandler(Func callback) : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ => Task.FromResult(callback(request));
+ }
+}
diff --git a/src/OpenClaw.Tests/ChatCommandProcessorTests.cs b/src/OpenClaw.Tests/ChatCommandProcessorTests.cs
index ab51bf5..f8110fa 100644
--- a/src/OpenClaw.Tests/ChatCommandProcessorTests.cs
+++ b/src/OpenClaw.Tests/ChatCommandProcessorTests.cs
@@ -32,4 +32,31 @@ public void RegisterDynamic_DuplicateCommand_FirstRegistrationWins()
Assert.Equal(DynamicCommandRegistrationResult.Registered, first);
Assert.Equal(DynamicCommandRegistrationResult.Duplicate, duplicate);
}
+
+ [Fact]
+ public async Task Compact_Command_ReportsRemainingTurns()
+ {
+ var store = new FileMemoryStore(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "openclaw-command-tests", Guid.NewGuid().ToString("N")), 4);
+ var processor = new ChatCommandProcessor(new SessionManager(store, new GatewayConfig(), NullLogger.Instance));
+ processor.SetCompactCallback(static (_, _) => Task.FromResult(6));
+
+ var session = new Session
+ {
+ Id = "sess-compact",
+ ChannelId = "websocket",
+ SenderId = "user1"
+ };
+ session.History.AddRange(
+ [
+ new ChatTurn { Role = "user", Content = "u1" },
+ new ChatTurn { Role = "assistant", Content = "a1" },
+ new ChatTurn { Role = "user", Content = "u2" },
+ new ChatTurn { Role = "assistant", Content = "a2" }
+ ]);
+
+ var (handled, response) = await processor.TryProcessCommandAsync(session, "/compact", CancellationToken.None);
+
+ Assert.True(handled);
+ Assert.Equal("Compacted: 4 turns → 6 turns remaining.", response);
+ }
}
diff --git a/src/OpenClaw.Tests/CoreServicesExtensionsTests.cs b/src/OpenClaw.Tests/CoreServicesExtensionsTests.cs
new file mode 100644
index 0000000..9ef5573
--- /dev/null
+++ b/src/OpenClaw.Tests/CoreServicesExtensionsTests.cs
@@ -0,0 +1,47 @@
+using Microsoft.Extensions.DependencyInjection;
+using OpenClaw.Core.Models;
+using OpenClaw.Gateway;
+using OpenClaw.Gateway.Bootstrap;
+using OpenClaw.Gateway.Composition;
+using Xunit;
+
+namespace OpenClaw.Tests;
+
+public sealed class CoreServicesExtensionsTests
+{
+ [Fact]
+ public void AddOpenClawCoreServices_RegistersLearningConfigForLearningService()
+ {
+ var tempPath = Path.Combine(Path.GetTempPath(), "openclaw-core-services-tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempPath);
+
+ var config = new GatewayConfig
+ {
+ Memory = new MemoryConfig
+ {
+ StoragePath = tempPath
+ }
+ };
+ var startup = new GatewayStartupContext
+ {
+ Config = config,
+ RuntimeState = new GatewayRuntimeState
+ {
+ RequestedMode = "jit",
+ EffectiveMode = GatewayRuntimeMode.Jit,
+ DynamicCodeSupported = true
+ },
+ IsNonLoopbackBind = false
+ };
+
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddOptions();
+ services.AddOpenClawCoreServices(startup);
+
+ using var provider = services.BuildServiceProvider();
+
+ Assert.Same(config.Learning, provider.GetRequiredService());
+ Assert.NotNull(provider.GetRequiredService());
+ }
+}
diff --git a/src/OpenClaw.Tests/FeatureParityTests.cs b/src/OpenClaw.Tests/FeatureParityTests.cs
index ef7495a..dbf919f 100644
--- a/src/OpenClaw.Tests/FeatureParityTests.cs
+++ b/src/OpenClaw.Tests/FeatureParityTests.cs
@@ -154,6 +154,30 @@ public async Task RunAsync_EstimatedTokenAdmissionControl_RejectsBeforeCallingLl
await chatClient.DidNotReceiveWithAnyArgs().GetResponseAsync(default!, default!, default);
}
+ [Fact]
+ public async Task RunAsync_RouteSystemPromptOverride_IsIncludedInSystemMessage()
+ {
+ var chatClient = Substitute.For();
+ IList? captured = null;
+ chatClient.GetResponseAsync(
+ Arg.Do>(messages => captured = messages),
+ Arg.Any(),
+ Arg.Any())
+ .Returns(Task.FromResult(new ChatResponse(new[] { new ChatMessage(ChatRole.Assistant, "ok") })));
+ var memory = Substitute.For();
+
+ var agent = new AgentRuntime(chatClient, [], memory, DefaultConfig, maxHistoryTurns: 10);
+ var session = CreateSession();
+ session.SystemPromptOverride = "Answer like the incident response agent.";
+
+ _ = await agent.RunAsync(session, "hello", CancellationToken.None);
+
+ Assert.NotNull(captured);
+ var systemMessage = Assert.Single(captured!, static message => message.Role == ChatRole.System);
+ Assert.Contains("[Route Instructions]", systemMessage.Text, StringComparison.Ordinal);
+ Assert.Contains("incident response agent", systemMessage.Text, StringComparison.Ordinal);
+ }
+
[Fact]
public void AgentStreamEvent_EnvelopeType_Maps_Correctly()
{
diff --git a/src/OpenClaw.Tests/GatewayWorkersTests.cs b/src/OpenClaw.Tests/GatewayWorkersTests.cs
index 46d8b1f..d997c31 100644
--- a/src/OpenClaw.Tests/GatewayWorkersTests.cs
+++ b/src/OpenClaw.Tests/GatewayWorkersTests.cs
@@ -1,5 +1,7 @@
using System.Collections.Concurrent;
using System.Diagnostics;
+using System.Net;
+using System.Text.Json;
using System.Threading.Channels;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
@@ -291,6 +293,244 @@ await pipeline.InboundWriter.WriteAsync(new InboundMessage
Assert.Contains(operations.RuntimeEvents.Query(new RuntimeEventQuery { Limit = 10 }), item => item.Action == "grant_consumed");
}
+ [Fact]
+ public async Task Start_RouteOverrides_AreAppliedToSessionBeforeRuntimeExecution()
+ {
+ var storagePath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "openclaw-worker-tests", Guid.NewGuid().ToString("N"));
+ var store = new FileMemoryStore(storagePath, 4);
+ var config = new GatewayConfig
+ {
+ Memory = new MemoryConfig
+ {
+ StoragePath = storagePath
+ },
+ Tooling = new ToolingConfig
+ {
+ EnableBrowserTool = false
+ },
+ Routing = new RoutingConfig
+ {
+ Enabled = true,
+ Routes = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["telegram:analyst"] = new()
+ {
+ ModelOverride = "route-model",
+ SystemPrompt = "Focus on production incidents.",
+ PresetId = "readonly",
+ AllowedTools = ["session_search", "profile_read"]
+ }
+ }
+ },
+ Channels = new ChannelsConfig
+ {
+ Telegram = new TelegramChannelConfig
+ {
+ DmPolicy = "open"
+ }
+ }
+ };
+
+ var sessionManager = new SessionManager(store, config, NullLogger.Instance);
+ var heartbeatService = new HeartbeatService(config, store, sessionManager, NullLogger.Instance);
+ var pipeline = new MessagePipeline();
+ var middleware = new MiddlewarePipeline([]);
+ var wsChannel = new WebSocketChannel(config.WebSocket);
+ await using var adapter = new RecordingChannelAdapter("telegram");
+ var agentRuntime = Substitute.For();
+ Session? capturedSession = null;
+ agentRuntime.RunAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(callInfo =>
+ {
+ capturedSession = callInfo.Arg();
+ return Task.FromResult("ok");
+ });
+ var toolApprovalService = new ToolApprovalService();
+ var approvalAuditStore = new ApprovalAuditStore(storagePath, NullLogger.Instance);
+ var pairingManager = new OpenClaw.Core.Security.PairingManager(storagePath, NullLogger.Instance);
+ var commandProcessor = new ChatCommandProcessor(sessionManager);
+ var runtimeMetrics = new OpenClaw.Core.Observability.RuntimeMetrics();
+ var providerRegistry = new LlmProviderRegistry();
+ var providerPolicies = new ProviderPolicyService(storagePath, NullLogger.Instance);
+ var runtimeEvents = new RuntimeEventStore(storagePath, NullLogger.Instance);
+ var operations = new RuntimeOperationsState
+ {
+ ProviderPolicies = providerPolicies,
+ ProviderRegistry = providerRegistry,
+ LlmExecution = new GatewayLlmExecutionService(
+ config,
+ providerRegistry,
+ providerPolicies,
+ runtimeEvents,
+ runtimeMetrics,
+ new OpenClaw.Core.Observability.ProviderUsageTracker(),
+ NullLogger.Instance),
+ PluginHealth = new PluginHealthService(storagePath, NullLogger.Instance),
+ ApprovalGrants = new ToolApprovalGrantStore(storagePath, NullLogger.Instance),
+ RuntimeEvents = runtimeEvents,
+ OperatorAudit = new OperatorAuditStore(storagePath, NullLogger.Instance),
+ WebhookDeliveries = new WebhookDeliveryStore(storagePath, NullLogger.Instance),
+ ActorRateLimits = new ActorRateLimitService(storagePath, NullLogger.Instance),
+ SessionMetadata = new SessionMetadataStore(storagePath, NullLogger.Instance)
+ };
+
+ using var lifetime = new TestApplicationLifetime();
+ GatewayWorkers.Start(
+ lifetime,
+ NullLogger.Instance,
+ workerCount: 1,
+ isNonLoopbackBind: false,
+ sessionManager,
+ new ConcurrentDictionary(),
+ new ConcurrentDictionary(),
+ pipeline,
+ middleware,
+ wsChannel,
+ agentRuntime,
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["telegram"] = adapter
+ },
+ config,
+ cronScheduler: null,
+ heartbeatService,
+ toolApprovalService,
+ approvalAuditStore,
+ pairingManager,
+ commandProcessor,
+ operations);
+
+ await pipeline.InboundWriter.WriteAsync(new InboundMessage
+ {
+ ChannelId = "telegram",
+ SenderId = "analyst",
+ Text = "status",
+ MessageId = "msg-route"
+ });
+
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
+ var outbound = await adapter.ReadAsync(timeout.Token);
+
+ Assert.Equal("ok", outbound.Text);
+ Assert.NotNull(capturedSession);
+ Assert.Equal("route-model", capturedSession!.ModelOverride);
+ Assert.Equal("Focus on production incidents.", capturedSession.SystemPromptOverride);
+ Assert.Equal("readonly", capturedSession.RoutePresetId);
+ Assert.Equal(["session_search", "profile_read"], capturedSession.RouteAllowedTools);
+ }
+
+ [Fact]
+ public async Task Start_StreamingVerboseFooter_IsSentBeforeAssistantDone()
+ {
+ var storagePath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "openclaw-worker-tests", Guid.NewGuid().ToString("N"));
+ var store = new FileMemoryStore(storagePath, 4);
+ var config = new GatewayConfig
+ {
+ Memory = new MemoryConfig
+ {
+ StoragePath = storagePath
+ },
+ Tooling = new ToolingConfig
+ {
+ EnableBrowserTool = false
+ }
+ };
+
+ var sessionManager = new SessionManager(store, config, NullLogger.Instance);
+ var session = await sessionManager.GetOrCreateByIdAsync("sess-stream", "websocket", "ws-user", CancellationToken.None)
+ ?? throw new InvalidOperationException("Failed to create session.");
+ session.VerboseMode = true;
+ await sessionManager.PersistAsync(session, CancellationToken.None);
+
+ var heartbeatService = new HeartbeatService(config, store, sessionManager, NullLogger.Instance);
+ var pipeline = new MessagePipeline();
+ var middleware = new MiddlewarePipeline([]);
+ var wsChannel = new WebSocketChannel(config.WebSocket);
+ var socket = new TestWebSocket();
+ Assert.True(wsChannel.TryAddConnectionForTest("ws-user", socket, IPAddress.Loopback, useJsonEnvelope: true));
+ var agentRuntime = Substitute.For();
+ agentRuntime.RunStreamingAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(StreamVerboseTestEvents());
+ var toolApprovalService = new ToolApprovalService();
+ var approvalAuditStore = new ApprovalAuditStore(storagePath, NullLogger.Instance);
+ var pairingManager = new OpenClaw.Core.Security.PairingManager(storagePath, NullLogger.Instance);
+ var commandProcessor = new ChatCommandProcessor(sessionManager);
+ var runtimeMetrics = new OpenClaw.Core.Observability.RuntimeMetrics();
+ var providerRegistry = new LlmProviderRegistry();
+ var providerPolicies = new ProviderPolicyService(storagePath, NullLogger.Instance);
+ var runtimeEvents = new RuntimeEventStore(storagePath, NullLogger.Instance);
+ var operations = new RuntimeOperationsState
+ {
+ ProviderPolicies = providerPolicies,
+ ProviderRegistry = providerRegistry,
+ LlmExecution = new GatewayLlmExecutionService(
+ config,
+ providerRegistry,
+ providerPolicies,
+ runtimeEvents,
+ runtimeMetrics,
+ new OpenClaw.Core.Observability.ProviderUsageTracker(),
+ NullLogger.Instance),
+ PluginHealth = new PluginHealthService(storagePath, NullLogger.Instance),
+ ApprovalGrants = new ToolApprovalGrantStore(storagePath, NullLogger.Instance),
+ RuntimeEvents = runtimeEvents,
+ OperatorAudit = new OperatorAuditStore(storagePath, NullLogger.Instance),
+ WebhookDeliveries = new WebhookDeliveryStore(storagePath, NullLogger.Instance),
+ ActorRateLimits = new ActorRateLimitService(storagePath, NullLogger.Instance),
+ SessionMetadata = new SessionMetadataStore(storagePath, NullLogger.Instance)
+ };
+
+ using var lifetime = new TestApplicationLifetime();
+ GatewayWorkers.Start(
+ lifetime,
+ NullLogger.Instance,
+ workerCount: 1,
+ isNonLoopbackBind: false,
+ sessionManager,
+ new ConcurrentDictionary(),
+ new ConcurrentDictionary(),
+ pipeline,
+ middleware,
+ wsChannel,
+ agentRuntime,
+ new Dictionary(StringComparer.Ordinal),
+ config,
+ cronScheduler: null,
+ heartbeatService,
+ toolApprovalService,
+ approvalAuditStore,
+ pairingManager,
+ commandProcessor,
+ operations);
+
+ await pipeline.InboundWriter.WriteAsync(new InboundMessage
+ {
+ ChannelId = "websocket",
+ SenderId = "ws-user",
+ SessionId = "sess-stream",
+ Text = "hello",
+ MessageId = "msg-stream"
+ });
+
+ await WaitForAsync(
+ () => socket.Sent.Count >= 5,
+ TimeSpan.FromSeconds(2),
+ "Timed out waiting for websocket stream events.");
+
+ var envelopes = socket.Sent
+ .Select(static payload => JsonDocument.Parse(payload).RootElement.GetProperty("type").GetString() ?? "")
+ .ToArray();
+ var footerIndex = Array.FindIndex(envelopes, static type => string.Equals(type, "text_delta", StringComparison.Ordinal));
+ var doneIndex = Array.FindIndex(envelopes, static type => string.Equals(type, "assistant_done", StringComparison.Ordinal));
+
+ Assert.Equal("typing_start", envelopes[0]);
+ Assert.Equal("assistant_chunk", envelopes[1]);
+ Assert.True(footerIndex >= 0, "Expected verbose footer text_delta event.");
+ Assert.True(doneIndex >= 0, "Expected assistant_done event.");
+ Assert.True(footerIndex < doneIndex, "Verbose footer should be emitted before assistant_done.");
+ Assert.Equal("typing_stop", envelopes[^1]);
+ }
+
[Fact]
public async Task Start_ApprovalTimeout_RecordsTimedOutAuditAndRuntimeEvent()
{
@@ -722,7 +962,10 @@ await pipeline.InboundWriter.WriteAsync(new InboundMessage
Text = job.Prompt
});
- var status = await WaitForHeartbeatStatusAsync(heartbeatService, TimeSpan.FromSeconds(2), static item => item.Outcome == "alert");
+ var status = await WaitForHeartbeatStatusAsync(heartbeatService, TimeSpan.FromSeconds(5), static item => item.Outcome == "alert");
+
+ // Allow outbound worker time to attempt delivery (which will throw)
+ await Task.Delay(500);
Assert.False(status.DeliverySuppressed);
Assert.Null(status.LastDeliveredAtUtc);
@@ -1230,6 +1473,13 @@ private static async Task WaitForAsync(Func predicate, TimeSpan timeout, s
throw new TimeoutException(message);
}
+ private static async IAsyncEnumerable StreamVerboseTestEvents()
+ {
+ yield return AgentStreamEvent.TextDelta("hello");
+ await Task.Yield();
+ yield return AgentStreamEvent.Complete();
+ }
+
private sealed class TestApplicationLifetime : IHostApplicationLifetime, IDisposable
{
private readonly CancellationTokenSource _stopping = new();
diff --git a/src/OpenClaw.Tests/SocketBridgeTransportTests.cs b/src/OpenClaw.Tests/SocketBridgeTransportTests.cs
index 8af1c85..936b9e7 100644
--- a/src/OpenClaw.Tests/SocketBridgeTransportTests.cs
+++ b/src/OpenClaw.Tests/SocketBridgeTransportTests.cs
@@ -78,15 +78,29 @@ public async Task DisposeAsync_DoesNotDeleteConfiguredSocketParentDirectory()
private static async Task ConnectAsync(string socketPath)
{
- var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
- await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath));
- var stream = new NetworkStream(socket, ownsSocket: true);
- var writer = new StreamWriter(stream, new UTF8Encoding(false), leaveOpen: true)
+ const int maxAttempts = 20;
+ for (var attempt = 0; attempt < maxAttempts; attempt++)
{
- AutoFlush = true
- };
+ var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
+ try
+ {
+ await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath));
+ var stream = new NetworkStream(socket, ownsSocket: true);
+ var writer = new StreamWriter(stream, new UTF8Encoding(false), leaveOpen: true)
+ {
+ AutoFlush = true
+ };
+
+ return new TestSocketClient(stream, writer);
+ }
+ catch (SocketException) when (attempt < maxAttempts - 1)
+ {
+ socket.Dispose();
+ await Task.Delay(10);
+ }
+ }
- return new TestSocketClient(stream, writer);
+ throw new InvalidOperationException($"Failed to connect to test socket at {socketPath}.");
}
private sealed class TestSocketClient(Stream stream, StreamWriter writer) : IAsyncDisposable
diff --git a/src/OpenClaw.Tests/ToolPresetResolverTests.cs b/src/OpenClaw.Tests/ToolPresetResolverTests.cs
index d5d33d5..e7931e2 100644
--- a/src/OpenClaw.Tests/ToolPresetResolverTests.cs
+++ b/src/OpenClaw.Tests/ToolPresetResolverTests.cs
@@ -73,6 +73,43 @@ public void Resolve_UsesSurfaceBindingWhenNoSessionOverride()
Assert.Contains("session_search", resolved.AllowedTools, StringComparer.OrdinalIgnoreCase);
}
+ [Fact]
+ public void Resolve_RoutePresetId_TakesPrecedenceOverSessionMetadata()
+ {
+ var storagePath = CreateStoragePath();
+ var metadataStore = new SessionMetadataStore(storagePath, NullLogger.Instance);
+ metadataStore.Set("sess_route", new SessionMetadataUpdateRequest
+ {
+ ActivePresetId = "ops"
+ });
+
+ var config = new GatewayConfig();
+ config.Tooling.Presets["ops"] = new ToolPresetConfig
+ {
+ DenyTools = ["shell"]
+ };
+ config.Tooling.Presets["readonly"] = new ToolPresetConfig
+ {
+ DenyTools = ["shell", "write_file"]
+ };
+
+ var resolver = new ToolPresetResolver(config, metadataStore);
+ var resolved = resolver.Resolve(
+ new Session
+ {
+ Id = "sess_route",
+ ChannelId = "websocket",
+ SenderId = "user1",
+ RoutePresetId = "readonly"
+ },
+ ["shell", "write_file", "session_search"]);
+
+ Assert.Equal("readonly", resolved.PresetId);
+ Assert.DoesNotContain("shell", resolved.AllowedTools, StringComparer.OrdinalIgnoreCase);
+ Assert.DoesNotContain("write_file", resolved.AllowedTools, StringComparer.OrdinalIgnoreCase);
+ Assert.Contains("session_search", resolved.AllowedTools, StringComparer.OrdinalIgnoreCase);
+ }
+
private static string CreateStoragePath()
{
var path = Path.Combine(Path.GetTempPath(), "openclaw-tests", Guid.NewGuid().ToString("N"));