# openclaw-rs — full content dump Source: https://openclaw-rs.neullabs.com License: MIT (content) · openclaw-rs is MIT Generated: 2026-06-04T17:06:52.512Z This file is a single-pass dump of the openclaw-rs marketing site for AI-search crawlers. The authoritative version is at https://openclaw-rs.neullabs.com. --- ## An embedded Vue 3 dashboard inside a Rust gateway URL: https://openclaw-rs.neullabs.com/blog/embedded-vue-dashboard-gateway Published: 2026-06-15 Tags: vue, dashboard, rust-embed, ui Cluster: guide openclaw-ui is a Vue 3 + Vite dashboard that's compiled into the openclaw-gateway binary via rust-embed. Here's why we ship UI inside the runtime and how the embed pipeline works. The dashboard for an agent runtime is the thing operators reach for first. Sessions, agents, tools, recent events. Without it, you're SSH'd into a host and tailing `journalctl`. `openclaw-rs` ships the dashboard *inside* the gateway binary. One port, one process, one URL. ## The two-crate setup ``` crates/ openclaw-ui/ # Vue 3 + Vite, builds to dist/ src/ package.json vite.config.ts openclaw-gateway/ # axum, embeds the ui crate's dist/ src/ Cargo.toml ``` `openclaw-ui` is a normal Vite SPA. `openclaw-gateway` declares it as a build dependency in a build script and embeds the resulting `dist/` via [`rust-embed`](https://github.com/pyrossh/rust-embed). ## The build script ```rust // crates/openclaw-gateway/build.rs use std::process::Command; fn main() { println!("cargo:rerun-if-changed=../openclaw-ui/src"); println!("cargo:rerun-if-changed=../openclaw-ui/package.json"); let status = Command::new("bun") .arg("run").arg("build") .current_dir("../openclaw-ui") .status() .expect("failed to build openclaw-ui"); assert!(status.success(), "openclaw-ui build failed"); } ``` `cargo build` triggers the Vite build before compiling the gateway. If `openclaw-ui` files change, `cargo` knows to rebuild. ## The embed ```rust use rust_embed::RustEmbed; #[derive(RustEmbed)] #[folder = "../openclaw-ui/dist/"] struct Dashboard; async fn dashboard_handler(uri: Uri) -> impl IntoResponse { let path = uri.path().trim_start_matches('/'); let path = if path.is_empty() { "index.html" } else { path }; match Dashboard::get(path) { Some(content) => { let mime = mime_guess::from_path(path).first_or_octet_stream(); ([(CONTENT_TYPE, mime.as_ref())], content.data).into_response() } None => fallback_html().into_response(), // SPA fallback for client routing } } ``` The SPA fallback (`fallback_html()`) returns the embedded `index.html` for any path the router doesn't recognise, so client-side routing works. ## What the dashboard does The current scaffold covers: - **Sessions** — list, drill into the event log, replay a session. - **Agents** — registered agents, status, last activity. - **Tools** — what's registered, last invocation, success rate. - **Channels** — Telegram connection status. - **Health** — gateway, sled, credential store, sandbox backend. It talks to the gateway over the same JSON-RPC `/rpc` endpoint your other clients use. There's no admin-only API surface. ## Why this is better than a separate UI service Three concrete wins: 1. **One thing to deploy.** No second container, no second port, no second TLS cert. 2. **Versions can't drift.** The dashboard build is locked to the gateway build by cargo. You can't ship a UI that calls a method the gateway doesn't have. 3. **Bootstrap latency disappears.** A fresh install with `openclaw onboard` lands on a working dashboard in one command. ## When you'd unbundle If you want a dashboard hosted on a CDN — for offline review of session logs you've exported, for a corporate-style admin portal — `openclaw-ui` is a normal Vite project. You can deploy it as static files and point it at any gateway. The embedded build is just the default that makes "I want to see what's happening" a zero-config operation. ## The bigger pattern This is the broader pattern in `openclaw-rs`: defaults that get you to "working" with no extra moving parts, but every component is also useful standalone if you want to break it out. - The gateway works without the embedded UI (it's still a JSON-RPC server). - The UI works without the embedded build (Vite dev server pointed at any gateway). - The Rust crates work without the gateway (use them in your own service). - The Node bindings work without anything else (Node + napi-rs binary). One binary by default. As many pieces as you need otherwise. --- ## Telegram-as-channel — building an allowlisted Rust bot URL: https://openclaw-rs.neullabs.com/blog/telegram-bot-rust-allowlist Published: 2026-06-08 Tags: telegram, channels, bot, allowlist Cluster: guide openclaw-channels ships a complete Telegram Bot API adapter — attachments, allowlisting, rule-based routing. Here's how the channel-adapter shape works in Rust. Channels are where the runtime meets the world. Telegram is the first one openclaw-channels ships in full — and the reference implementation for the rest. ## The Channel trait shape ```rust pub trait Channel: Send + Sync { fn id(&self) -> ChannelId; fn name(&self) -> &str; } #[async_trait] pub trait ChannelInbound: Channel { async fn run(&self, sink: mpsc::Sender) -> Result<()>; } #[async_trait] pub trait ChannelOutbound: Channel { async fn send(&self, to: PeerId, message: OutboundMessage) -> Result; } ``` A real channel implements both `ChannelInbound` (it produces events) and `ChannelOutbound` (it accepts replies). The runtime orchestrates: events go through the routing rules → an agent → a reply back through the same channel. ## Allowlisting Before an inbound event reaches an agent, it goes through the allowlist: ```rust pub struct Allowlist { rules: Vec, } pub enum AllowRule { PeerId(PeerId), PeerIdPrefix(String), GroupId(GroupId), Wildcard, // dangerous; opt-in } impl Allowlist { pub fn allows(&self, event: &InboundEvent) -> bool { ... } } ``` This isn't optional. A new Telegram channel starts with `Allowlist::default()` (deny-all) and you have to add explicit `AllowRule` entries before it'll process anything. If you forget, the bot is silently inert. We prefer "silently inert" over "open to the entire internet." ## Routing rules After allowlist, routing decides *which agent* gets the message: ```rust pub struct Router { rules: Vec, } pub struct RouteRule { pub priority: u32, pub matcher: RouteMatcher, pub target: AgentId, } pub enum RouteMatcher { PeerId(PeerId), Keyword(String), Regex(Regex), Always, } ``` Rules sort by priority. First match wins. `Always` is the catch-all fallback. A typical config: ```rust let router = Router::new() .add(100, RouteMatcher::Keyword("/code".into()), AgentId("code-agent".into())) .add(50, RouteMatcher::Keyword("/help".into()), AgentId("help-agent".into())) .add(0, RouteMatcher::Always, AgentId("default".into())); ``` ## The Telegram adapter The Telegram Bot API is just HTTP. Polling for updates is `getUpdates`. Sending is `sendMessage`, `sendPhoto`, `sendDocument`. The Rust adapter is built on `reqwest` and a thin set of types. ```rust pub struct TelegramChannel { id: ChannelId, bot_token: ApiKey, client: reqwest::Client, allowlist: Allowlist, } #[async_trait] impl ChannelInbound for TelegramChannel { async fn run(&self, sink: mpsc::Sender) -> Result<()> { let mut offset = 0i64; loop { let updates = self.get_updates(offset).await?; for u in updates { offset = u.update_id + 1; if !self.allowlist.allows_update(&u) { continue; } let event = self.into_inbound(u)?; sink.send(event).await?; } } } } ``` Note `ApiKey` for the bot token — same redaction guarantees as the LLM keys. ## Attachments Telegram messages can carry photos, documents, voice notes, video. The adapter normalises these into: ```rust pub struct Attachment { pub kind: AttachmentKind, // Photo, Document, Voice, Video pub mime: String, pub bytes: Vec, pub filename: Option, } ``` The runtime applies the same validation limits as for text: - Max 50 MB per attachment. - Max 10 attachments per message. - MIME type checked against `application/octet-stream` family for plausibility. ## Outbound `ChannelOutbound::send` is the symmetric path. The runtime calls it after an agent produces a response: ```rust #[async_trait] impl ChannelOutbound for TelegramChannel { async fn send(&self, to: PeerId, message: OutboundMessage) -> Result { let chat_id: i64 = to.parse()?; let resp = self.client .post(format!("https://api.telegram.org/bot{}/sendMessage", self.bot_token.expose_secret())) .json(&json!({ "chat_id": chat_id, "text": message.content, "parse_mode": "MarkdownV2", })) .send().await? .error_for_status()?; // ... } } ``` ## Configuration In `openclaw.json`: ```jsonc { "channels": { "telegram": { "enabled": true, "bot_token": "{{ ANTHROPIC_TELEGRAM_TOKEN }}", // resolved from CredentialStore "allowlist": [ { "kind": "PeerId", "value": "123456789" }, { "kind": "GroupId", "value": "-1001234567890" } ], "routing": [ { "priority": 100, "match": { "kind": "Keyword", "value": "/code" }, "target": "code-agent" }, { "priority": 0, "match": { "kind": "Always" }, "target": "default" } ] } } } ``` `{{ ANTHROPIC_TELEGRAM_TOKEN }}` is resolved from the credential store at load time. It never sits in the config file as plaintext. ## How to use this as a template The Telegram adapter is the reference shape for every other channel. Discord, Slack, Signal, Matrix, WhatsApp — each is a module that implements `Channel + ChannelInbound + ChannelOutbound` and registers itself via `ChannelRegistry::register()`. If you want to add one, the reading list is: 1. `crates/openclaw-channels/src/lib.rs` — the traits. 2. `crates/openclaw-channels/src/telegram/` — the reference implementation. 3. `docs/CONTRIBUTING.md` — the PR checklist. PRs welcome. --- ## Announcing openclaw-rs v0.1.0 URL: https://openclaw-rs.neullabs.com/blog/openclaw-rs-v0-1-0-release Published: 2026-06-02 Tags: release, announcement, v0.1.0 Cluster: announcement The first public release of openclaw-rs — a Rust agent runtime compatible with TypeScript OpenClaw. Here's what shipped, what's partial, and what's coming next. After months of building, `openclaw-rs` v0.1.0 is public. ## What's in the box **Stable today:** - `openclaw-core` — types, JSON5 config, sled event store, AES-256-GCM credential store, input validation, OAuth. - `openclaw-ipc` — nng transport + JSON-RPC message types for the TypeScript plugin bridge. - `openclaw-providers` — Anthropic + OpenAI with SSE streaming and tool use. - `openclaw-agents` — runtime, sandbox backends for Linux/macOS/Windows, tool registry, workflow engine. - `openclaw-gateway` — axum HTTP + WebSocket + JSON-RPC 2.0 + embedded Vue 3 dashboard. - `openclaw-cli` — `onboard`, `gateway`, `doctor`, `status`, `config`, `sessions`, `channels`, `daemon`. - `openclaw-node` — napi-rs bindings with pre-built binaries for Linux x64/arm64, macOS x64/arm64, Windows x64. - `openclaw-ui` — Vue 3 dashboard embedded in the gateway. **Partial:** - `openclaw-channels` — Telegram complete (full Bot API, attachments, allowlisting). Discord/Slack/Signal/Matrix/WhatsApp are roadmap items. - `openclaw-plugins` — TypeScript IPC bridge complete with 8 lifecycle hooks. WASM runtime (wasmtime vs wasmer) under evaluation. ## How to try it ```bash cargo install openclaw-cli openclaw onboard openclaw gateway run ``` The dashboard is at `http://localhost:18789`. ## Compatibility with TypeScript OpenClaw - **Same config**: `~/.openclaw/openclaw.json` (JSON5) — drop-in. - **Same skills**: Markdown + YAML — drop-in. - **Same plugin contract**: existing TypeScript plugins run unchanged via the nng IPC bridge. - **Same event format on disk.** The migration story is its own piece: [migrating from TypeScript OpenClaw to Rust](/blog/migrate-typescript-openclaw-to-rust). ## What's next In rough order: 1. **Channel adapters** — Discord, Slack, Signal, Matrix, WhatsApp. 2. **WASM plugin runtime** — once we pick a backend. 3. **Additional providers** — Google Gemini, Ollama. 4. **Benchmark numbers** — we want measured figures before making performance claims with concrete numbers. 5. **OpenTelemetry exporters** — tracing is already wired; OTel is a small step. ## Where to file issues - Bugs: [GitHub Issues](https://github.com/neul-labs/openclaw-rs/issues). - Questions: [GitHub Discussions](https://github.com/neul-labs/openclaw-rs/discussions). - Security: see `docs/SECURITY.md` for the responsible disclosure address. ## Thank you This wouldn't exist without the original OpenClaw project. The architecture is theirs. The Rust implementation is ours. We're grateful for the design, the docs, and the community that made it sensible to build on. — The Neul Labs team --- ## AES-256-GCM + Argon2id — how openclaw-rs stores API keys at rest URL: https://openclaw-rs.neullabs.com/blog/aes-gcm-credential-store-rust Published: 2026-06-01 Tags: security, encryption, secrets, aes-gcm Cluster: guide Encrypted credential storage in Rust: AEAD with AES-256-GCM, Argon2id for key derivation, per-record nonces, 0600 file permissions, and ApiKey wrappers that redact themselves. API keys are the things you most want to keep out of your logs, your crash dumps, your tracing spans, your CI artifacts, and your disk images. openclaw-rs treats them as if they're radioactive. ## The shape of the store ``` ~/.openclaw/credentials/ ├── anthropic.enc # AES-256-GCM ciphertext + nonce + tag ├── openai.enc └── master.salt # Argon2id salt ``` File permissions: `0600` on POSIX (owner read/write only). On Windows, ACLs that limit to the user SID. ## Key derivation: Argon2id The user provides a master password (during `openclaw onboard` or via a process-supplied secret). We never store it. To derive the AES-256 key: ```rust let salt = SaltString::generate(&mut OsRng); let params = Params::new( 65536, // memory cost (KiB) — 64 MiB 3, // time cost — 3 iterations 4, // parallelism — 4 lanes Some(32), // output length — 32 bytes (256 bits) )?; let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); let hash = argon.hash_password(master_password.as_bytes(), &salt)?; let key: [u8; 32] = hash.hash.unwrap().as_bytes().try_into()?; ``` Argon2id is the OWASP-recommended password KDF. 64 MiB / 3 iterations / 4 lanes gives strong resistance to GPU and ASIC attacks within reasonable user-experience latency (≈ 200 ms on a modern laptop). ## Encryption: AES-256-GCM GCM is AEAD — authenticated encryption with associated data. The output bundles the ciphertext with a 16-byte authentication tag. Tamper with the ciphertext and decryption fails before plaintext is exposed. ```rust use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::{Aead, OsRng, rand_core::RngCore}}; let cipher = Aes256Gcm::new_from_slice(&key)?; let mut nonce_bytes = [0u8; 12]; OsRng.fill_bytes(&mut nonce_bytes); let nonce = Nonce::from_slice(&nonce_bytes); let ciphertext = cipher.encrypt(nonce, plaintext.as_bytes())?; // Persist: nonce || ciphertext || tag let mut record = Vec::with_capacity(12 + ciphertext.len()); record.extend_from_slice(&nonce_bytes); record.extend_from_slice(&ciphertext); fs::write(path, &record).await?; fs::set_permissions(path, Permissions::from_mode(0o600)).await?; ``` A fresh nonce per record means we never reuse one with the same key. (GCM is catastrophically broken if you reuse nonce-key pairs.) ## Reading it back ```rust let record = fs::read(path).await?; let (nonce_bytes, sealed) = record.split_at(12); let nonce = Nonce::from_slice(nonce_bytes); let plaintext = cipher.decrypt(nonce, sealed)?; // plaintext is consumed immediately; never returned to logs. ``` `cipher.decrypt` returns `Err` on tag mismatch — corruption or tampering is detected, not silently ignored. ## The `ApiKey` wrapper Decrypted bytes never touch a plain `String`. They go straight into `ApiKey`: ```rust pub struct ApiKey(SecretBox); impl ApiKey { pub fn new(s: String) -> Self { Self(SecretBox::new(s.into_boxed_str())) } pub fn expose_secret(&self) -> &str { self.0.expose_secret() } } impl fmt::Debug for ApiKey { fn fmt(...) { write!(f, "ApiKey([REDACTED])") } } impl fmt::Display for ApiKey { fn fmt(...) { write!(f, "[REDACTED]") } } impl Drop for ApiKey { /* SecretBox zeroes on drop */ } ``` You can pass `ApiKey` into a tracing span, format-print it in an error, ship it across the Send boundary — and the textual representation is always `[REDACTED]`. The only way to get the bytes is `.expose_secret()`, and the linter flags every call site. ## End-to-end ```rust // onboard: user types master password once let store = CredentialStore::open(path, &master_password)?; // store: encrypt + persist store.put("anthropic", &ApiKey::new("sk-ant-...".to_string()))?; // retrieve: decrypt + wrap let key: ApiKey = store.get("anthropic")?; let provider = AnthropicProvider::new(key); // use: explicit expose at the HTTP boundary only let resp = client .post(url) .header("x-api-key", provider.api_key().expose_secret()) .send() .await?; ``` ## What this defends against - **Disk theft.** Without the master password, the `.enc` files are ciphertext + tag. No partial recovery. - **Log leaks.** `Debug` / `Display` on `ApiKey` print `[REDACTED]`. - **Accidental serialisation.** `ApiKey` doesn't implement `Serialize`. You can't send it over JSON-RPC by accident. - **Tampering.** GCM's auth tag means any byte flip in the ciphertext fails the decrypt. - **Process inspection mid-runtime.** `SecretBox` zeroes the buffer on drop. ## What this doesn't defend against - **Compromised host with active session.** If an attacker has root on the host while openclaw-rs is running and the master password is in memory, they can read the decrypted keys. That's an OS-level threat, not a crypto-level one. - **Coercion of the user.** If you give the password to someone, the encryption is irrelevant. - **A key-logged master password.** Same. The credential store is a defence against passive disk-level attacks and accidental leaks. For active threats, the rest of the runtime (sandbox, audit log, rate limit) is what helps. ## Rotation `openclaw configure --section auth --rotate` re-prompts the master password and re-encrypts every record with a freshly-derived key and salt. The old `.enc` files are overwritten. The cost is a single prompt per host. ## Why pure Rust crypto `aes-gcm` and `argon2` are pure-Rust crates from RustCrypto — no openssl, no libsodium, no C dependency. Cross-compilation stays trivial. Audit surface stays small. Reproducibility stays high. This is also why `rustls` is the default HTTPS stack and `secrecy` is the default secret wrapper. The fewer non-Rust dependencies the security boundary touches, the smaller the threat surface. --- ## Single-binary deployment for AI agents URL: https://openclaw-rs.neullabs.com/blog/single-binary-deployment-rust Published: 2026-05-30 Tags: deployment, ops, containers, binary-size Cluster: guide Why openclaw-rs ships as one statically-linked Rust binary — and what that buys you in container images, cold starts, and ops simplicity compared to a Node.js deployment. A Node OpenClaw deployment is a Docker image, a `node_modules` directory, a process manager, and a TLS layer. A `openclaw-rs` deployment is a binary. That's the whole pitch. The consequences are bigger than they look. ## What "one binary" actually contains `cargo install openclaw-cli` produces a single executable that bundles: - The gateway (axum + WebSocket). - The JSON-RPC dispatcher. - The event store driver (sled). - The credential store (AES-256-GCM). - The provider clients (Anthropic, OpenAI). - The sandbox shims (bwrap on Linux, sandbox-exec on macOS, Job Objects on Windows). - The Telegram channel adapter. - The Vue 3 dashboard (embedded via `rust-embed`). - The CLI itself. That's the entire surface of openclaw-rs. There is no second process to start, no second deployment to coordinate. ## Container builds ### Alpine + glibc ```dockerfile FROM rust:1.85-alpine AS build RUN apk add --no-cache musl-dev WORKDIR /src COPY . . RUN cargo build --release --target x86_64-unknown-linux-musl FROM alpine:3.20 RUN apk add --no-cache ca-certificates bubblewrap COPY --from=build /src/target/x86_64-unknown-linux-musl/release/openclaw /usr/local/bin/ EXPOSE 18789 ENTRYPOINT ["openclaw", "gateway", "run"] ``` Result: a final image around 20 MB including bubblewrap. ### Distroless ```dockerfile FROM rust:1.85 AS build WORKDIR /src COPY . . RUN cargo build --release FROM gcr.io/distroless/cc-debian12 COPY --from=build /src/target/release/openclaw / ENTRYPOINT ["/openclaw", "gateway", "run"] ``` Distroless plus a single binary plus CA certs = a few MB on top of base. No shell, no package manager, no surface for an attacker. ### Scratch (if you don't need bubblewrap) If you can disable the sandbox (you've reviewed every tool, you trust your plugins), a fully static musl build can run in a scratch image. ## Cold start `openclaw gateway run` starts and is serving requests in well under a second on modern hardware. Node OpenClaw's equivalent path involves `node`, the V8 startup, and module resolution — typically a few seconds to first 200. This matters most for: - Serverless deployments (Lambda, Cloud Run) where cold start is billable. - Auto-scaling fleets where new instances need to take traffic now. - Local development where you restart the gateway many times a day. ## Memory baseline A fresh openclaw-rs gateway with no sessions sits at < 20 MB resident. Adds a few MB per active session (the event store + projection state). Compares favourably to a Node OpenClaw baseline that starts higher and grows less predictably under GC pressure. We aren't going to publish a chart with specific numbers in this post — the project is young, the numbers will shift, and we'd rather you measure your own workload. The shape of the curve is what matters: predictable, flat, GC-free. ## Cross-compilation Building for a different target is `cargo build --release --target `: - Linux x64: `x86_64-unknown-linux-gnu` - Linux arm64: `aarch64-unknown-linux-gnu` - macOS x64: `x86_64-apple-darwin` - macOS arm64: `aarch64-apple-darwin` - Windows x64: `x86_64-pc-windows-msvc` `cross` makes the Linux targets even easier — no toolchain dance, no Docker-in-Docker. The Node bindings (`openclaw-node`) use the same `napi-rs` cross-build pipeline, so npm install picks the right pre-built binary on every platform. ## Ops surface What you stop worrying about: - `node_modules/` size and audit warnings. - pnpm vs npm vs yarn lockfile drift. - Native module rebuilds when you bump Node. - Process-supervisor configs that need to know the Node version. - TLS termination running in a separate process because you didn't want Node holding certs. What you still own: - The bind address and port. - The systemd / launchd / NSSM unit (`openclaw daemon install` writes a sane default). - The reverse proxy if you don't want axum to be your edge. - The TLS certs (axum-server can do TLS directly, or you proxy). ## When this matters less If you already have a polished Node deployment pipeline, the "single binary" pitch is a marginal benefit. The deeper wins come from: - Memory safety guarantees. - The event-sourced runtime. - The sandbox model. But for teams setting up a new agent deployment, "deploy a binary, point a port at it" is hard to beat. --- ## A JSON-RPC 2.0 agent gateway on axum URL: https://openclaw-rs.neullabs.com/blog/jsonrpc-gateway-axum-websocket Published: 2026-05-25 Tags: axum, websocket, json-rpc, gateway Cluster: guide How openclaw-gateway exposes the same JSON-RPC surface over HTTP and WebSocket using axum, tower middleware, and the embedded Vue 3 dashboard — all in one binary. The gateway is the only thing your clients talk to. It dispatches JSON-RPC, holds the rate limit, runs auth middleware, and serves the dashboard. One binary, one port, one schema. ## The method surface ``` session.create → SessionKey session.message → AgentResponse session.history → [SessionEvent] session.end → ack agent.list → [Agent] agent.status → AgentStatus tools.list → [Tool] tools.execute → ToolResult ``` Eight methods. That's the public API. ## The transport choice JSON-RPC 2.0 is a small spec. A request is: ```json { "jsonrpc": "2.0", "method": "session.message", "params": {...}, "id": 7 } ``` A response is: ```json { "jsonrpc": "2.0", "result": {...}, "id": 7 } ``` The same envelope works over HTTP (request/response) and over WebSocket (bidirectional with server-initiated notifications). One schema, two transports. For clients that just want to send a message and read the reply: hit `/rpc` over HTTP. For clients that want streaming token deltas, tool-call notifications, or session-state pushes: open the WebSocket at `/ws`. ## The axum side ```rust let app = Router::new() .route("/rpc", post(rpc_handler)) .route("/ws", get(ws_handler)) .route("/health", get(health)) .route("/status", get(status)) .nest_service("/", embedded_dashboard()) .layer(rate_limit_layer()) .layer(auth_layer()) .layer(cors_layer()) .with_state(state); ``` axum + tower is the right stack for this: - **axum** for the HTTP handlers, type-safe routing, easy WebSocket upgrades. - **tower** middleware composition — rate limiting, auth, CORS each layer cleanly. - **tokio** under the hood for async I/O. The state (`AppState`) holds the `EventStore`, the `AgentRegistry`, and the `ToolRegistry` behind `Arc>`. Handlers borrow them, no clones, no surprises. ## Rate limiting Per-client buckets via tower-governor or a hand-rolled middleware on the `tower::Service` trait: ```rust let limit = RateLimitLayer::new(60, Duration::from_secs(60)) // gateway-level .compose(SessionRateLimit::new(30, Duration::from_secs(60))); // agent-level ``` Defaults are conservative (60 req/min, 30 msg/session/min). Override in `openclaw.json` if you have a real reason. ## Auth The auth middleware checks a bearer token against `openclaw-core::auth`. Tokens are short-lived (default 1 hour), refresh tokens are encrypted at rest, scopes are minimal-required. ```rust async fn auth_middleware( State(state): State, headers: HeaderMap, req: Request, next: Next, ) -> Result { let token = extract_bearer(&headers)?; state.auth.verify(&token).await?; Ok(next.run(req).await) } ``` If the token's bad, the request never reaches the dispatcher. ## The dispatcher The RPC dispatcher is a `HashMap<&'static str, Handler>`: ```rust fn dispatch(state: &AppState, req: RpcRequest) -> RpcResponse { match req.method.as_str() { "session.create" => state.handle_session_create(req.params), "session.message" => state.handle_session_message(req.params), "session.history" => state.handle_session_history(req.params), "tools.execute" => state.handle_tools_execute(req.params), // ... _ => RpcResponse::method_not_found(req.id), } } ``` Each handler is async, deserialises params with serde, calls into the appropriate core crate, serialises the result back out. JSON-RPC errors get a numeric code; everything else is a sanitised error response. ## WebSocket: streaming The WebSocket handler upgrades the connection, then runs a per-connection loop that: 1. Receives JSON-RPC requests from the client. 2. Spawns async tasks per request. 3. Forwards server-initiated notifications (token deltas, session events) from a broadcast channel. ```rust async fn ws_loop(socket: WebSocket, state: AppState) { let (mut tx, mut rx) = socket.split(); let events = state.events_subscribe(); loop { tokio::select! { Some(Ok(msg)) = rx.next() => handle_inbound(&state, msg, &mut tx).await, Ok(event) = events.recv() => tx.send(ws_event(event)).await?, } } } ``` Server pushes ride the same WebSocket the client used to open the connection. No second connection, no extra auth round-trip. ## The dashboard The trick that delights people is the dashboard. `openclaw-ui` is a Vue 3 + Vite app. At build time, its `dist/` is `include_bytes!`'d into the `openclaw-gateway` crate via `rust-embed`. At runtime, axum serves the bundle as static files at `/`. ```rust #[derive(rust_embed::RustEmbed)] #[folder = "../openclaw-ui/dist/"] struct Dashboard; async fn dashboard_handler(uri: Uri) -> impl IntoResponse { let path = uri.path().trim_start_matches('/'); let path = if path.is_empty() { "index.html" } else { path }; Dashboard::get(path) .map(|f| ([(CONTENT_TYPE, mime(path))], f.data)) .ok_or(StatusCode::NOT_FOUND) } ``` No external dashboard service, no separate deployment, no second port to keep open. One binary. The dashboard ships *inside* the gateway. ## The builder For embedders, the gateway exposes a builder: ```rust let gateway = GatewayBuilder::new() .with_event_store(event_store) .with_agent(my_agent) .with_tool_registry(tools) .with_rate_limit(60, Duration::from_secs(60)) .build(); gateway.serve("127.0.0.1:18789").await?; ``` If you don't want the CLI, you don't need it. The gateway is a library too. ## Why this design JSON-RPC has been around forever. WebSocket has been around almost as long. axum is the consensus modern Rust HTTP framework. There is nothing exotic here. That's the point. The gateway is the unglamorous part — it should be boring, well-trodden, and never the thing that fails. --- ## Calling Rust from Node — what napi-rs gives you for free URL: https://openclaw-rs.neullabs.com/blog/napi-rs-bindings-rust-from-node Published: 2026-05-18 Tags: node, napi-rs, interop, bindings Cluster: guide openclaw-node exposes the Rust core to JavaScript via napi-rs. Pre-built binaries, TypeScript definitions, real async, no FFI boilerplate. Here's how it actually works. `openclaw-node` ([npm](https://www.npmjs.com/package/openclaw-node)) is the bridge between the Rust core and the Node ecosystem. Existing Node apps don't have to choose between "Rust runtime" and "stays in JS." They can have both. ## What napi-rs actually is [napi-rs](https://napi.rs/) is a Rust framework that compiles your Rust functions into a `.node` native module that Node.js loads like any other addon. It handles the JavaScript ↔ Rust value conversion, generates TypeScript definitions, and produces pre-built binaries on CI so end users never need a Rust toolchain. The developer experience is roughly: write a Rust function, slap a `#[napi]` attribute on it, and call it from JavaScript with full types. ```rust #[napi] pub async fn complete(req: CompletionRequest) -> Result { let provider = AnthropicProvider::new(...); provider.complete(req.into()).await .map(Into::into) .map_err(napi_err) } ``` ```typescript // Auto-generated types import { complete } from "openclaw-node"; const res = await complete({ model: "claude-3-5-sonnet-20241022", messages: [...] }); ``` ## What `openclaw-node` exposes - **Providers** — `AnthropicProvider`, `OpenAIProvider` with `.complete()` and `.completeStream()`. - **Auth** — `NodeApiKey`, `CredentialStore` (AES-256-GCM, file-backed). - **Events** — `NodeEventStore` with `append`, `query`, `project`. - **Validation** — `validateMessage()`, `validatePath()`. - **Config** — `loadConfig()`, `loadDefaultConfig()`, `validateConfig()`. - **Sessions** — `buildSessionKey()`. - **Tools** — `ToolRegistry` with `register()` and `execute()`. Full TypeScript definitions ship in `openclaw-node/index.d.ts`. Your IDE knows the types. ## A real example ```javascript import { AnthropicProvider, CredentialStore } from "openclaw-node"; // Load encrypted credential from disk const store = new CredentialStore("~/.openclaw/credentials/"); const apiKey = await store.get("anthropic"); // Use it const provider = new AnthropicProvider(apiKey); // Non-streaming const response = await provider.complete({ model: "claude-3-5-sonnet-20241022", messages: [{ role: "user", content: "Hello!" }], maxTokens: 1024, }); console.log(response.content); // Streaming provider.completeStream(request, (err, chunk) => { if (err) throw err; if (chunk.delta) process.stdout.write(chunk.delta); if (chunk.done) process.stdout.write("\n"); }); ``` The `CredentialStore` here is the same AES-256-GCM-backed store the Rust core uses. The Node code never touches plaintext on disk. ## Pre-built binaries `openclaw-node` publishes platform-specific packages: - `@openclaw-node/linux-x64-gnu` - `@openclaw-node/linux-arm64-gnu` - `@openclaw-node/darwin-x64` - `@openclaw-node/darwin-arm64` - `@openclaw-node/win32-x64-msvc` The main `openclaw-node` package depends on the right platform package via `optionalDependencies`. On install, npm picks exactly one. No `node-gyp` dance, no C++ toolchain on the install host. ## Where this fits Three concrete use cases: ### 1. Existing Node app, want the Rust providers Drop in `openclaw-node`, swap your provider client. The rest of your app is unchanged. Use the Rust streaming for free. ### 2. Want the Rust event store from Node ```javascript import { NodeEventStore } from "openclaw-node"; const store = new NodeEventStore("./events.db"); await store.append(sessionKey, { kind: "MessageReceived", content: "hi" }); const events = await store.query(sessionKey, { from: 0, to: 100 }); ``` You get sled-backed durability without managing a database from JS. ### 3. Bridge to a Rust gateway If you've moved your runtime to the Rust gateway (`openclaw gateway run`), you might still have Node services that need to call into it. `openclaw-node` can drive JSON-RPC over HTTP/WS from the Node side, sharing types with the Rust core. ## The overhead question napi-rs call overhead is typically in the nanoseconds. For LLM workloads where each call is hundreds of milliseconds of network I/O, the napi boundary cost is rounding error. Where it matters more is in tight allocation loops — and `openclaw-node` is designed not to have those, since LLM streams and event appends are coarse-grained operations. ## When not to use openclaw-node If you're starting from scratch and you want a Rust core, just use the Rust crates directly. The Node bindings are for teams that: - Have a large existing Node codebase to integrate with. - Want the Rust runtime properties without rewriting the orchestration layer. - Need to expose openclaw-rs primitives to a non-Rust team incrementally. For greenfield Rust services, `openclaw-core`, `openclaw-providers`, and `openclaw-agents` are the direct path. --- ## Migrating from TypeScript OpenClaw to Rust URL: https://openclaw-rs.neullabs.com/blog/migrate-typescript-openclaw-to-rust Published: 2026-05-11 Tags: migration, typescript, compatibility, plugins Cluster: guide How to move a production TypeScript OpenClaw deployment to openclaw-rs without rewriting plugins or changing your config. The compatibility surface is the migration path. The compatibility surface is the migration path. openclaw-rs was designed so that "switching runtime" is not a project — it's a config swap, plus optional cleanup. ## Step 0: Audit what you actually use Run through your current TypeScript OpenClaw deployment: - Which **providers** are you using? (Anthropic? OpenAI? Google? Ollama?) - Which **channels** are active? (Telegram? Slack? Discord?) - Which **plugins** are loaded? (List them by name.) - What's in your **skills** directory? - What's in your **`openclaw.json`**? Write these down. The migration plan falls out of the answers. ## Step 1: Install the Rust CLI alongside ```bash cargo install openclaw-cli openclaw --version ``` This doesn't touch your existing TS deployment. The Rust CLI uses the same `~/.openclaw/` workspace by default, but you can point it elsewhere with `--workspace`. ## Step 2: Validate the config ```bash openclaw config validate ~/.openclaw/openclaw.json ``` openclaw-rs reads the same JSON5 schema. If you used providers that don't ship in v0.1.0 yet (Google, Ollama), the validator will tell you. Comment those out for now; they're roadmap items. ## Step 3: Plan provider coverage Today's stable providers in openclaw-rs: | Provider | Status | Notes | |---|---|---| | Anthropic | shipped | Claude 3.5 Sonnet, Haiku, Opus. SSE streaming, tool use. | | OpenAI | shipped | GPT-4o, GPT-4, GPT-3.5. SSE streaming, function calling. Azure base URL supported. | | Google Gemini | planned | Roadmap. | | Ollama | planned | Roadmap. | If your TS deployment uses Anthropic + OpenAI, you're done here. If you depend on Google or Ollama, either stay on TS for those agents or wait for the planned provider. ## Step 4: Plug in your TypeScript plugins This is the moment of truth. Move your plugins as-is: ``` ~/.openclaw/plugins/ ├── my-team-plugin/ │ ├── package.json │ └── index.ts └── another-plugin/ ├── package.json └── index.ts ``` openclaw-plugins discovers them by scanning for `package.json` markers. It launches each plugin as a Node subprocess, opens an nng IPC channel, and dispatches the 8 lifecycle hooks over JSON-RPC. The hooks are identical to TS OpenClaw: ```typescript export default { async BeforeToolCall(ctx, params) { // your existing code, unchanged }, async AfterToolCall(ctx, result) { // your existing code, unchanged }, }; ``` You haven't deleted anything yet. ## Step 5: Plan channel coverage Today's stable channels in openclaw-rs: - **Telegram** — full Bot API adapter with attachments, allowlisting, routing rules. Roadmap: Discord, Slack, Signal, Matrix, WhatsApp. If your TS deployment uses Slack today, that adapter has to keep running in TS while you migrate. The Channel traits in openclaw-channels are stable, so writing the Slack adapter in Rust is a single-file PR if you're motivated. We'd merge it. ## Step 6: Run the Rust gateway ```bash openclaw gateway run --port 18790 # different port from your TS gateway openclaw doctor # confirm sled, credential store, sandbox ``` Point a test session at the Rust gateway. Verify your existing plugins fire. Verify event history reads correctly. The event format on disk is wire-compatible with TS OpenClaw. ## Step 7: Cut over Once your test traffic looks identical: 1. Stop the TS gateway. 2. Switch the Rust gateway to the production port. 3. Migrate channel adapters one-by-one (Telegram first, since it's covered). If something breaks, the TS gateway is still installed. You can flip back in seconds. ## Step 8: Optional — delete the Node dependency After a week of stable Rust operation, you can: - Uninstall the Node OpenClaw package. - Delete `node_modules/` from your deployment image. - Trim ~100 MB off your container. Or keep TS around if you have plugins that genuinely need it. The plugin bridge means it's not all-or-nothing. ## What you don't migrate - **Skill files** (Markdown + YAML frontmatter). Same format. Same loader. - **Event format**. openclaw-rs reads the existing append-only log. - **Plugin code**. The nng bridge handles it. - **Config**. Same JSON5 schema. - **Workspace layout**. `~/.openclaw/` stays the same. ## What you might actually rewrite - **Custom channel adapters** if you wrote any in TS. Port them to the Rust `Channel` trait. - **Custom tools** if you wrote them as Node modules rather than as plugins. Port them to a Rust `Tool` impl or run them as a TS plugin. - **CI scripts** that assume `node_modules`. Cleanup, not blocker. ## When *not* to migrate yet - You depend on Google Gemini or Ollama → wait for the planned providers. - You depend on Discord/Slack/Signal/Matrix/WhatsApp natively → stay on TS or write the adapter yourself. - You're prototyping → stay on TS for the dev velocity. For everyone else: the runtime is a `cargo install` away. --- ## A zero-trust runtime for AI agents URL: https://openclaw-rs.neullabs.com/blog/zero-trust-agent-runtime Published: 2026-05-04 Tags: security, zero-trust, validation, secrets Cluster: guide Input validation, secret redaction, fail-secure errors, capability-based tool registration. The defence-in-depth model openclaw-rs uses to make untrusted prompts safe by construction. The premise of zero-trust is simple: *no input is trusted, no internal call is trusted, no error path is trusted*. You validate at every boundary, you fail closed, you redact secrets even from yourself. openclaw-rs applies this at five layers. ## Layer 1: Input validation at boundaries Every external byte hits a validator before anything else looks at it. ```rust pub const MAX_MESSAGE_SIZE: usize = 100_000; // 100 KB pub const MAX_JSON_DEPTH: usize = 32; pub const MAX_ATTACHMENT_SIZE: u64 = 50_000_000; // 50 MB pub const MAX_ATTACHMENTS: usize = 10; ``` We reject oversized payloads, deeply-nested JSON, null bytes, control characters, and malformed UTF-8. Boring. Effective. Cheap. ## Layer 2: Capability-based tool registration Agents can't invent tools. They invoke tools by name, and the name has to be in the `ToolRegistry`. ```rust let mut tools = ToolRegistry::new(); tools.register("bash", BashTool::new(sandbox_config.clone()))?; tools.register("read_file", ReadFileTool::new(workspace.clone()))?; // Tools NOT registered here cannot be called, period. ``` If a prompt-injected LLM asks for `tool: delete_all_data`, the registry says no, and the runtime never spins up a process for it. ## Layer 3: The sandbox When a tool *does* run, it runs inside a [platform sandbox](/blog/sandboxing-rust-agents-cross-platform). bubblewrap, sandbox-exec, or Job Objects — capped CPU, capped memory, workspace-only filesystem, no network unless explicitly granted. The sandbox is the bridge between "the LLM asked for it" and "your machine actually does it." That bridge has a guard. ## Layer 4: Secrets that protect themselves API keys live inside `ApiKey`: ```rust pub struct ApiKey(SecretBox); impl fmt::Debug for ApiKey { fn fmt(...) { write!(f, "[REDACTED]") } } impl fmt::Display for ApiKey { fn fmt(...) { write!(f, "[REDACTED]") } } ``` You can format-print an `ApiKey` anywhere — including a panic message or a tracing span — and the literal characters `[REDACTED]` come out. You unlock the contents only with an explicit `.expose_secret()` call, in the code path that actually makes the HTTP request. At rest, secrets go through the `CredentialStore`: AES-256-GCM, Argon2id key derivation, file permissions 0600, per-record nonces. See [the AES-GCM piece](/blog/aes-gcm-credential-store-rust) for the long version. ## Layer 5: Fail-secure errors Every function that touches external state returns `Result`. There is no `unwrap()` on user input, no `panic!()` on parse failure. Errors return sanitised messages; the underlying cause is logged but never returned to the caller. If we can't decide whether something is safe, the answer is "no." ```rust match validate_message(&input) { Ok(msg) => process(msg).await, Err(_) => { // The error has been logged with full context internally. // The caller gets a generic 400. Err(GatewayError::InvalidInput) } } ``` ## Rate limiting Tower middleware enforces: - 60 requests/min per client (gateway-level). - 30 messages/min per session (agent-level). This isn't security in the classical sense — it's the difference between an attacker burning through a budget in seconds and burning through it in months. ## Audit logging Authentication attempts, authorisation decisions, tool executions, configuration changes, rate-limit triggers — all logged via `tracing` with structured fields. The log is your record of "what was attempted" even when policies blocked it. ## Dependency hygiene `cargo-deny` is part of CI. We reject: - Crates with known security advisories. - Copyleft licenses (we ship MIT). - Crates with unmaintained-recently flags unless we have a reason. We prefer `rustls` over `openssl`, `aes-gcm` over hand-rolled crypto, `secrecy` over raw `String` for sensitive types. ## What you still own Zero-trust at the runtime doesn't make your tool code safe. If you register a tool that does `format!("rm {}", user_input)`, no sandbox will save you from yourself — the policy will allow the command because it came from your registered code. Validate inside your tools too. Use `serde` to parse params into typed structures. Use `validator` or `garde` to constrain fields. Never `unwrap()` on a model-supplied value. ## The structure that makes this work Defence in depth is layered, not stacked. Each layer expects the others to do their job, but degrades gracefully when they fail. If input validation slips: capability-based registration catches the missing tool. If registration slips: the sandbox catches the dangerous tool process. If the sandbox slips: secret redaction stops sensitive data leaving the process. If redaction slips: audit logs tell you what happened. That's not paranoia. That's how you build a runtime that ships AI agents to production without panic-deploying every time a new prompt-injection paper drops. --- ## Sandboxing Rust agents on Linux, macOS, and Windows URL: https://openclaw-rs.neullabs.com/blog/sandboxing-rust-agents-cross-platform Published: 2026-04-27 Tags: sandbox, security, linux, macos, windows Cluster: guide Three platform-specific sandbox backends behind one Rust API — bubblewrap, sandbox-exec, and Job Objects. Here's how openclaw-rs isolates tool execution without locking you to one OS. When an agent calls a tool, the tool *runs code on your machine*. That code came indirectly from a language model that read user input. The threat model is obvious; the defence is platform-specific isolation. openclaw-rs ships three backends behind one Rust API. ## The policy abstraction ```rust pub enum SandboxLevel { None, // No sandbox. Tools run with full process privileges. Relaxed, // Workspace-only writes, network allowed. Strict, // Workspace-only writes, no network, isolated namespace. } pub struct SandboxConfig { pub level: SandboxLevel, pub allowed_paths: Vec, pub network_access: bool, pub max_cpu_time: Option, pub max_memory: Option, } ``` This is the policy. The implementation differs per OS; the policy doesn't. ## Linux: bubblewrap [bubblewrap](https://github.com/containers/bubblewrap) (`bwrap`) is the gold-standard Linux user-namespace sandbox. It's what Flatpak uses. ```bash bwrap \ --ro-bind /usr /usr \ --ro-bind /lib /lib \ --ro-bind /lib64 /lib64 \ --ro-bind /etc /etc \ --bind ${WORKSPACE} ${WORKSPACE} \ --unshare-net \ --unshare-pid \ --die-with-parent \ --new-session \ -- ${TOOL_COMMAND} ``` `bwrap` builds a fresh mount namespace, gives the tool a read-only view of the system, a read-write view of the workspace, no network, and a private PID namespace. The tool can't see or signal anything outside that namespace. Install: `sudo apt install bubblewrap` (Debian/Ubuntu) or your distro's equivalent. ## macOS: sandbox-exec macOS ships an undocumented but stable sandbox interpreter at `/usr/bin/sandbox-exec`. The policy is a small Scheme-ish language called Seatbelt. ```scheme (version 1) (deny default) (allow process-exec) (allow signal (target self)) (allow sysctl-read) (allow mach-lookup) (allow file-read* (subpath "/usr")) (allow file-read* (subpath "/System")) (allow file-read* (subpath "/Library/Frameworks")) (allow file-read* file-write* (subpath "{WORKSPACE}")) (deny network*) ``` We render the profile from `SandboxConfig`, write it to a temp file, and invoke `sandbox-exec -f profile.sb -- ${TOOL_COMMAND}`. No external dependency; macOS ships it. ## Windows: Job Objects Windows has no namespace sandbox in the Unix sense, but it has [Job Objects](https://docs.microsoft.com/en-us/windows/win32/procthread/job-objects), which let you cap CPU + memory and prevent child processes from escaping the job. We create the job, set `JOBOBJECT_BASIC_LIMIT_INFORMATION`: - `JOB_OBJECT_LIMIT_PROCESS_TIME` — CPU time cap. - `JOB_OBJECT_LIMIT_JOB_MEMORY` — memory cap. - `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` — kill all processes if parent dies. - `JOB_OBJECT_LIMIT_BREAKAWAY_OK` disabled — child can't escape. Filesystem and network restrictions aren't free here; the Strict policy on Windows is "weaker" than on Linux/macOS, and we document that honestly in `docs/SECURITY.md`. If your threat model demands the strongest filesystem sandbox, run on Linux. ## A single Rust API ```rust let config = SandboxConfig { level: SandboxLevel::Strict, allowed_paths: vec![workspace.clone()], network_access: false, max_cpu_time: Some(Duration::from_secs(30)), max_memory: Some(512 * 1024 * 1024), }; let result = sandbox::execute(&config, &tool_command).await?; ``` `sandbox::execute` dispatches to the right backend by `cfg!(target_os = ...)`. The policy crosses platforms; the implementation differs. ## What the sandbox doesn't do Be honest about scope. The sandbox is the *last* line of defence, not the only one. - **Input validation** comes first. `openclaw-core::validation` caps message sizes (100 KB), JSON depth (32), attachment sizes (50 MB), attachment counts (10), and rejects null bytes. - **Tool registration** comes second. The agent runtime only invokes tools registered in the `ToolRegistry`. The LLM can't conjure a tool that wasn't there. - **The sandbox** is what runs the registered tool with restricted privileges. If an attacker can prompt-inject the LLM into calling a tool that does something destructive, but the sandbox stops the tool from touching anything outside the workspace, the attacker accomplished nothing. ## Picking your level | Level | Best for | What it gives up | |---|---|---| | **None** | Trusted internal tools you've reviewed | Process isolation entirely | | **Relaxed** | Tools that genuinely need network access | Network-level isolation | | **Strict** | Untrusted-looking tools, default for unknown plugins | Network access | Set the default to Strict in production. Whitelist looser policies per tool. ## Further reading - `docs/SECURITY.md` in the repo for the full threat model. - [`zero-trust-agent-runtime`](/blog/zero-trust-agent-runtime) for how the sandbox fits the broader defence-in-depth story. --- ## Event sourcing for AI agents URL: https://openclaw-rs.neullabs.com/blog/event-sourcing-for-ai-agents Published: 2026-04-20 Tags: event-sourcing, sled, crdt, architecture Cluster: cornerstone openclaw-rs stores every session as an append-only log of SessionEvent values in sled. Here's why event sourcing fits agent workloads and what the projection model looks like in Rust. There are two shapes for an agent's state: 1. **Mutable state**: the current message list, current tool stack, current memory — overwritten as the agent runs. 2. **Event-sourced state**: an append-only log of every event that ever happened; the current view is a *projection* of that log. openclaw-rs picks the second. ## Why event sourcing fits agents Agents are conversations. Conversations are sequences. A conversation is exactly the kind of thing you want to: - **Replay** for debugging. - **Audit** for compliance. - **Time-travel** to investigate "what did the agent see at step 12?" - **Branch** to A/B-test prompt changes against a real history. - **Project** into different views (chat UI, agent memory, training dataset) without coupling write paths. The mutable model lets you do *none* of these without a lot of bookkeeping. The event-sourced model gives them to you for free. ## The eight SessionEvent kinds ```rust pub enum SessionEventKind { SessionStarted { channel: ChannelId, peer_id: PeerId }, MessageReceived { content: String, attachments: Vec }, MessageSent { content: String, message_id: MessageId }, ToolCalled { tool: String, params: serde_json::Value }, ToolResult { tool: String, result: serde_json::Value, success: bool }, AgentResponse { content: String, model: String, tokens: u64 }, SessionEnded { reason: EndReason }, StateChanged { key: String, value: serde_json::Value }, } ``` Eight is small. That's the point. Anything you can do during a session falls into one of those buckets — and the bucket carries enough metadata to reconstruct *what happened* without ambiguity. ## Why sled The `EventStore` is backed by [sled](https://github.com/spacejam/sled): pure-Rust embedded ACID, lock-free reads, append-friendly. The shape matches our workload: - Writes are append-only — one new entry per event. - Reads are walks of a per-session sub-tree, in insertion order. - We never need to update an event after it's written. sled is also dependency-light, which keeps cross-compilation simple. No C dependency, no `bindgen`. ## Projections: last-write-wins CRDT semantics The session's *current state* — the rendered chat, the cumulative token count, the active tool set — is a *projection*. It's derived from the event log on demand. We project with **CRDT** semantics: every state key uses last-write-wins merge, with the event's monotonically-increasing sequence as the version. That means two projections of the same log always converge to the same state, regardless of order of arrival. It also means future clustering (multiple gateway instances in front of one event store) is safe by construction. ```rust let projection = SessionProjection::project(&events); // Read derived state let active_tools = projection.get("tools.active"); let token_count = projection.get("tokens.total"); ``` ## Time travel for free Because the log is the source of truth, "what did the agent see at step 12" is just: ```rust let snapshot = SessionProjection::project(&events[..12]); ``` That's the entire feature. This is gold for debugging weird tool behaviour: rewind to the moment before `ToolCalled`, inspect the projected state, run the tool yourself with the captured params, compare results. ## Why this matters for security There is one corollary of "every state change is an event" that's load-bearing for the security model: **you can audit a session without trusting any code path other than the event store**. Auth decisions, tool executions, secret accesses — they're all in the log, in order. If a plugin misbehaves, the log tells you what it tried to do before you decided to trust it. We come back to this in [the zero-trust runtime piece](/blog/zero-trust-agent-runtime). ## The migration story If you're coming from a mutable-state agent framework, you may be wondering: do I have to model my whole world as events from day one? No. The event log lives at the boundary between the agent runtime and the rest of your system. Your tools, your channel adapters, your dashboard — they don't need to know it exists. They just see a session API. The event store is what gives that API its replay, audit, and projection superpowers. --- ## Why we rewrote OpenClaw in Rust URL: https://openclaw-rs.neullabs.com/blog/why-rewrite-openclaw-in-rust Published: 2026-04-12 Tags: rust, performance, deployment, rewrite Cluster: cornerstone OpenClaw is a great TypeScript framework. Rewriting it in Rust gave us memory safety, sub-millisecond message routing, and a single-binary deploy story — without breaking the existing plugin contract. The original [OpenClaw](https://github.com/openclaw/openclaw) is a beautifully-designed TypeScript framework: clear primitives, sane defaults, a thoughtful plugin surface. We use it. We like it. We owe it. So why a Rust rewrite? ## The shape of the problem Agent runtimes are unusual server workloads: - Long-lived sessions, append-only event logs, lots of small allocations. - High concurrency: every session has a streaming model, sometimes a tool call running in parallel, often a channel adapter pushing events from somewhere else. - Strict security boundary requirements: untrusted tool invocations, untrusted plugin code, untrusted user input. - A burning desire to ship as a single binary so deployment is trivial. Node.js handles three of those four well. It loses on the fourth and on memory predictability under sustained load. ## What the rewrite actually buys you ### 1. Sub-millisecond message routing The hot path through `openclaw-gateway` is a JSON-RPC dispatch on top of `axum`. `tokio`'s task scheduler and `sled`'s lock-free reads keep the per-message overhead low. We're targeting < 10 ms per message in the gateway and seeing comfortably less than that in dev. ### 2. Memory safety without a GC `#![forbid(unsafe_code)]` is enforced across every workspace crate. No `unsafe`, no null pointers, no data races by construction. Sessions that hold thousands of events stay predictable under load — no GC pauses, no leaky generational nightmares. ### 3. Single static binary `cargo install openclaw-cli` and you have: - The HTTP/WebSocket gateway. - The agent runtime + sandbox backends. - The Vue 3 dashboard (embedded at build time). - The Telegram channel adapter. - The CLI surface (`onboard`, `doctor`, `status`, `config`, `daemon`). One binary. No Node runtime to ship. No `node_modules`. No Dockerfile gymnastics. ### 4. TypeScript still works This was non-negotiable: **`openclaw-plugins`** lets existing TypeScript plugins keep running. Each plugin is a process, the bridge is `nng` (an embedded message-passing transport from the nanomsg family), the wire format is JSON-RPC, and there are eight lifecycle hooks: `BeforeMessage`, `AfterMessage`, `BeforeToolCall`, `AfterToolCall`, `SessionStart`, `SessionEnd`, `AgentResponse`, `Error`. The cost of migration is "delete a `package.json`," not "rewrite your tools." ## Concretely, what does the runtime look like? ```rust use openclaw_agents::{AgentRuntime, SandboxConfig, SandboxLevel}; use openclaw_providers::AnthropicProvider; use openclaw_core::secrets::ApiKey; let provider = AnthropicProvider::new(ApiKey::new("sk-ant-...".into())); let sandbox = SandboxConfig { level: SandboxLevel::Strict, allowed_paths: vec![PathBuf::from("./workspace")], network_access: false, ..Default::default() }; let runtime = AgentRuntime::new(provider) .with_tools(tools) .with_sandbox(sandbox); let response = runtime.process_message(&mut context, "Hello!").await?; ``` The same runtime is reachable over JSON-RPC for clients, over IPC for plugins, and over napi-rs for Node code. One core, three surfaces. ## What it costs Honesty about trade-offs: - The Rust compile is slower than `tsc` — but `cargo build` is fully incremental and most edits hit a hot crate. - Rewriting the channel adapters from scratch takes time. Telegram ships; Discord, Slack, Signal, Matrix, WhatsApp are roadmap items. - The WASM plugin runtime is still under evaluation (wasmtime vs wasmer). If WASM is on your critical path right now, stay on the TypeScript OpenClaw plugin bridge. ## Who this is for - Teams that want a Rust core for production agent infrastructure. - Teams that have outgrown a Node process for performance, security, or deployment reasons. - Teams migrating from TypeScript OpenClaw who want compatibility, not a forced rewrite. It is not for greenfield prototypers who want "the fastest path to ship." Stay on TypeScript for that. Come back when you need the runtime properties Rust gives you. ## Get it running ```bash cargo install openclaw-cli openclaw onboard openclaw gateway run ``` Three commands. One binary. A real agent runtime.