This is the protocol a device speaks to connect to Sentient: smart glasses firmware (ESP32-S3 class), a phone
app, a watch, or the built-in web app at /node/. It is plain JSON over one WebSocket plus the voice socket.
The REST API the desktop app uses is in docs/API.md section 13.
A working client in Python lives in sentient/nodes/reference.py (sentient node --help); the browser client
is sentient/nodes/web/app.js.
| Where | Address | Who |
|---|---|---|
| LAN listener (when Settings › Devices › Accept devices on your network is on) | wss://<lan-ip>:7778/ws/node (port = nodes.lan_port) |
glasses, phones on the same network |
| Loopback gateway | ws://127.0.0.1:<gateway port>/ws/node |
the desktop app, developer tools on the same PC |
The LAN listener serves only: WS /ws/node, WS /ws/voice, GET /node/ (web app) and
POST /api/nodes/upload. Nothing else of the engine is reachable from the network.
Sentient announces _sentient._tcp.local. (instance Sentient on <hostname>), IPv4 A records, and TXT:
| key | example | meaning |
|---|---|---|
version |
3.0.0a0 |
engine version |
protocol |
1 |
this document's version |
fingerprint |
3f9a… (64 hex) |
SHA-256 of the TLS certificate (see §2) |
port |
7778 |
same as the SRV port |
path |
/ws/node |
node socket path |
tls |
1 |
always TLS on the LAN |
ESP-IDF: mdns_query_ptr("_sentient", "_tcp", 3000, 5, &results). Arduino: MDNS.queryService("sentient", "tcp").
If discovery fails, let the user enter the address, or take it from the pairing QR link (§3).
The LAN listener uses a self-signed certificate (ECDSA P-256, stored in ~/.sentient/tls/, reused for years so the
fingerprint stays stable). Devices must not use hostname or CA verification. Instead:
- Complete the TLS handshake with verification disabled.
- Compute SHA-256 over the server certificate in DER form. Compare it with the expected fingerprint
(64 lowercase hex characters; compare case-insensitively and ignore
:separators). - If it does not match, close the connection before sending anything and tell the user.
Where the expected fingerprint comes from, in order: the value you stored after the first successful pairing;
the fp parameter of the pairing link; the TXT record (trust-on-first-use; weakest). The desktop shows it
next to the pairing code (GET /api/nodes/lan → fingerprint).
mbedTLS: set MBEDTLS_SSL_VERIFY_OPTIONAL, then after mbedtls_ssl_handshake take
mbedtls_ssl_get_peer_cert(&ssl)->raw and hash it with mbedtls_sha256.
- The user opens Devices › Pair a device in Sentient. The desktop calls
POST /api/nodes/pairingand shows:- a 6-digit code (valid 10 minutes, single use),
- a QR code of
web_url(https://<ip>:7778/node/#code=123456) that a phone camera opens directly, - the link
sentient://pair?url=wss%3A%2F%2F192.168.1.20%3A7778%2Fws%2Fnode&code=123456&fp=<fingerprint>for apps and firmware that can scan (glasses can read this QR with their camera).
- The device connects and sends
hellowithpair_code. - The engine answers
welcomewith atoken. Store it (NVS / keychain / localStorage) together with the URL and fingerprint. Every later connection sendstokeninstead ofpair_code.
Wrong codes are rate-limited: 5 failures per minute per client address, and 30 failures in 10 minutes cancel every outstanding code. A device removed in Sentient has its token revoked.
Every message is one JSON text frame with a type. Unknown fields must be ignored (the engine may add fields).
Binary frames are only used as described in §4.4.
The first message after connecting, within 15 seconds.
{"type": "hello", "protocol": 1, "name": "Maya's glasses", "kind": "glasses",
"platform": "esp32-s3", "app_version": "0.4.1",
"capabilities": ["camera.photo", "display.text", "speak", "audio.play", "button.events", "battery"],
"token": "…", "state": {"battery": 81, "charging": false, "worn": true}}kind:phone|glasses|watch|custom(desktopis reserved for the desktop app).- Exactly one of
token/pair_code(apair_codeis used if the token is rejected). stateis optional (same fields as §4.3).
{"type": "welcome", "protocol": 1, "node_id": "9c1e0f3a2b4d6e71", "name": "Maya's glasses",
"assistant": "Sentient", "server_version": "3.0.0a0", "keepalive_s": 60, "idle_timeout_s": 180,
"stopped": false, "token": "only-on-first-pairing"}stopped is true while the user has pressed Stop everything (§4.5).
The desktop app itself connects on the loopback gateway with ?token=<gateway token>; it becomes node desktop
without pairing. The gateway token is refused on the LAN listener.
The engine asks the device to do something.
{"type": "invoke", "id": "a1b2c3d4e5f6", "capability": "display.text", "params": {"text": "Turn left in 50 m"}, "timeout_ms": 20000}Answer once, with the same id, before timeout_ms (late answers are dropped):
{"type": "result", "id": "a1b2c3d4e5f6", "ok": true, "data": {"shown": true}}
{"type": "result", "id": "a1b2c3d4e5f6", "ok": false, "error": {"code": "permission_denied", "message": "Camera permission was denied."}}error may also be a plain string. Invokes can overlap; answer each independently.
{"type": "event", "event": "button", "data": {"action": "press"}}
{"type": "event", "event": "wake", "data": {"phrase": "hey sentient"}}
{"type": "state", "battery": 64, "charging": false, "worn": true}
{"type": "state", "mic": true, "mic_for": "Recording a voice memo", "camera": false, "camera_off": false}
{"type": "ping", "ts": 12345} → {"type": "pong", "ts": 12345}eventnames:button,wake,gesture,battery(data: {level, charging}),presence(data: {worn}),notification_action(data: {id, action}); any[a-z0-9_.-]{1,40}name is accepted and published to the desktop asnode.event.state:battery0..100,chargingandwornbooleans; send when they change.- Microphone and camera (#170,
docs/API.mdsection 24): Sentient shows an indicator whenever a microphone or camera is live. It already knows about audio on/ws/voiceand its owncamera.photorequests. Report anything else your device turns on withmic/camera(booleans) and an optional shortmic_for/camera_for("Recording a voice memo"), and sendfalsewhen it goes off.camera_off: truesays the camera is switched off on the device itself (camera-off mode, section 11.7): Sentient then never asks for a photo and shows "Camera switched off". Only the device can end it (camera_off: false). - Sending
helloagain on an open connection updates the capability list.
Three equivalent ways to return camera.photo / screen.capture data, pick what suits the device:
- Inline base64:
"data": {"mime": "image/jpeg", "base64": "…"}(simple; ~33% bigger; phones and PCs). - Binary follow frame (best for microcontrollers): send
{"type": "result", "id": "…", "ok": true, "data": {"mime": "image/jpeg", "binary": true, "width": 1280, "height": 720}}and immediately afterwards one binary frame with the raw JPEG bytes. - HTTP upload:
POST /api/nodes/uploadwithAuthorization: Bearer <node token>,Content-Type: image/jpegand the raw bytes as the body (or multipart fieldfile), max 20 MB →{"upload_id", "mime", "size"}; then answer{"ok": true, "data": {"mime": "image/jpeg", "upload_id": "…"}}. Uploads are single use and expire after an hour.
WebSocket frames up to 32 MB are accepted on the LAN listener.
The engine sends payloads the same way: an invoke whose params contain "binary": true and "bytes": <length> is
followed by exactly one binary frame with that payload. This is how audio.pcm delivers speech (§5), so a
microcontroller never has to hold a base64 string of a whole sentence in RAM.
A paired device can stop all of Sentient's work at once, and resume it. The engine handles this itself, never through the model.
{"type": "stop_all"} → {"type": "stop_state", "stopped": true, "stopped_at": "2026-10-09T10:00:00+00:00", "source": "device"}
{"type": "resume"} → {"type": "stop_state", "stopped": false, "stopped_at": null, "source": "device"}stop_state is also sent to every connected device when the user stops or resumes anywhere else (desktop, tray,
Telegram, Discord). A phone app can show a Stop or Resume button from it; other devices may ignore it.
When the user presses Turn off on the indicator for something the device reported, the engine sends
{"type": "stop_sensor", "sensor": "mic"}Turn it off and report {"type": "state", "mic": false}; until then it stays on the indicator. A device that reports
camera: true while Sentient's photo request runs stays shown after Turn off until it reports camera: false, and
should drop the photo instead of sending it (the reference node and the glasses firmware do). Audio on /ws/voice is
stopped by the engine itself: the voice socket gets {"type": "error", "code": "mic_stopped", ...} and closes. A photo
being taken ends with code: "stopped" and is never saved. The desktop app alone also receives sensors messages
(the full list, for its tray); other devices never do.
| capability | params | data |
|---|---|---|
camera.photo |
facing?: "back"|"front", max_width? |
{mime, base64} (or §4.4), width?, height? |
screen.capture |
{mime, base64} |
|
location.get |
timeout_ms? |
{lat, lon, accuracy_m, label?} |
notify.show |
title?, text, tag? |
{shown} |
display.text |
text, duration_ms? |
{shown} |
display.card |
title?, text, image_base64?, image_mime?, duration_ms? |
{shown} |
audio.play |
mime: "audio/wav", base64, text? (what is said) |
{playing} |
audio.pcm |
format: "pcm16", sample_rate, channels, text?, binary: true, bytes + one binary frame |
{playing} |
speak |
text, lang? |
{spoken} (on-device TTS) |
mic.stream |
advertises that the device talks over /ws/voice (§6) |
|
clipboard.read / clipboard.write |
text (write) |
{text} (read) |
button.events |
advertises that the device sends button events |
|
battery |
{battery, charging} |
audio.play WAVs are PCM16 mono with a 44-byte header; a speaker driver can skip the header and play the samples at
the rate in the header (usually 24 kHz or 16 kHz). For speech the engine picks, in order, speak (on-device TTS),
audio.pcm (raw frame, best for microcontrollers) and audio.play (base64 WAV).
camera.photo (binary follow frame, JPEG ≤ 1280 px), display.text (and display.card with a screen),
audio.pcm, mic.stream, button.events, battery. Skip on-device speak unless the chip has TTS: the engine
speaks better. A tiny display should show the last display.text until the next one or duration_ms.
Agent tools use these capabilities: device_take_photo, device_capture_screen, device_get_location,
device_notify, device_display, device_speak. Taking a photo or a screenshot asks the user for approval unless
nodes.camera_requires_approval is off.
Open wss://<host>:7778/ws/voice?node_token=<token> (same TLS pinning) and follow docs/API.md section 9:
{"type": "start", "channel": "glasses", "sample_rate": 16000, "audio_format": "pcm16", "mode": "conversation"}- Microphone: binary frames of PCM16 little-endian mono at
sample_rate(16000 recommended), 20-100 ms per frame. On button release send{"type": "end_utterance"}; without a button the server's voice activity detection decides. - Replies: each
{"type": "audio", "format": "wav"|"pcm16", "sentence_index", "text"}header is followed by exactly one binary frame with that sentence's audio. Withaudio_format: "pcm16"the frame is raw PCM16 mono (no header) at thesample_rateannounced inready.audio_endcloses the turn. - Barge-in:
{"type": "interrupt"}when the user presses the button while the assistant speaks. - Hands-free:
mode: "wake"listens for the wake word first (section 16). - Keep the voice socket closed while idle to save power; open it on button press (it is ready within one round trip).
{"type": "error", "code": "…", "message": "…"}; for authentication errors the engine closes right after.
| code | close | what to do |
|---|---|---|
pairing_required |
4401 | show "pair this device" |
bad_code |
4401 | wrong or expired code: ask for a new code |
bad_token |
4401 | token unknown: delete it, pair again |
revoked |
4401 | removed in Sentient: delete the token, pair again |
rate_limited |
4429 | wait at least 60 s before trying a code again |
disabled |
4403 | devices are off in Sentient: retry every few minutes |
protocol |
4400 (on hello) | bug in the device: first message must be hello; later protocol errors do not close |
replaced |
4409 | the same device connected again elsewhere: do not reconnect automatically |
unknown_type |
none | ignore |
Invoke failures seen by the engine (POST /api/nodes/{id}/invoke) add: offline, unsupported, timeout,
not_allowed, bad_upload, camera_off (the device reported camera-off mode, so the photo was never requested),
stopped (the user turned the camera off mid photo), plus whatever error.code the device returned. A device in
camera-off mode should itself answer a camera.photo with error.code camera_off too.
- Reconnect with exponential backoff: 1 s, 2 s, 4 s … capped at 30 s, plus 0-50% random jitter. Reset after a
welcome. Do not reconnect afterbad_code,rate_limited(wait),replaced,bad_token/revoked(re-pair). - The engine never pings devices (no WebSocket protocol pings on the LAN listener), so the radio can sleep. The device
sends
{"type": "ping"}everykeepalive_s(default 60 s). A device silent foridle_timeout_s(default 180 s) is considered gone and its socket is closed. - Any message counts as activity; a device sending
stateregularly does not need extra pings. - Wi-Fi modem sleep between pings is fine; keep TCP keepalive off.
→ {"type":"hello","protocol":1,"name":"Glasses","kind":"glasses","platform":"esp32-s3","app_version":"0.1",
"capabilities":["camera.photo","display.text","audio.play","mic.stream","button.events","battery"],"pair_code":"482913"}
← {"type":"welcome","protocol":1,"node_id":"9c1e0f3a2b4d6e71","name":"Glasses","assistant":"Sentient",
"server_version":"3.0.0a0","keepalive_s":60,"idle_timeout_s":180,"token":"Qm9…"}
→ {"type":"state","battery":92,"charging":false,"worn":true}
← {"type":"invoke","id":"5e2f7a90c1d3","capability":"camera.photo","params":{"facing":"back"},"timeout_ms":20000}
→ {"type":"result","id":"5e2f7a90c1d3","ok":true,"data":{"mime":"image/jpeg","binary":true,"width":1280,"height":720}}
→ <binary: 184 213 bytes of JPEG>
← {"type":"invoke","id":"77ab01c2d3e4","capability":"display.text","params":{"text":"That is a Monstera plant."},"timeout_ms":20000}
→ {"type":"result","id":"77ab01c2d3e4","ok":true,"data":{"shown":true}}
→ {"type":"ping"}
← {"type":"pong","ts":null}
→ {"type":"hello","protocol":1,"name":"Glasses","kind":"glasses","capabilities":[…],"token":"Qm9…"}
← {"type":"error","code":"bad_token","message":"This device is no longer paired. Pair it again with a new code."}
← <close 4401>
→ {"type":"start","channel":"glasses","sample_rate":16000,"audio_format":"pcm16"}
← {"type":"ready","session_id":"…","stt":"faster_whisper","tts":"kokoro","sample_rate":16000}
← {"type":"state","state":"listening","session_id":"…"}
→ <binary PCM16 frames while the button is held>
→ {"type":"end_utterance"}
← {"type":"state","state":"transcribing",…} {"type":"transcript","text":"what's the weather","final":true,…}
← {"type":"state","state":"thinking",…} {"type":"text_delta","text":"It is 24 degrees"…}
← {"type":"audio","format":"pcm16","sentence_index":0,"text":"It is 24 degrees and sunny."} → <binary PCM16>
← {"type":"audio_end",…} {"type":"state","state":"listening",…}
- Web app: open
http://127.0.0.1:<gateway port>/node/on the PC (camera and microphone work on localhost) or thehttps://<lan-ip>:7778/node/link on a phone (accept the certificate warning once). - Reference node:
sentient node --url "sentient://pair?url=…&code=…&fp=…"orsentient node --url wss://192.168.1.20:7778/ws/node --code 123456 --fingerprint <fp> --camera 0. Press Enter to send a button press, typeb 40to report 40% battery,qto quit.--insecureskips pinning (dev only).--camera-offstarts in camera-off mode (it offerscamera.photo, reportscamera_off: trueand refuses photos without opening a webcam); typecto switch it, as the glasses' button would. While a photo is taken it reportscamera: true.
The first Sentient glasses: ESP32-S3 (8 MB PSRAM, Wi-Fi), INMP441 I2S microphone, MAX98357A I2S
amplifier, OV3660 camera, one button. Firmware: firmware/esp32s3-glasses/ (ESP-IDF). Pin numbers below are
#defines in main/app_config.h; change them to match the board, they are not fixed by the protocol.
Advertise: camera.photo, display.text, notify.show, audio.pcm, mic.stream, button.events, battery.
| setting | value | why |
|---|---|---|
| peripheral | I2S_NUM_0, master, RX only |
leaves I2S_NUM_1 for the amplifier, so capture and playback run at the same time |
| standard | Philips, I2S_SLOT_MODE_MONO, slot_mask = I2S_STD_SLOT_LEFT |
INMP441 with L/R tied to GND drives the left slot |
| data width | I2S_DATA_BIT_WIDTH_32BIT on the wire |
the INMP441 sends 24 bits MSB-aligned inside a 32-bit slot |
| sample rate | 16000 | what /ws/voice expects; no resampling on the device |
| conversion | int16 = clamp(raw32 >> MIC_GAIN_SHIFT), default shift 14 |
>> 16 would be correct 16-bit but very quiet; 14 adds ~12 dB. Tune MIC_GAIN_SHIFT (12 loud, 16 quiet) |
| DMA | 4 buffers × 240 frames | ~60 ms of slack; the reader task wakes every ~15 ms |
| pins (defaults) | SCK=GPIO4, WS=GPIO5, SD=GPIO6 |
any free GPIO; keep them away from the camera's data bus |
Send microphone audio as PCM16 little-endian mono at 16 kHz in binary frames of 640 samples (1280 bytes, 40 ms). That is the exact format §6 asks for, so no conversion happens anywhere.
| setting | value |
|---|---|
| peripheral | I2S_NUM_1, master, TX only |
| format | Philips, I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO (the same sample goes to both slots) |
| sample rate | whatever the engine announces: 16000 from /ws/voice with audio_format: "pcm16", or the sample_rate in audio.pcm params (often 24000) |
| pins (defaults) | BCLK=GPIO15, LRCLK/WS=GPIO16, DIN=GPIO7; SD pulled high (or to a GPIO to mute), GAIN left floating for 9 dB |
| rate changes | i2s_channel_disable() → i2s_channel_reconfig_std_clock() → i2s_channel_enable() before the first frame of a reply |
Two ways audio arrives, both raw PCM16 that goes straight to I2S with no decoding:
- Voice replies:
/ws/voicestartwithaudio_format: "pcm16"sends an{"type":"audio",…}header followed by one binary frame per sentence, at thesample_ratefromready. audio.pcminvokes (whatdevice_speakuses for this board):paramscarryformat,sample_rate,channels,binary: trueandbytes, and the samples follow in one binary frame.
A sentence is roughly 32 KB/s at 16 kHz, so stream the websocket chunks into a ring buffer and start playing at the first chunk instead of waiting for the whole frame. Never buffer a whole reply in DRAM.
pixel_format = PIXFORMAT_JPEG frame_size = FRAMESIZE_VGA (640x480) jpeg_quality = 12 (0 best … 63 worst)
fb_count = 2 fb_location = CAMERA_FB_IN_PSRAM grab_mode = CAMERA_GRAB_LATEST
xclk_freq_hz = 20 MHz OV3660 extras: set_vflip(1), set_brightness(1), set_saturation(-2)
VGA at quality 12 gives a 30-60 KB JPEG, which decides how to return it:
| way | cost on this board | verdict |
|---|---|---|
| binary follow frame (§4.4 #2) | sends straight from the PSRAM frame buffer, no copy, no extra RAM | use this |
POST /api/nodes/upload (§4.4 #3) |
a second TLS session, ~40 KB heap during the upload | fallback when the socket is busy with audio, or for photos over ~200 KB |
| inline base64 (§4.4 #1) | +33% bytes and a 40-80 KB string built in RAM on top of the frame buffer | avoid |
Always esp_camera_fb_return() the buffer immediately after the send, and keep only one capture in flight.
The camera is powered only for a photo (section 11.7): power on, esp_camera_init(), drop CAM_WARMUP_FRAMES dark
frames, grab one JPEG, send it, esp_camera_deinit(), power off. That adds roughly 0.3 to 0.6 s to each photo
(estimated, not measured on hardware), and the firmware reports camera: true / false around it.
An mbedTLS session costs roughly 35-45 KB of heap with default buffers. Two sockets (node + voice) means two sessions. What keeps it comfortable:
CONFIG_MBEDTLS_DYNAMIC_BUFFER=yfrees handshake buffers afterwards (saves ~20 KB per session).CONFIG_MBEDTLS_SSL_IN_CONTENT_LEN=4096andOUT_CONTENT_LEN=2048: our frames are small, and the engine never sends a TLS record larger than this to a device.- The engine's certificate is ECDSA P-256, so the handshake needs far less RAM and CPU than RSA-2048.
- If RAM is still tight: open the voice socket only while the button is held and close it after the reply, keep
buffer_sizeat 2048, put Wi-Fi/LWIP buffers in PSRAM (CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=y), and drop the camera toFRAMESIZE_QVGAwhile talking.
Pinning (§2) in two steps, because esp_websocket_client does not expose the peer certificate:
- Once per engine:
esp_tls_conn_new_sync()with no CA (verification off), thenmbedtls_ssl_get_peer_cert()→ SHA-256 ofcrt->raw→ compare with the expected fingerprint. The user reads that fingerprint off the Devices screen in Sentient (GET /api/nodes/lan→fingerprint), or it comes from the mDNS TXT record or the pairing QR'sfp=. - Store that certificate as PEM in NVS and pass it as
cert_pemtoesp_websocket_clientwithskip_cert_common_name_check = truefor every later connection. mbedTLS then verifies the real connection against exactly that certificate, and the check costs nothing at runtime.
Redo step 1 only if the connection starts failing with a certificate error (Sentient was reset): treat it as "pair again".
- Wi-Fi: reconnect on
WIFI_EVENT_STA_DISCONNECTEDwith 1, 2, 4 … 30 s backoff plus jitter, the same curve as §8. Re-run mDNS discovery after every join: the engine's address changes with DHCP, the fingerprint does not. - Keepalive: use the
keepalive_sfromwelcome(default 60) for{"type":"ping"}on the node socket. Do not enable the websocket library's own protocol pings: the engine does not need them and they wake the radio twice. - Modem sleep:
esp_wifi_set_ps(WIFI_PS_MAX_MODEM)is safe with a 60 s ping and cuts idle current a lot. - Deep sleep drops the sockets, so use it only for long idles: wake on the button with
esp_sleep_enable_ext1_wakeup(), then reconnect with the token from NVS (no pairing needed) and send the button event once connected. Budget ~2 s from wake towelcomeon a known network. - Battery: read the divider with ADC1 oneshot + calibration, send
{"type":"state","battery":N,"charging":false}on connect, every 5 minutes, and whenever it moves 2% or more. Report percent, not volts.
One GPIO with an internal pull-up, 30 ms debounce, polled in a small task:
- Press: send
{"type":"event","event":"button","data":{"action":"press"}}on the node socket, open/ws/voice?node_token=…(already pinned, so it is one round trip), sendstart, and begin streaming microphone frames. - Release: send
{"type":"end_utterance"}, stop streaming, keep the socket open for the reply, then close it afterVOICE_IDLE_CLOSE_MS(default 20 s) of silence. - Press while the assistant is speaking: send
{"type":"interrupt"}and stop playback immediately (barge-in). - A long press (2 s) can send
{"event":"button","data":{"action":"long_press"}}; the engine just forwards it asnode.event.
Two guarantees that do not depend on the engine (or on the firmware being bug free, for the first):
- The LED follows the camera's power. The camera module is fed through a load switch whose enable pin is
CAM_POWER_GPIO. The privacy LED and its resistor sit on the switched side of that load switch, not on a GPIO of its own, so it is lit exactly while the camera has power. The firmware powers the camera only while a photo is taken (section 11.3), so the LED blinks once per photo, and once at power on while the camera is checked. WithCAM_PIN_PWDNwired the sensor is also held in power-down between photos. - Camera-off mode. Within
CAMERA_OFF_WINDOW_MS(5 s) of switching the glasses on, press and hold the talk button forCAMERA_OFF_TOGGLE_HOLD_MS(3 s) to turn camera-off mode on or off (after the switch-on, because the default button pin, GPIO0, is a strapping pin). It is kept in NVS (camera_off). While it is on the camera is never powered, everycamera.photois refused witherror.codecamera_off, andhello.state.camera_offistrue, so Sentient shows "Camera switched off" and never asks for a photo. No message from the engine changes it: only the button does.CAMERA_HARD_OFF 1builds firmware that never powers the camera and does not offercamera.photoat all.
Neither has run on hardware yet (see the firmware README).