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:
{ "jsonrpc": "2.0", "method": "session.message", "params": {...}, "id": 7 }
A response is:
{ "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
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<RwLock<…>>. 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:
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.
async fn auth_middleware(
State(state): State<AppState>,
headers: HeaderMap,
req: Request<Body>,
next: Next,
) -> Result<Response, AuthError> {
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>:
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:
- Receives JSON-RPC requests from the client.
- Spawns async tasks per request.
- Forwards server-initiated notifications (token deltas, session events) from a broadcast channel.
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 /.
#[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:
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.