19 KiB
esp-openclaw-node
esp-openclaw-node is the ESP-IDF component package in this repository for
running an ESP32 application as an OpenClaw Node over WebSocket.
The public C API is declared in
esp_openclaw_node.h and uses the
esp_openclaw_node_* prefix.
The component provides:
- Device identity generation and persistence
- OpenClaw
connect.challengesigning andconnectrequest construction - setup-code, shared-token, password, no-auth, and saved-session connect paths
- capability and command advertisement
- Handling
node.invoke.requestcommands and sendingnode.invoke.resultreplies
Contents
Requirements
- ESP-IDF
5.x - a board that can reach the OpenClaw Gateway
- application-managed network setup, such as Wi-Fi or Ethernet
nvs_flash_init(),esp_netif_init(), and the default event loop before the node is created or connected
Overview
What The Component Handles
- Generating or loading the device seed from NVS
- Deriving the Ed25519 keypair and stable
device_id - Opening one WebSocket transport at a time
- Handling
connect.challenge - Building and signing the OpenClaw
connectrequest - Advertising capabilities and commands
- Dispatching
node.invoke.requestinto registered handlers - Sending
node.invoke.result - Persisting the final
{ gateway_uri, device_token }reconnect session after a successfulhello-ok
What The Application Handles
- Wi-Fi, Ethernet, PPP, or any other route to the gateway
- Local UI, REPL, or provisioning flows
- How setup codes, gateway URIs, tokens, or passwords reach the board
- Deciding whether the next attempt should use a saved session or explicit auth
- Deciding whether and when to retry after
CONNECT_FAILEDorDISCONNECTED - Any factory reset, identity reset, or saved-session clear workflow
- The actual device-specific command handlers
Public API
Lifecycle
esp_openclaw_node_config_init_default()esp_openclaw_node_create()esp_openclaw_node_destroy()
Registration
esp_openclaw_node_register_capability()esp_openclaw_node_register_command()
Async Control
esp_openclaw_node_request_connect()esp_openclaw_node_request_disconnect()
Inspection
esp_openclaw_node_get_device_id()esp_openclaw_node_has_saved_session()
The component is driven through registration, one explicit connect request at a time, and terminal events.
Usage
Basic Lifecycle
- Initialize NVS,
esp_netif, and the default event loop. - Bring up networking, or arrange to wait for it before connecting.
- Initialize
esp_openclaw_node_config_twithesp_openclaw_node_config_init_default(). - Create the node with
esp_openclaw_node_create(). - Register capabilities and commands before the first accepted connect request.
- Submit one connect request with
esp_openclaw_node_request_connect(). - Wait for a terminal event before submitting the next control request.
- Destroy the node with
esp_openclaw_node_destroy()when finished.
Quick Start
#include <string.h>
#include "esp_openclaw_node.h"
static esp_err_t handle_device_info(
esp_openclaw_node_handle_t node,
void *context,
const char *params_json,
size_t params_len,
char **out_payload_json,
esp_openclaw_node_error_t *out_error)
{
(void)node;
(void)context;
(void)params_json;
(void)params_len;
(void)out_error;
*out_payload_json = strdup("{\"status\":\"ok\"}");
return *out_payload_json != NULL ? ESP_OK : ESP_ERR_NO_MEM;
}
static void handle_node_event(
esp_openclaw_node_handle_t node,
esp_openclaw_node_event_t event,
const void *event_data,
void *user_ctx)
{
(void)node;
(void)event_data;
(void)user_ctx;
if (event == ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED) {
/* Decide whether and when to retry in application code. */
}
}
void app_main(void)
{
esp_openclaw_node_config_t config = {0};
esp_openclaw_node_handle_t node = NULL;
const char *setup_code = "<setup-code>";
esp_openclaw_node_config_init_default(&config);
config.event_cb = handle_node_event;
ESP_ERROR_CHECK(esp_openclaw_node_create(&config, &node));
ESP_ERROR_CHECK(esp_openclaw_node_register_capability(node, "device"));
esp_openclaw_node_command_t cmd = {
.name = "device.info",
.handler = handle_device_info,
};
ESP_ERROR_CHECK(esp_openclaw_node_register_command(node, &cmd));
esp_openclaw_node_connect_request_t request = {
.source = ESP_OPENCLAW_NODE_CONNECT_SOURCE_SETUP_CODE,
.gateway_uri = NULL,
.value = setup_code,
};
ESP_ERROR_CHECK(esp_openclaw_node_request_connect(node, &request));
}
Configuration And Defaults
esp_openclaw_node_config_init_default() sets:
display_name = "OpenClaw ESP32"platform = "esp32"device_family = "ESP32"client_id = "node-host"client_mode = "node"role = "node"model_identifier = CONFIG_IDF_TARGETlocale = "en-US"use_cert_bundle = truetls_common_name = NULLtls_cert_pem = NULLskip_cert_common_name_check = false
Registering Capabilities And Commands
Register everything before the first connect request.
ESP_ERROR_CHECK(esp_openclaw_node_register_capability(node, "display"));
esp_openclaw_node_command_t cmd = {
.name = "display.show",
.handler = handle_display_show,
.context = &display_state,
};
ESP_ERROR_CHECK(esp_openclaw_node_register_command(node, &cmd));
Rules:
- capabilities are plain strings
- commands are plain strings plus a handler and optional context pointer
- Duplicate capability or command names are ignored and return
ESP_OK - Registration is allowed only when no session is active and no connect or disconnect request is in flight
- Registration and transport resource limits default to
ESP_OPENCLAW_NODE_MAX_CAPABILITIES,ESP_OPENCLAW_NODE_MAX_COMMANDS, the internal work-queue length, and the component/WebSocket task and buffer sizes. These can be tuned inmenuconfigunderComponent config -> ESP OpenClaw Node.
Current menuconfig options and defaults:
CONFIG_ESP_OPENCLAW_NODE_MAX_CAPABILITIES=16CONFIG_ESP_OPENCLAW_NODE_MAX_COMMANDS=32CONFIG_ESP_OPENCLAW_NODE_WORK_QUEUE_LENGTH=32CONFIG_ESP_OPENCLAW_NODE_TASK_STACK_SIZE=8192CONFIG_ESP_OPENCLAW_NODE_TRANSPORT_TASK_STACK_SIZE=8192CONFIG_ESP_OPENCLAW_NODE_TRANSPORT_BUFFER_SIZE=2048
The component advertises capability names and command names only. It does not currently send parameter schemas to the gateway.
Command Handlers
Handler signature:
typedef esp_err_t (*esp_openclaw_node_command_handler_t)(
esp_openclaw_node_handle_t node,
void *context,
const char *params_json,
size_t params_len,
char **out_payload_json,
esp_openclaw_node_error_t *out_error);
Handler behavior:
- handlers run synchronously on the component task
params_jsonis the raw UTF-8 JSON string frompayload.paramsJSON- when the request omits
paramsJSON, the component passes"{}" - the component always passes
out_payload_jsonand initializes*out_payload_jsontoNULLbefore calling the handler - on success, return
ESP_OKand either leave*out_payload_jsonasNULLto send no payload or assign a UTF-8 JSON string to*out_payload_json - on failure, return a non-
ESP_OKcode and populateout_errorwith a stable errorcodeand human-readablemessage - any non-
NULL*out_payload_jsonbuffer must bemalloc()-compatible; the component sends it aspayloadJSONand then frees it
Because handlers run on the component task, long-running work should be handed off to another task if it cannot complete quickly.
Connect and Session Model
Connect Model
The component performs one connection attempt at a time. It does not run an automatic reconnect loop and it does not choose between saved-session reconnect and explicit auth input on behalf of the application.
Request rules:
ESP_OPENCLAW_NODE_CONNECT_SOURCE_SAVED_SESSIONis valid only when a saved reconnect session is present Applications can check saved-session availability withesp_openclaw_node_has_saved_session()before submitting that request.- explicit connect requests are valid only when no session is active and no connect or disconnect request is in flight
esp_openclaw_node_request_disconnect()is valid only while the node is ready- once destroy begins, new async requests are rejected
For each accepted connect request, wait for exactly one terminal outcome before submitting another control request.
Supported Connect Sources
The public API exposes five caller-chosen connect sources:
ESP_OPENCLAW_NODE_CONNECT_SOURCE_SAVED_SESSIONESP_OPENCLAW_NODE_CONNECT_SOURCE_SETUP_CODEESP_OPENCLAW_NODE_CONNECT_SOURCE_GATEWAY_TOKENESP_OPENCLAW_NODE_CONNECT_SOURCE_GATEWAY_PASSWORDESP_OPENCLAW_NODE_CONNECT_SOURCE_NO_AUTH
Field requirements for esp_openclaw_node_connect_request_t:
SAVED_SESSION:gateway_uri = NULL,value = NULLSETUP_CODE:gateway_uri = NULL,value = <setup code>GATEWAY_TOKEN:gateway_uri = <ws://...|wss://...>,value = <token>GATEWAY_PASSWORD:gateway_uri = <ws://...|wss://...>,value = <password>NO_AUTH:gateway_uri = <ws://...|wss://...>,value = NULL
When a connect attempt begins, the component resolves auth material like this:
- saved session: send
auth.deviceToken - explicit gateway token: send
auth.token - explicit gateway password: send
auth.password - explicit no-auth: omit the
authobject - setup code: depends on the decoded credential field
This selection is per attempt. The component does not fall back from one auth mode to another automatically.
Setup Codes
In the current component, a setup code is base64url-encoded JSON that must contain:
url- exactly one of:
bootstrapTokentokenpassword
Example decoded payload:
{
"url": "ws://192.168.1.10:19001",
"bootstrapToken": "oc_bootstrap_example_token"
}
Pairing Flow
The usual first-pairing path is one explicit setup-code connect attempt. The
component does not stage setup-code state for a later connect call.
sequenceDiagram
participant App as ESP-IDF App
participant Node as esp_openclaw_node
participant NVS as NVS
participant GW as OpenClaw Gateway
App->>Node: esp_openclaw_node_create()
Node->>NVS: load or create device_seed
Node->>NVS: load saved reconnect session
App->>Node: register capabilities and commands
App->>Node: esp_openclaw_node_request_connect(SETUP_CODE)
Node->>Node: decode setup code
Node->>GW: open websocket to setup-code url
GW-->>Node: connect.challenge
Node->>Node: resolve auth material and sign payload
Node->>GW: connect(auth..., device signature)
alt hello-ok with auth.deviceToken
GW-->>Node: hello-ok
Node->>NVS: store {session_v, session_uri, session_dev_tok}
Node-->>App: ESP_OPENCLAW_NODE_EVENT_CONNECTED
else auth rejected or finalization fails
GW-->>Node: error or incomplete hello-ok
Node-->>App: ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED
end
GW-->>Node: node.invoke.request
Node->>App: registered handler(paramsJSON)
App-->>Node: payloadJSON or error
Node->>GW: node.invoke.result
Events
The component emits these events through esp_openclaw_node_event_cb_t:
ESP_OPENCLAW_NODE_EVENT_CONNECTEDESP_OPENCLAW_NODE_EVENT_CONNECT_FAILEDESP_OPENCLAW_NODE_EVENT_DISCONNECTED
ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED carries
esp_openclaw_node_connect_failed_event_t with:
ESP_OPENCLAW_NODE_CONNECT_FAILURE_TRANSPORT_START_FAILEDESP_OPENCLAW_NODE_CONNECT_FAILURE_CONNECTION_LOSTESP_OPENCLAW_NODE_CONNECT_FAILURE_AUTH_REJECTEDESP_OPENCLAW_NODE_CONNECT_FAILURE_SESSION_FINALIZATION_FAILED
ESP_OPENCLAW_NODE_EVENT_DISCONNECTED carries
esp_openclaw_node_disconnected_event_t with:
ESP_OPENCLAW_NODE_DISCONNECTED_REASON_REQUESTEDESP_OPENCLAW_NODE_DISCONNECTED_REASON_CONNECTION_LOST
Event callback rules:
- callbacks run on the component task
- keep callback code short and non-blocking
- callbacks may call the async request APIs
- callbacks must not call
esp_openclaw_node_destroy()
Stored State
The component stores internal state in NVS namespace openclaw.
Identity:
device_seed: 32-byte Ed25519 seed
Saved reconnect session:
session_vsession_urisession_dev_tok
Derived at runtime from device_seed:
device_id = hex(sha256(public_key))public_keyprivate_key
Persistence rules:
- setup-code bootstrap tokens are never persisted
- explicit shared gateway tokens are never persisted
- explicit gateway passwords are never persisted
- explicit no-auth selections are never persisted
- only the final
{ gateway_uri, device_token }reconnect session is persisted
Reference
Example Wire Messages
Example connect.challenge from the gateway:
{
"type": "event",
"event": "connect.challenge",
"payload": {
"nonce": "M2QxYjBiNDItYzJlZS00YzA3LWFkMWMtMmE4NGJmZTg4M2E5",
"ts": 1774830385123
}
}
Example connect from the node:
{
"type": "req",
"id": "connect-1774830385140123",
"method": "connect",
"params": {
"minProtocol": 3,
"maxProtocol": 3,
"client": {
"id": "node-host",
"displayName": "OpenClaw ESP32",
"version": "1.0.0",
"platform": "esp32",
"deviceFamily": "ESP32",
"modelIdentifier": "esp32c6",
"mode": "node"
},
"role": "node",
"scopes": [],
"caps": ["device", "wifi", "gpio"],
"commands": [
"device.info",
"device.status",
"wifi.status",
"gpio.mode",
"gpio.read",
"gpio.write"
],
"auth": {
"deviceToken": "<saved-device-token>"
},
"userAgent": "esp-openclaw-node/1.0.0",
"locale": "en-US",
"device": {
"id": "<device-id>",
"publicKey": "<base64url-public-key>",
"signature": "<base64url-signature>",
"signedAt": 1774830385123,
"nonce": "M2QxYjBiNDItYzJlZS00YzA3LWFkMWMtMmE4NGJmZTg4M2E5"
}
}
}
params.auth variants by connect source:
{ "bootstrapToken": "<bootstrap-token>" }
{ "token": "<gateway-token>" }
{ "password": "<gateway-password>" }
For explicit no-auth, the component omits the auth object entirely.
Successful hello-ok response:
{
"type": "res",
"id": "connect-1774830385140123",
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": 3,
"server": {
"version": "2026.4.9",
"connId": "<gateway-connection-id>"
},
"auth": {
"deviceToken": "<node-device-token>",
"role": "node",
"scopes": [],
"deviceTokens": [
{
"deviceToken": "<bounded-operator-token>",
"role": "operator",
"scopes": [
"operator.approvals",
"operator.read",
"operator.talk.secrets",
"operator.write"
]
}
]
}
}
}
The gateway may return the primary node reconnect token in
payload.auth.deviceToken plus additional tokens in
payload.auth.deviceTokens.
This component persists and reuses only
payload.auth.deviceToken; it ignores any extra payload.auth.deviceTokens entries.
Example node.invoke.request from the gateway:
{
"type": "event",
"event": "node.invoke.request",
"payload": {
"id": "inv_01JV0A7X9S1ZQY7V5NXJYQ5V8K",
"nodeId": "<node-id>",
"command": "display.show",
"paramsJSON": "{\"heading\":\"OpenClaw\",\"text\":\"Hello from the gateway.\"}"
}
}
Successful node.invoke.result from the node:
{
"type": "req",
"id": "esp32-1774830401987123",
"method": "node.invoke.result",
"params": {
"id": "inv_01JV0A7X9S1ZQY7V5NXJYQ5V8K",
"nodeId": "<node-id>",
"ok": true,
"payloadJSON": "{\"heading\":\"OpenClaw\",\"text\":\"Hello from the gateway.\",\"renderCount\":1}"
}
}
Error node.invoke.result from the node:
{
"type": "req",
"id": "esp32-1774830402987001",
"method": "node.invoke.result",
"params": {
"id": "inv_01JV0AA4J2QJ4V4X1R7W43G6CE",
"nodeId": "<node-id>",
"ok": false,
"error": {
"code": "INVALID_PARAMS",
"message": "display params must include string heading and text fields"
}
}
}
TLS
The component supports both ws:// and wss://.
For wss://, one of these trust paths must be configured:
- set
tls_cert_pemto a PEM trust anchor, or - leave
use_cert_bundle = trueand build withCONFIG_MBEDTLS_CERTIFICATE_BUNDLE
If neither is true, wss:// connect requests are rejected before transport
startup.
Useful fields in esp_openclaw_node_config_t:
tls_cert_pemtls_cert_lenuse_cert_bundletls_common_nameskip_cert_common_name_check
skip_cert_common_name_check is available for local development, but should
stay disabled for production-like deployments.
Examples and Reconnect Policy
The component itself does not implement automatic reconnect policy.
This repository's examples provide that behavior outside the component in examples/common/esp_openclaw_node_example_saved_session_reconnect.c. That helper:
- waits for Wi-Fi to be online
- checks
esp_openclaw_node_has_saved_session() - retries only the saved-session path
- retries after retryable
CONNECT_FAILEDandDISCONNECTEDoutcomes
The application keeps network policy and retry policy, while the component focuses on node identity, protocol, and one attempt
at a time.
Component Tests
Component tests live under
components/esp-openclaw-node/test_apps.