This document covers OpenClaw device pairing — the auth flow used by the OpenClaw backend. The OpenAI-compatible and Hermes backends use a simpler bearer-token model documented in connections.md and connections.md.
For OpenClaw connections, lucinate authenticates using device pairing — no usernames or passwords. Each device generates a persistent Ed25519 keypair and an administrator approves it on the gateway.
OpenClaw URLs can be added via the connections picker (the default UX) or via OPENCLAW_GATEWAY_URL (auto-added on first run). See connections.md for the resolution order.
OPENCLAW_GATEWAY_URL=https://your-gateway-host
This can be set in the shell or in a .env file in the working directory. Lucinate derives the WebSocket endpoint automatically by replacing https:// with wss:// (or http:// with ws://) and appending /ws. Both values are held in internal/config/config.go as Config.GatewayURL and Config.WSURL.
On first run, identity.Store.LoadOrGenerate() creates an Ed25519 keypair under ~/.lucinate/identity/<endpoint>/, where <endpoint> is derived from the gateway URL's host and port (e.g. gateway.example.com or localhost_8789). Each gateway endpoint gets its own keypair and device token, so switching OPENCLAW_GATEWAY_URL does not overwrite existing credentials. The keypair acts as a stable device identity across restarts.
- Run
lucinate— the client connects to the gateway with the device identity but no token. The gateway respondsNOT_PAIREDand registers a pending pairing request. - The connecting view enters its
subStatePairingRequiredmodal (internal/tui/connecting.go) with on-screen instructions. On the gateway host, an administrator approves the device:openclaw device list --pending openclaw device approve <device-id> - The user presses Enter in the modal.
authResolvedMsg{}triggersretryConnect(internal/tui/app.go), which re-invokesConnecton the same backend. The gateway accepts the connection and issues a fresh device token inhello.Auth.DeviceToken, whichclient.dialpersists viastore.SaveDeviceToken. dialthen closes the bootstrap connection and re-dials with the freshly-issued token (see Re-dial after first-time pairing). The TUI advances to the agent picker — no process restart required.
The token save is non-fatal — if it fails, a warning is logged but the session continues. On subsequent runs the saved token is loaded and presented on connect.
Note: Bootstrap tokens were removed in v0.10.2. Device pairing is the only setup path.
The bootstrap connect for a freshly approved device authenticates by Ed25519 device-key signature alone — the token field on connect is empty because the client doesn't have one yet. The gateway issues the first device token in hello-ok. Reusing that bootstrap connection for scoped RPCs leaves sessions.create silently stalled; the OpenClaw protocol expects scoped operations to run on a connection that authenticated with the token at connect time.
internal/client/client.go handles this inline: when dial observes that it presented an empty token AND the gateway returned a non-empty hello.Auth.DeviceToken, it persists the token, closes the bootstrap gateway.Client, and dials a second time so the surviving connection carries the issued token. Subsequent launches (token already on disk → bootstrap presents it → gateway returns no new one) skip the second dial. Tests pin both branches in internal/client/dial_test.go.
The TUI also wraps CreateSession (internal/tui/app.go) in a per-call context.WithTimeout derived as 2 × the configured connect timeout, so any future stall in this region surfaces as a UI error in the agent picker rather than freezing the view.
- Load
OPENCLAW_GATEWAY_URLfrom environment, or use a saved connection from~/.lucinate/connections.json. - Load the Ed25519 identity and stored device token from
~/.lucinate/identity/<endpoint>/. - Open a WebSocket connection to the gateway with the identity and token. The gateway SDK (
github.com/a3tai/openclaw-go) attaches the token to all API calls. - On a successful
Hellohandshake, any refreshed token is saved back to disk; no second dial is needed because the connection already authenticated with a token. - Proceed to the agent picker.
If the token is expired or revoked the gateway rejects the connection and the connecting view opens the appropriate auth-recovery modal (see below).
When Connect fails with a recognised auth error, runConnect in internal/tui/app.go classifies it via the isNotPairedErr / isTokenMismatchErr / isTokenMissingErr / isAPIKeyErr predicates and handleConnectResult opens the matching modal sub-state in internal/tui/connecting.go:
- Not paired (
NOT_PAIRED) — pairing-required modal: instructions to runopenclaw device approveon the gateway host, then Enter to retry. See First-run pairing flow. - Token mismatch (
gateway token mismatch) — the stored device token is no longer valid for this gateway (for example, the device was removed and re-added). The modal offers three choices, each routed through theDeviceTokenAuthsub-interface on the live backend:- Clear the stored token and retry pairing (
ClearToken, default). - Reset the device identity entirely (new keypair) and retry pairing (
ResetIdentity). - Cancel back to the connections picker.
- Clear the stored token and retry pairing (
- Token missing (
gateway token missing) — text prompt for a pre-shared token; submission stores viaDeviceTokenAuth.StoreTokenand the connect is retried. - API key required (HTTP 401/403 from any
/v1request on OpenAI-compat or Hermes connections) — text prompt for an API key; submission stores viaAPIKeyAuth.StoreAPIKey.
All four flows happen inside the running TUI — modal text inputs, not stdin. Cancellation routes back to the connections picker; the active backend is closed before the next pick.
Once the TUI is running, a supervisor in internal/client/supervisor.go watches the gateway connection (Client.Done()) and reconnects automatically if it drops — for example, when the gateway is restarted.
- Backoff schedule: 1s, 2s, 4s, 8s, 15s, then 30s for every subsequent attempt. Each attempt's connect timeout comes from
prefs.ConnectTimeoutSeconds(default 15s), the same knob that floors the initial connect. - State surface: the supervisor pushes
tui.ConnStateMsginto the bubbletea program. The chat header shows⚠ disconnected,⟳ reconnecting (attempt N), or✖ auth failed. A one-line system message is added to the chat scrollback on disconnect and on recovery. - In-flight streams: if a reply was streaming when the connection dropped, the placeholder is cleared so the input is usable again. The gateway has no resume protocol for an interrupted run; the partial reply is abandoned.
- Auth failures: if the gateway rejects the device token mid-session (
gateway token mismatch/token missing), the supervisor stops retrying. The chat banner advises switching connections via/connectionsso the connecting view's auth-recovery modal can run on the chosen connection. The supervisor cannot drive the modal itself — that flow lives on the connect path, not the reconnect path.
The client connects with operator-level scopes: ScopeOperatorRead, ScopeOperatorWrite, ScopeOperatorAdmin, and ScopeOperatorApprovals. These are set in internal/client/client.go and are required for session management, exec approval, and agent administration.
| Path | Contents |
|---|---|
~/.lucinate/identity/<endpoint>/ |
Ed25519 keypair and device token (per gateway endpoint) |
~/.lucinate/secrets/secrets.json |
OpenAI-compat and Hermes API keys, keyed by connection ID (mode 0600) — see connections.md |
~/.lucinate/connections.json |
Saved connection records — see connections.md |
~/.lucinate/hermes/<conn-id>/ |
Per-connection Hermes state: last_response_id pointer + capped prompts log — see backend_hermes.md |
~/.lucinate/config.json |
UI preferences — not authentication-related |