# 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](./include/esp_openclaw_node.h) and uses the
`esp_openclaw_node_*` prefix.
The component provides:
- Device identity generation and persistence
- OpenClaw `connect.challenge` signing and `connect` request construction
- setup-code, shared-token, password, no-auth, and saved-session connect paths
- capability and command advertisement
- Handling `node.invoke.request` commands and sending `node.invoke.result` replies
## Contents
- [Requirements](#requirements)
- [Overview](#overview)
- [What The Component Handles](#what-the-component-handles)
- [What The Application Handles](#what-the-application-handles)
- [Public API](#public-api)
- [Lifecycle](#lifecycle)
- [Registration](#registration)
- [Async Control](#async-control)
- [Inspection](#inspection)
- [Usage](#usage)
- [Basic Lifecycle](#basic-lifecycle)
- [Quick Start](#quick-start)
- [Configuration And Defaults](#configuration-and-defaults)
- [Registering Capabilities And Commands](#registering-capabilities-and-commands)
- [Command Handlers](#command-handlers)
- [Connect and Session Model](#connect-and-session-model)
- [Connect Model](#connect-model)
- [Supported Connect Sources](#supported-connect-sources)
- [Setup Codes](#setup-codes)
- [Events](#events)
- [Stored State](#stored-state)
- [Reference](#reference)
- [TLS](#tls)
- [Examples and Reconnect Policy](#examples-and-reconnect-policy)
- [Component Tests](#component-tests)
## 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 `connect` request
- Advertising capabilities and commands
- Dispatching `node.invoke.request` into registered handlers
- Sending `node.invoke.result`
- Persisting the final `{ gateway_uri, device_token }` reconnect session after a
successful `hello-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_FAILED` or `DISCONNECTED`
- 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
1. Initialize NVS, `esp_netif`, and the default event loop.
2. Bring up networking, or arrange to wait for it before connecting.
3. Initialize `esp_openclaw_node_config_t` with
`esp_openclaw_node_config_init_default()`.
4. Create the node with `esp_openclaw_node_create()`.
5. Register capabilities and commands before the first accepted connect request.
6. Submit one connect request with `esp_openclaw_node_request_connect()`.
7. Wait for a terminal event before submitting the next control request.
8. Destroy the node with `esp_openclaw_node_destroy()` when finished.
### Quick Start
```c
#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_TARGET`
- `locale = "en-US"`
- `use_cert_bundle = true`
- `tls_common_name = NULL`
- `tls_cert_pem = NULL`
- `skip_cert_common_name_check = false`
### Registering Capabilities And Commands
Register everything before the first connect request.
```c
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 in `menuconfig` under
`Component config -> ESP OpenClaw Node`.
Current `menuconfig` options and defaults:
- `CONFIG_ESP_OPENCLAW_NODE_MAX_CAPABILITIES` = `16`
- `CONFIG_ESP_OPENCLAW_NODE_MAX_COMMANDS` = `32`
- `CONFIG_ESP_OPENCLAW_NODE_WORK_QUEUE_LENGTH` = `32`
- `CONFIG_ESP_OPENCLAW_NODE_TASK_STACK_SIZE` = `8192`
- `CONFIG_ESP_OPENCLAW_NODE_TRANSPORT_TASK_STACK_SIZE` = `8192`
- `CONFIG_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:
```c
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_json` is the raw UTF-8 JSON string from `payload.paramsJSON`
- when the request omits `paramsJSON`, the component passes `"{}"`
- the component always passes `out_payload_json` and initializes
`*out_payload_json` to `NULL` before calling the handler
- on success, return `ESP_OK` and either leave `*out_payload_json` as `NULL`
to send no payload or assign a UTF-8 JSON string to `*out_payload_json`
- on failure, return a non-`ESP_OK` code and populate `out_error` with a stable
error `code` and human-readable `message`
- any non-`NULL` `*out_payload_json` buffer must be `malloc()`-compatible; the
component sends it as `payloadJSON` and 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_SESSION` is valid only when a saved
reconnect session is present
Applications can check saved-session availability with
`esp_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_SESSION`
- `ESP_OPENCLAW_NODE_CONNECT_SOURCE_SETUP_CODE`
- `ESP_OPENCLAW_NODE_CONNECT_SOURCE_GATEWAY_TOKEN`
- `ESP_OPENCLAW_NODE_CONNECT_SOURCE_GATEWAY_PASSWORD`
- `ESP_OPENCLAW_NODE_CONNECT_SOURCE_NO_AUTH`
Field requirements for `esp_openclaw_node_connect_request_t`:
- `SAVED_SESSION`: `gateway_uri = NULL`, `value = NULL`
- `SETUP_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 `auth` object
- 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:
- `bootstrapToken`
- `token`
- `password`
Example decoded payload:
```json
{
"url": "ws://192.168.1.10:19001",
"bootstrapToken": "oc_bootstrap_example_token"
}
```
<details>
<summary>Pairing Flow</summary>
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.
```mermaid
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
```
</details>
### Events
The component emits these events through `esp_openclaw_node_event_cb_t`:
- `ESP_OPENCLAW_NODE_EVENT_CONNECTED`
- `ESP_OPENCLAW_NODE_EVENT_CONNECT_FAILED`
- `ESP_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_FAILED`
- `ESP_OPENCLAW_NODE_CONNECT_FAILURE_CONNECTION_LOST`
- `ESP_OPENCLAW_NODE_CONNECT_FAILURE_AUTH_REJECTED`
- `ESP_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_REQUESTED`
- `ESP_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_v`
- `session_uri`
- `session_dev_tok`
Derived at runtime from `device_seed`:
- `device_id = hex(sha256(public_key))`
- `public_key`
- `private_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
<details>
<summary>Example Wire Messages</summary>
Example `connect.challenge` from the gateway:
```json
{
"type": "event",
"event": "connect.challenge",
"payload": {
"nonce": "M2QxYjBiNDItYzJlZS00YzA3LWFkMWMtMmE4NGJmZTg4M2E5",
"ts": 1774830385123
}
}
```
Example `connect` from the node:
```json
{
"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:
```json
{ "bootstrapToken": "<bootstrap-token>" }
```
```json
{ "token": "<gateway-token>" }
```
```json
{ "password": "<gateway-password>" }
```
For explicit no-auth, the component omits the `auth` object entirely.
Successful `hello-ok` response:
```json
{
"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:
```json
{
"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:
```json
{
"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:
```json
{
"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"
}
}
}
```
</details>
### TLS
The component supports both `ws://` and `wss://`.
For `wss://`, one of these trust paths must be configured:
- set `tls_cert_pem` to a PEM trust anchor, or
- leave `use_cert_bundle = true` and build with
`CONFIG_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_pem`
- `tls_cert_len`
- `use_cert_bundle`
- `tls_common_name`
- `skip_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](../../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_FAILED` and `DISCONNECTED` outcomes
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](./test_apps/README.md).
057a96a222af816930e1dfc4d07228f08777a825
idf.py add-dependency "espressif/esp-openclaw-node^1.0.0"