OS Server (Go, Gin framework) runs on port 5000.
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/health/live |
Liveness probe |
| GET | /api/health/readiness |
Readiness probe (agent gateway connected?) |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/system/info |
CPU, RAM, temp, uptime, version, agent status (name/connected/emotion/version/uptime) |
| GET | /api/system/network |
WiFi SSID, IP, signal, internet status |
| GET | /api/system/dashboard |
Aggregated snapshot (agent + config + HW) |
| GET | /api/system/ota-security |
OTA trust posture from the bootstrap worker: legacy vs verified, pinned key fingerprint, last metadata fetch (see bootstrap-ota.md) |
| POST | /api/system/reboot |
Admin-gated: acknowledge, then ask HAL to announce and reboot the OS |
| POST | /api/system/shutdown |
Admin-gated: acknowledge, then ask HAL to announce, release servos, and shut down the OS |
The power endpoints return 202 Accepted before scheduling their HAL call, so
the browser can receive the acknowledgement before the device becomes
unreachable. Only one reboot or shutdown can be pending at a time; a second
request receives 409 Conflict. HAL owns the physical sequence: reboot plays
the reboot cue, while shutdown plays its cue and releases servos before issuing
the OS power command.
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/device/setup |
Configure WiFi + LLM + channel + MQTT (async, returns immediately) |
| POST | /api/device/channel |
Change messaging channel |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/device/timezone |
Active IANA zone + selectable zone list (admin-gated) |
| POST | /api/device/timezone |
Apply an IANA zone (admin-gated) |
GET response (data):
{
"current": "Asia/Ho_Chi_Minh",
"zones": ["UTC", "Asia/Ho_Chi_Minh", "..."]
}currentis read live from/etc/timezone, falling back to resolving the/etc/localtimesymlink, then thetimezonefield inconfig/config.json.zonescomes fromtimedatectl list-timezones, falling back to a walk of/usr/share/zoneinfo, then a built-in common list.
POST request body:
{ "timezone": "Asia/Ho_Chi_Minh" }The zone is validated against /usr/share/zoneinfo; an unknown zone returns HTTP 400. On success the server: repoints the /etc/localtime symlink at the zone's tzdata file, writes /etc/timezone (Debian-style, trailing newline), runs timedatectl set-timezone <tz> best-effort (non-fatal if absent), and persists timezone to config/config.json.
The change takes effect without a HAL restart — HAL's clock helpers (hal/clock.py) read /etc/timezone fresh on every call.
Config field: timezone in config/config.json (IANA zone string, omitempty) — a record of the applied zone. The OS files (/etc/timezone + /etc/localtime) are the source of truth.
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/network |
Scan WiFi networks |
| GET | /api/network/current |
Current SSID + IP |
| GET | /api/network/check-internet |
Check internet connectivity |
Connectivity monitor (system/network/service.go, started once
SetUpCompleted flips true). Pings 8.8.8.8 every 5s — interface-agnostic, so a
device online over ethernet is seen as online. After 5 consecutive failures it
raises the Connectivity LED state; after 10 (~50s) it escalates to a WiFi
reconnect (restart wpa_supplicant@wlan0, bounce the interface), and after 5
failed reconnects (~10 min) it reboots the device.
That escalation is a WiFi recovery path, so it is skipped when WiFi is not the
link in question — otherwise a wired device would reboot itself every ~10 minutes
for the length of an upstream outage it plays no part in. It is skipped when
either: no SSID is on file (the device was provisioned over ethernet — see
setupWired in docs/setup-flow.md), or the default route belongs to another
interface (traffic is leaving over the cable). A genuinely dropped WiFi link
leaves no default route and PrimaryInterface() falls back to wlan0, so the
outage the escalation exists for still passes the guard.
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/guard/enable |
Enable guard mode |
| POST | /api/guard/disable |
Disable guard mode |
| GET | /api/guard |
Check guard mode status (returns {"guard_mode": true/false}) |
| POST | /api/guard/alert |
Manually broadcast alert to all OpenClaw chat sessions |
All guard endpoints require administrator authentication for network callers. Device-local callers on strict loopback, including HAL and the agent runtime, are allowed so internal guard-mode operation remains available.
Alert request body:
{
"message": "Intruder detected in living room",
"images": ["<base64 JPEG>", "…"] // optional, one entry per attached photo
}When guard mode is ON, presence.enter and motion sensing events are additionally broadcast to ALL OpenClaw chat sessions (Telegram DMs + groups) via chat.send RPC. Normal sensing flow (emotion, servo, TTS) continues unchanged.
Config field: guard_mode in config/config.json (bool, default false). The OpenClaw agent can also toggle guard mode via the guard skill.
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/sensing/event |
Receive sensing event from HAL |
| POST | /api/mood/log |
Log user mood (called by agent via Mood skill) |
| POST | /api/monitor/event |
Push an event directly to the monitor bus (used by HAL for sound tracker state) |
Note: Stranger visit tracking (stats, persistence) is handled by HAL (port 5001) at
GET /face/stranger-stats. See sensing-behavior.md for details.
Request body:
{
"type": "voice_command|voice_followup|voice|web_chat|mqtt_chat|motion|sound|presence.enter|presence.leave|presence.away|light.level|motion.activity",
"message": "...",
"images": ["<base64 JPEG>", "…"] // optional, one entry per attached photo
}Event types:
| Type | Source | Has image? | Description |
|---|---|---|---|
voice_command / voice_followup / voice |
Mic (Deepgram STT) | No | voice_command is wake-word confirmed; voice_followup is authorized by the short wake-word focus window; voice is ambient STT |
web_chat |
Web Monitor /chat UI |
Yes (file/clipboard attach) | Typed message from web monitor — TTS suppressed (reply rendered in UI), no physical wake, no opening filler |
mqtt_chat |
MQTT kind:"chat.send" (phone app) |
Yes (image + file) | Same handling as web_chat in every gate (sensingmsg.IsChat); separate type only so the Flow Monitor badge shows the origin. speak:true forwards as voice instead |
motion |
Camera (frame diff) | Yes (large motion) | Motion detected |
presence.enter |
Camera (InsightFace recognition) | Yes (bbox-annotated JPEG) | Face detected — friend or stranger classified |
presence.leave |
Camera (3 consecutive ticks without face) | No | Person left |
light.level |
Camera (mean brightness) | No | Significant ambient light change (>30/255) |
sound |
Mic (RMS energy) | No | Loud noise |
presence.away |
PresenceService (15 min no motion) | No | No one around for 15+ min — device going to sleep |
motion.activity |
MotionPerception (while PRESENT) | No | Activity detected while user is present — emotional actions logged via Mood skill |
Processing flow:
voice_command,voice_followup, orvoice+ local intent enabled → match intent → execute directly (~50ms).voice_followuphas the same user priority asvoice_command;web_chat/mqtt_chatskip local intent (typed text ≠ wake-word voice).- Ambient turn floor:
motion.activity,emotion.detected,speech_emotion.detected,sound,presence.away,light.levelare dropped when the last agent turn created by this handler (any type) was less thansensing_turn_floor_sseconds ago (config key, default120,0disables; guard mode bypasses). One cross-type floor on top of HAL's independent per-type gates — a burst of different event types costs at most one agent turn per window. Dropped events surface assensing_drop(reasonambient_floor) in the Flow Monitor. - No match → forward to OpenClaw via WebSocket
chat.send - If event has
images→ callSendChatMessageWithImages→ send every attached photo with the text for AI vision analysis. A LIST, not a single field: a chat client can attach several at once and every wire format behind the gateway already carriesattachments[]; a camera event simply sends one entry. For chat types (web_chat/mqtt_chat), each image is saved to/tmp/web-chat-<ms>-<i>.jpg(indexed so photos attached to the SAME turn cannot collide) and tagged[image: <path>]so the agent can reference it (e.g. for face enrollment). When the main model is text-only, the describe-first gate runs once PER image, concurrently (safego), and the descriptions are numbered(image N of M). Concurrency is not an optimisation here: the gate runs inside the HTTP handler, so the caller's POST does not return until every describe finishes — a single describe measured 8-38 s, so two photos in series left the web chat silent for ~53 s, long enough that reloading the page (which cancels the request and loses the turn) is the natural move. Fanning out makes the wait the slowest image instead of their sum. - The describe-first gate above covers only images entering a turn from OUTSIDE (chat/Telegram attachment, HAL's realtime look-frame handoff). A frame the agent captures MID-TURN with
/camera/snapshotnever passes through it — the shell tool returns only{"path": ...}, which a text-only main model cannot see. For that path thecameraskill callsPOST /api/vision/look(loopback-only,system/server/vision.go) instead of HAL directly: os-server takes the snapshot itself (hal.Snapshot, 768px/q75 fixed server-side) and returns{"path": ..., "description": ...}. The vision-capability branch lives here, not in the skill — whenvision.ModelSupportsVisionsays the main model reads images itself, describe is SKIPPED entirely (no vision-model call, no 8-38s wait) and onlypathcomes back for the agent to open. Describe failure returns 502 so the agent admits it could not see instead of guessing - Chat runs (
web_chat/mqtt_chat) are tagged viaMarkWebChatRun(runID)so the SSE handler suppresses TTS at lifecycle end — reply is rendered in the chat UI only (web SSE, or MQTTchat.eventstream).
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/agent/status |
WS connection status; includes uptime (OS server WS uptime) and agentUptime (OpenClaw process uptime, survives OS server restarts) |
| GET | /api/agent/events |
SSE stream real-time events |
| GET | /api/agent/recent |
100 most recent events (ring buffer) |
| POST | /api/agent/speech/cancel |
Physical cancel gesture (single click, called by HAL — loopback-only auth so the button works without a login). Silences every turn currently in flight and stops HAL playback (StopTTS, which also clears the pre-synthesised speak-queue). The turns are not aborted: they keep running, their tools still fire, and their text still reaches web chat and history — they only lose the speaker. Implemented as a monotone unix-ms watermark (speechWatermarkMs): deliverTTS drops any reply whose turn was created at or before the mark and logs a tts_cancelled flow event. Turn age comes from the runID — device ids end in their creation stamp (device-chat-7-<unix-ms>, 13 digits), channel ids (tg-<messageID>) have none and fall back to the first time speech was requested for that run. Because new turns are always on the far side of the mark, the user can click and immediately speak again while an older backlog drains silently; the watermark never needs clearing. The same mark also drops the turn's [HW:] markers in fireHWCall — servos and LEDs stop too, since a device that keeps moving after being told to stop reads as ignoring the user. The run id is put through resolveRunID first: the TTS path already holds the device id while HW dispatch may still carry the raw backend UUID for the same turn, and judging them separately muted the reply while the markers fired anyway. /dm, /broadcast and /speak are exempt (the gate sits after them): the click means "stop talking to me" and must not swallow a reply addressed to a Telegram user. A second watermark (autoSpeechWatermarkMs) works the same way but is stamped by the system: it moves forward whenever HAL reports voice_agent_handled — the realtime voice agent has answered a newer utterance out loud — so the main-agent turn still working on the question before it loses the speaker instead of answering it afterwards in a different voice. deliverTTS drops a reply older than either mark; fireHWCall consults only the click mark, since a machine judgement must not silently cancel an action the user did ask for. Opt-in per body: set OS_REALTIME_SUPERSEDES_MAIN_REPLY=1 in the body's /opt/hal/.env. Default OFF, so a body that has never heard of the switch is unaffected. The click also calls FillerManager.CancelAllActive(). Fillers speak straight to HAL and never pass through deliverTTS, so the watermark alone cannot reach them — and because a muted turn keeps running, every tool boundary it crossed re-armed another "one moment" for a reply the user had just cancelled. Every run holding filler state at that instant is on the old side of the mark, so all of them are dropped; the Opening filler for whatever the user says next is armed afterwards and is unaffected. A dropped reply is still posted to HAL's POST /voice/realtime/history: the click takes the speaker, not the answer, and the realtime agent's record of what the main agent replied otherwise rides on TTS completion (see docs/realtime-voice.md). |
| POST | /api/agent/restart |
"Start + enable + restart" recovery for the active runtime. Steps: (1) best-effort systemctl enable <unit> — where <unit> is picked from a runtime→unit map (openclaw, hermes-gateway, picoclaw, codex, claudecode, opencode) — so the fix survives a reboot; (2) agentGateway.RestartAgent() which resolves to systemctl restart <unit> and thus STARTS the service even if it was stopped. Response {backend, enabled}. Used by the Overview's Agent Gateway card to recover a gateway that was stopped+disabled, without SSH. Internal restart callers (config refresh, migration) still bypass the enable step. |
The device sends operational / maintainer alerts about its own actions to
POST {llm_base_url}/alert (i.e. /api/v1/ai/v1/alert on bff-campaign-service),
authenticated with the device's lobster API key (Authorization: Bearer <llm_api_key>).
bff-campaign-service holds the Telegram bot token + destination chat and relays the
text to a fixed maintainer chat — the token is never on the device or in this
public repo. Implemented in system/lib/alert.
Privacy & data scope: these alerts report only device actions and state changes — never end-customer content. No chat messages, no personal data are captured. They exist for product improvement and troubleshooting only. Each alert carries device identity (label, MAC, SSID, IP, component versions) plus the action outcome below.
What fires an alert:
| Event | Trigger |
|---|---|
| Runtime switch | hermes.setup / picoclaw.setup (starting / success / failure) |
| Channel add / refresh | add_channel, channel.refresh_config (success / failure) |
| Connector set / remove | connector.set.*, connector.remove.* (success / failure) |
| OAuth refresh | refresh loop — alerted only on ok↔fail state change per provider |
| Skills install | skills.install (success / failure) |
| Device soft reset | device.soft_reset |
| Claude Code login / WhatsApp pair | terminal pairing outcome (paired / failure / timeout) |
| Default model swap | model sync — only when the version-gated primary/image model actually changes |
Runtime switches are exclusive. While a backend install or switch is running,
another POST /api/device/agent-runtime receives 409 Conflict rather than
starting a competing systemd transition. The web selector stays disabled until
the first switch is confirmed or times out.
HTTP-triggered switches additionally request runtime readiness confirmation for
up to 60 seconds before they stop the old runtime and persist agent_runtime;
systemctl is-active alone is never treated as proof that a gateway can serve
requests. Each runtime supplies its own probe: OpenClaw runs its authenticated
RPC status probe, Hermes polls authenticated /health, and PicoClaw, Codex,
Claude Code, and OpenCode must accept an authenticated WebSocket upgrade.
MQTT runtime setup uses the same probes: it publishes starting immediately,
then publishes success only after the target probe passes (or failure after
the switcher rolls back). The success acknowledgement is emitted before the
required os-server restart so it can reach the broker.
On boot after a runtime switch, the startup sequence may still reconcile
runtime config, channels, and onboarding files; those steps can restart a
gateway. Before it sends the physical wake greeting, os-server therefore
requires the active gateway to remain ready continuously for 15 seconds. This
prevents a greeting from being sent into a gateway that passed an earlier health
probe but is still restarting. The system greeting also tells the agent that its
device skills are available; it should use the relevant skill only for a later
action or device-related request, rather than scanning all skills during boot.
It includes structured agent_runtime context from the ready gateway's display
name (for example, OpenClaw or Codex), so the agent can follow that runtime's
workspace instructions for tool and session conventions. It also includes the
resolved device_type and a sorted device_capabilities list from that device's
ROBOT.md; the agent can avoid assuming unavailable hardware exists. This is
deliberately the ready gateway rather than config.agent_runtime, which can be
transiently out of date while a runtime switch is being reconciled.
Alerts are enabled whenever llm_base_url + llm_api_key are set; set
alerts_disabled: true in config/config.json to mute a device.
Accessed via nginx proxy: /hw/* → 127.0.0.1:5001
| Method | Endpoint | Description |
|---|---|---|
| GET | /servo |
Recordings + animation state + motion_mode (zero / hold / released, or null when no mode is holding the body) — the posture mode that decides whether /servo/play is honoured |
| POST | /servo/play |
Play animation (idle, curious, nod, headshake, happy_wiggle, sad, excited, shock, shy, scanning, wake_up, music_groove, listening, thinking_deep, laugh, confused, sleepy, greeting, acknowledge, stretching). Idle auto-plays on boot. Answers {"status":"ignored","reason":"hold"|"zero"|"released"|"sleeping"} when the mode or the sleep gate drops the play — "ok" means the recording actually started. |
| POST | /servo/move |
Send joint positions with smooth interpolation |
| POST | /servo/release |
Disable torque on all servos |
| GET | /servo/position |
Current servo positions |
| GET | /servo/aim |
List aim directions |
| POST | /servo/aim |
Aim device head (center, desk, wall, left, right, up, down, user). left/right change only base_yaw; an explicit center resets it; every other direction — and the unknown-direction fallback — keeps the current yaw |
| GET | /servo/track/targets |
List suggested target names for YOLOWorld detection |
| POST | /servo/track |
Start tracking — {"target":"cup"} (auto-detect) or {"bbox":[x,y,w,h]}. See vision-tracking.md |
| POST | /servo/track/stop |
Stop current tracking session |
| GET | /servo/track |
Get tracking status (active, target, bbox, confidence) |
| POST | /servo/track/update |
Re-initialize tracker with new bounding box |
| Method | Endpoint | Description |
|---|---|---|
| GET | /led |
LED strip info |
| GET | /led/color |
Current LED color |
| POST | /led/solid |
Fill entire strip with one color |
| POST | /led/paint |
Set individual pixels (array up to 64), or gradient stops with "gradient": true |
| POST | /led/off |
Turn off all LEDs |
| POST | /led/effect |
Start effect (breathing, candle, rainbow, notification_flash, pulse) |
| POST | /led/effect/stop |
Stop running effect |
| Method | Endpoint | Description |
|---|---|---|
| GET | /camera |
Availability + resolution |
| GET | /camera/snapshot |
Capture 1 JPEG frame. ?save=true saves to timestamped file, returns JSON {"path":"..."} |
| GET | /camera/stream |
MJPEG live stream (downscaled + throttled) |
| Method | Endpoint | Description |
|---|---|---|
| GET | /audio |
Audio device availability |
| POST | /audio/volume |
Set volume (0-100%) |
| GET | /audio/volume |
Get volume |
| POST | /audio/play-tone |
Play test tone |
| POST | /audio/record |
Record WAV |
| POST | /audio/play |
Play music by query. Body: {"query":"song artist","person":"name"}. person optional — enables per-user history. Fires a short cached TTS cue ("On it.", "Coming up.", …) before yt-dlp resolve so the device sounds responsive while ffmpeg loads. Cue is suppressed when speaker muted, TTS busy, music already playing, or VoiceService is mid-STT-session. |
| POST | /audio/stop |
Stop current music playback |
| GET | /audio/status |
Current playback status (playing, title, elapsed) |
| GET | /audio/history |
Music play history. Query: ?person=name&date=YYYY-MM-DD&last=50. person filters per-user; omit for shared. |
| Method | Endpoint | Description |
|---|---|---|
| POST | /emotion |
Combined expression: servo + LED + display eyes |
15 emotions: curious, happy, sad, thinking, idle, excited, shy, shock, listening, laugh, confused, sleepy, greeting, acknowledge, stretching
| Method | Endpoint | Description |
|---|---|---|
| GET | /scene |
List scene presets |
| POST | /scene |
Activate scene (reading, focus, relax, movie, night, energize) |
| Method | Endpoint | Description |
|---|---|---|
| GET | /presence |
Current state (present/idle/away) |
| POST | /presence/enable |
Enable auto presence control |
| POST | /presence/disable |
Disable auto presence (manual mode) |
Requires sensing with camera (InsightFace). Enrolled person JPEGs persist under /root/local/users/{label}/ by default, or under HAL_USERS_DIR if set. Each person's folder contains a metadata.json with telegram_username and telegram_id for DM targeting.
| Method | Endpoint | Description |
|---|---|---|
| POST | /face/enroll |
Body: image_base64, label, telegram_username?, telegram_id? — save photo, train friend embeddings, persist Telegram identity |
| GET | /face/status |
enrolled_count, enrolled_names |
| POST | /face/remove |
Body: label — remove one person (404 if unknown) |
| POST | /face/reset |
Clear all enrolled persons and photos on disk |
| Method | Endpoint | Description |
|---|---|---|
| GET | /user/info?name=X |
User metadata: name, is_friend, telegram_id, telegram_username. Defaults to "unknown" if name omitted. Auto-creates folder. |
Wellbeing activity history lives on the OS server HTTP API (port 5000). See
POST /api/wellbeing/logandGET /api/agent/wellbeing-history— entries are JSONL under/root/local/users/{user}/wellbeing/YYYY-MM-DD.jsonlwith schema{ts, seq, hour, action, notes}(action ∈drink/break/sedentary/emotional). HAL no longer hosts wellbeing endpoints.
| Method | Endpoint | Description |
|---|---|---|
| GET | /display |
Current state (mode, expression) |
| POST | /display/eyes |
Set eye expression + pupil position |
| POST | /display/info |
Switch to info mode (text/subtitle) |
| POST | /display/eyes-mode |
Switch back to eyes mode (default) |
| GET | /display/snapshot |
Current frame as JPEG |
11 expressions: neutral, happy, sad, curious, thinking, excited, shy, shock, sleepy, angry, love
| Method | Endpoint | Description |
|---|---|---|
| POST | /voice/start |
Start voice pipeline (Deepgram STT + TTS) |
| POST | /voice/stop |
Stop voice pipeline |
| POST | /voice/speak |
TTS — convert text to speech. Body fields: text, voice?, interruptible?, provider?, tts_api_key?, tts_base_url?, cached? (use WAV cache, render+save on miss), prerender? (render+save without playing — boot warmup) |
| GET | /voice/status |
voice_available, voice_listening, tts_available, tts_speaking |
A third TTS provider alongside openai and elevenlabs, selected as
tts_provider: "piper". Synthesis runs on the device, which removes the two
limits a hosted provider imposes: there is no shared concurrency cap to queue
behind (every unit renders its own audio, so throughput scales with units sold
and costs nothing per utterance), and there is no network round trip, so
time-to-first-audio drops — measured 129–236 ms for short replies against the
2–5 s a hosted call typically takes. The trade is quality: Piper is audibly
behind a hosted neural voice, so it is offered as the free default rather than
as a replacement.
Nothing ships in the image. The engine (~26 MB) and each voice (~63 MB) are
downloaded to the device when the operator asks for them in Settings → Voice.
That keeps the image small, means a unit that never leaves the hosted voice
pays nothing, and — because the user's own device fetches from upstream — keeps
Autonomous out of the business of redistributing GPL-3.0 software. Bundling
Piper into the image would reverse that; see CREDITS.md.
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/voice/piper/status |
Engine installed, voices installed, the download catalogue, and any job in flight. Proxied to HAL and re-wrapped in the standard envelope — the web client rejects a bare payload. |
| POST | /api/voice/piper/install |
Install the engine. Idempotent: already-installed returns ok, so the UI can call it without checking first. |
| POST | /api/voice/piper/voice |
Download one catalogue voice. Body {name}; names outside the catalogue are refused, so a caller cannot turn this into an arbitrary fetch into /opt/piper. |
| POST | /api/voice/piper/voice/remove |
Delete a downloaded voice and free its ~63 MB. Body {name}, catalogue-only for the same reason — an arbitrary name here would delete an arbitrary file. Refuses to remove the last installed voice. |
All four are admin-gated: they install software and write 63–79 MB per voice.
HAL serves the same four under /voice/piper/*; downloads run in a background
piperProxy retries a POST while HAL is not answering, for up to 25 s. Every
voice save restarts HAL (~8 s of downtime, occasionally doubled because two
config paths each request a restart), and a Download or Remove landing in that
window was simply lost — the page said nothing changed and the operator had to
guess when to try again. Only a failed dial is retried, and the distinction
carries the whole safety argument: a dial that never connected proves the
request was not delivered, so replaying it cannot repeat an effect. A timeout
proves nothing of the sort — the deadline covers reading the reply, so HAL may
have done the work and answered slowly — and those surface as a plain failure.
Any reply, including a refusal, is final and passed straight through. GET is
deliberately
excluded — the status poll's failure is what tells the page the device is
restarting, and holding those open would stack requests and hide the state.
Covered by piper_test.go, which restarts a listener under the call.
A download does not run inside HAL. hal/routes/piper_download.py is
launched by systemd-run as a transient unit, and the two sides agree through
a job file at /var/lib/autonomous/piper-job.json instead of shared memory.
This is not over-engineering: saving any voice setting makes os-server run
systemctl restart hal (device/config_update.go), and hal.service is
KillMode=control-group, so an in-process thread — or any ordinary child — was
killed mid-transfer. The record of the job died with it, so the page reverted
to Download 63 MB as though the click had never happened, with no error and
nothing to retry from. The worker imports nothing from hal: the package pulls
in hardware drivers on import, which a downloader has no business touching, and
staying dependency-free means it keeps running even when HAL will not start.
Each run gets its own unit name (autonomous-piper-download-<ns>). A fixed
name collides with the run before it: a finished unit sits in inactive for a
moment before --collect reaps it, and systemd-run refuses a name that still
exists. That failure fell through to the in-process fallback, which then died
with the next HAL restart and surfaced as download stopped unexpectedly for no
visible reason. The fallback now logs systemd's own stderr, because falling back
silently is how a download ends up inside HAL's control group unnoticed.
Nothing restarts HAL for a download. Voices are listed from the filesystem per
request and the model path is resolved per utterance, so a voice is listable and
speakable the moment its file lands — measured: downloaded at 18:32:29 on a HAL
that started at 18:31:59, listed and spoken at 18:33:11 with no restart between.
Applying a voice does not restart HAL either. POST /voice/tts/config sets
provider, voice, key and base URL on the running TTS service, which reads all of
them per utterance, so the change takes effect on the next sentence.
The phrases the device says about itself — restart, shutdown, reboot, sleep —
are rendered into the TTS cache ahead of time, at boot and again whenever
/voice/tts/config changes provider or voice (the cache key includes both, so a
voice change invalidates every clip). They play at the worst possible moments:
the restart notice is spoken while HAL is tearing down, the boot cue while every
other service is still coming up. On Piper a cache miss there means loading a
63 MB model on a saturated CPU — measured on an 8-core sun60iw2, the load alone
is 2–3.4 s and the restart phrase synthesises at 1.1x realtime, close enough to
breaking even that a little extra load starves the audio stream and the speech
comes out slurred. A hit costs no synthesis at all.
The realtime flag compares before and after rather than reacting to presence.
The settings page puts a realtime block in every save, so treating it as a
change restarted HAL on every save — which would have made the live TTS push
above dead code.
A shipped device carries the Autonomous team's proxy credentials in
llm_api_key, llm_model and llm_base_url, and every other section starts
from those same three values. Typing a personal key over them used to destroy
them outright — devices reached the field with no way back to the credentials
they were sold with.
autonomous_defaults is a top-level object in config.json holding
base_url / api_key / model. It is written once, by
captureAutonomousDefaults, immediately before the first save that carries any
credential — LLM, TTS, STT, or realtime key/URL — and never written again.
Capturing twice would store the operator's own key under the Autonomous name
and lose the real one for good, which is the exact failure it exists to prevent.
A save touching nothing credential-shaped (wifi, rename, channels) does not
trigger it, and a config with no credentials to preserve is skipped so an empty
set is never mistaken for a valid default. Only a factory reset clears it.
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/device/restore-defaults |
Put one section back on the shipped credentials. Body {"section": "llm" | "voice" | "realtime"}. Admin-gated. |
Restore is per section, because that is how an operator thinks about it — they swapped the brain, or the voice provider, and want that one thing back. Each section takes the slice of the stored set it started from: the AI Brain url + key + model, realtime and the voice pipeline url + key. Qwen realtime is refused: it talks straight to the Alibaba host with its own credentials, and the shipped set would produce a 401 there.
It is implemented as an ordinary UpdateConfig rather than a direct write, so
it inherits every side effect a manual edit gets — hal restart or the live TTS
push, gateway model sync, agent session reset. A hand-rolled save would drift
from that list the first time someone adds to it.
has_autonomous_defaults on GET /api/device/config says whether anything is
stored, never the values. The web uses it to decide whether to offer the action
at all.
HAL reads each service's own credentials, falling back to the AI Brain's
when they are blank: tts_api_key/tts_base_url for TTS, stt_api_key/
stt_base_url for STT, llm_api_key/llm_base_url otherwise. On most devices
all three are the same string, because the settings page mirrors the brain's
key and URL into the other two while those are blank. It matters when the brain
points elsewhere: a device with llm_base_url on openrouter and tts_base_url
on the autonomous proxy was building
openrouter.ai/api/v1/elevenlabs/text-to-speech/… and taking a 404 on every
spoken reply, because the ElevenLabs backend appends /elevenlabs to whatever
base it is handed and it was being handed the brain's. The config had the right
URL all along; nothing read it.
device/config_update.go splits what used to be one voiceSnapshot in two:
bootSnapshot (LLM and STT keys and URLs — genuinely read at import, still
worth a restart) and ttsSnapshot (provider, voice, TTS key and URL — pushed
live). A voice change is the most common save an operator makes, and restarting
for it took the microphone, speaker and wake word down for ten to fifteen
seconds; any admin click landing in that window was lost, because HAL was not
listening. If the live push fails, os-server falls back to the restart — a voice
that was saved but never reached HAL is worse than the restart it avoided.
A job is claimed before the POST replies, and the reply carries it. Leaving the claim to the worker loses a race the UI cannot recover from: the panel only polls while a job is active, so if its first read lands before the worker's first write it concludes nothing started and stops looking, and a several-minute download runs to completion invisibly. Claiming under the same lock that checks for a running job also makes a double-click one download.
The reader treats an active job as real only while its pid exists, so a worker
killed by anything other than its own error handler shows as stopped rather
than as a download frozen forever. The start-up orphan sweep skips the files a
running job owns — transfers now outlive HAL, so the sweep runs during one,
and deleting its .part would break the exact case this design protects.
The job reports bytes_done/bytes_total alongside percent, tracked for the
model only — the sidecar is a few KB and would flicker the counter to a tiny
total and back. A failed voice install deletes its own partial files, and HAL
sweeps orphaned sidecars and .part files once at start: the listing keys off
.onnx, so a sidecar whose model never arrived is invisible in the UI while
still occupying space on a small card.
Removal enforces one invariant: never delete the last model. HAL is not told
which voice is configured — os-server sends it with each /voice/speak call —
so it cannot refuse "the one in use", and it does not try. Removing any other
voice is survivable because an unknown voice falls back to one that is
installed; removing the last one is not, because the backend then has nothing
to load and the device goes silent. The UI additionally hides Remove on the
in-use row, so switching comes before deleting.
thread and report progress through job in the status payload, because a 63 MB
pull is far longer than an HTTP request should be held open for.
Two things the implementation gets wrong if copied carelessly. Piper output
already peaks at full scale, so the volume_boost of 2.5 the hosted backends
use would clip every vowel — the backend reports 1.0. And model load costs
~700 ms, which dominated time-to-first-audio for short replies until the
backend started keeping a pre-spawned process warm and replacing it after each
utterance.
Voices are enumerated from the filesystem (/opt/piper/voices/*.onnx), not from
a hardcoded list, so dropping a model in makes it selectable. Which models are
offered for download is a licensing decision, recorded with each entry in
hal/drivers/voice/tts/piper_catalog.py.
The backend reports itself available when the binary and any voice are present, not the configured one specifically. A device can legitimately be set to a voice it does not yet have — the operator saves the choice while the 63 MB model is still downloading — and gating on the exact name would take TTS offline entirely. Instead an unknown voice falls back to the default, then to whatever is installed, and logs the substitution once per name. Speaking in the wrong voice is a fault that explains itself; a silent device reads as broken hardware.
GET /api/device/voices?provider=piper fails rather than answers empty when
HAL is unreachable. Voices are files under /opt/piper, so HAL is the only
thing that can know what is installed; an empty success would be a claim
os-server cannot make, and the web takes the reply as authoritative — the picker
empties, and since it only refetches on a provider or language change, it never
fills back in. Every voice save restarts HAL, so that window is hit routinely.
An error leaves the client holding its last known-good list.
For the same reason domain.TTSVoicesByProvider is empty for Piper: no image
ships a voice, so any name offered as a fallback would be a name the device does
not have — and the web UI would save it as the configured voice.
| Method | Endpoint | Description |
|---|---|---|
| GET | /health |
Hardware driver availability |
OS Server (Go):
{"status": 1, "data": {...}, "message": null} // success
{"status": 0, "data": null, "message": "error"} // failureHAL (Python): FastAPI standard JSON responses.
- OS Server starts Gin on :5000
- Reads
config/config.json- Seeds
device_typefrom the resolved device class (DEVICE_TYPEenv, else the existing key) so config.json carries it for readers that have no env — HAL's wake words andsoftware-update. Provisioning only writes the env, so without this seed the key never exists on a provisioned device. Written once, when the stored value differs - Seeds
tts_provider+tts_voicefrom ROBOT.mdvoice:block when the user hasn't chosen them (persisted once; the user's saved choice always wins; provider absent/unknown →openai). When the seeded provider iselevenlabsand no voice is declared, picks a language-aware default (vi→Ngan,zh→Amy, else Rachel)
- Seeds
- If
SetUpCompleted:- Connect OpenClaw WebSocket
- Connect MQTT
- Start ambient behaviors
- Wait for HAL to answer
GET :5001/health(up to 120s) before any HAL call. os-server binds :5000 well before HAL's FastAPI is listening, and a first boot also builds the venv and loads models, so an un-gated one-shot call is lost to a connection refused - Set speaker volume: the level the user last set (persisted by HAL on every
/audio/volumechange) wins; otherwise the device'sstartup_volume(ROBOT.md front matter, default 100)
- If not yet set up: wait for
POST /api/device/setup
make os-dev runs the same binary that ships to the board — no build tag,
no second code path. Only the device-absolute paths move, through the env vars
system/lib/syspath reads. Unset env = board defaults, byte for byte
(runtimes/codex/paths_default_test.go asserts this).
| Env var | Default (device) | Used for |
|---|---|---|
CODEX_HOME |
/root/.codex |
Codex state dir — config.toml, auth.json, .env, skills/, sessions/, workspace/. Anchors every codex path on both the client and codex-gatewayd |
CODEX_PORT |
18792 |
Bridge WebSocket port (WSURL and the gatewayd listener) |
CODEX_WS_TOKEN |
autonomous_codex_token |
Bearer token os-server sends to the bridge |
OS_AGENT_HOME |
/root |
Root a Telegram coding session resolves ~ and relative folders against |
OS_AGENT_STATE_PATH |
/root/config/agent_state.json |
Runtime-switch history (persona migration) |
OS_BOOTSTRAP_CONFIG |
/root/config/bootstrap.json |
The file os-server reads metadata_url from — the base for skill zips and the skill watcher |
OS_LOG_FILE |
/var/log/os-server.log |
Rotating log file |
DEVICE_TYPE / DEVICES_DIR |
— / /opt/devices |
Body selector and robots/<type>/ root (pre-existing) |
config.json needs no env: configPath is config/config.json relative to the
cwd, so os-dev runs from the state dir exactly as systemd's
WorkingDirectory=/root does on the board.
A full laptop stack is three terminals:
make sim # HAL on :5001
make codex-dev # codex bridge on $CODEX_PORT
make os-dev # API on :5000
make web-dev # web UI on :5173 (optional)os-server serves no HTML: on a board nginx serves web/dist and proxies /api
and /hw to it. make web-dev puts Vite in nginx's place, with LAMP_PROXY
(default http://127.0.0.1:5000) naming the device the SPA talks to — a .env
in web/ still wins, so pointing at a real Pi is unchanged. Open
http://localhost:5173/monitor; Vite binds [::1] only, so 127.0.0.1:5173
is refused. Admin routes need auth — log in with the device password, or append
?llm_api_key=<the key in config.json> once and the SPA exchanges it for a
session cookie and scrubs it from the address bar.
Three of the six log tabs work off-device. hal and os-server follow
OS_HAL_LOG_FILE / OS_LOG_FILE, and the Agent tabs follow
OS_AGENT_BRIDGE_LOG — make codex-dev tees the bridge to a file because a
laptop has no journal to read. bootstrap (the worker is not run off-device)
and buddy (a Mac app with no log here) stay empty by design; unset env leaves
all six exactly as they resolve on a board.
Makefile knobs: OS_STATE_DIR (default /tmp/autonomous-os), OS_AGENT_RUNTIME
(default codex), CODEX_HOME (default $HOME/.codex), CODEX_PORT,
CODEX_BIN. scripts/dev/os-dev-seed.sh writes device_type, agent_runtime
and set_up_completed: true into the state dir's config.json — the last one
matters because the startup sequence that runs presync and EnsureOnboarding
is gated on it (server/config_watch.go), so without it the workspace stays
empty. Nothing in the target installs the codex CLI itself — that is expected to
be on PATH already.
Skills DO install themselves. os-dev-seed.sh also seeds a bootstrap.json
carrying metadata_url, derived from the same GCS_BUCKET / BUCKET_PREFIX
that scripts/release/ota-config.sh defines, so the dev URL cannot drift from
what upload-skills.sh publishes. With it set, EnsureOnboarding runs the same
downloadSkills() the board runs: every skill this DEVICE_TYPE supports is
pulled as <base>/skills/<name>.zip into $CODEX_HOME/skills, and the skill
watcher then refreshes it on version changes. The CDN objects are public, so no
credentials are involved. Seeded once — an edited bootstrap.json survives.
metadata_url is the ONLY key os-server reads from that file, and its only
consumers are the skill watcher and the runtimes' otaBaseURL() helpers, so
setting it off-device enables skills and nothing else — OTA self-update lives in
the separate bootstrap-server binary, which make os-dev does not run.
make sim alone boots HAL with virtual devices. make sim SIM_MEDIA=host opens
the Mac's microphone, speaker and camera and runs the real voice pipeline
(STT → realtime → [turn] route=… dispatch → this server), so a spoken turn
travels the same path it does on a board. The sim target sets three paths for
it:
| Env | Points at | Why |
|---|---|---|
OS_CONFIG_PATH |
$OS_STATE_DIR/config/config.json |
The one file HAL and os-server share, as /root/config/config.json is on a board. Carries the credentials and agent_runtime |
HAL_SNAPSHOT_DIR |
$CODEX_HOME/media/hal-snapshots |
Where ?save=true writes. Must sit under the runtime's own home or the agent cannot read the frame and GET /api/sensing/agent-snapshot/… cannot serve it |
HAL_SNAPSHOT_PERSIST_DIR |
$SIM_STATE_DIR/snapshots |
/var/lib/hal/snapshots is root-only |
HAL_TTS_CACHE_DIR, HAL_CALIBRATION_DIR, HAL_USER_BEARING_PATH, HAL_FACE_HEIGHT_PATH, HAL_VOICE_STRANGERS_DIR, HAL_DL_STALL_LOG |
$SIM_STATE_DIR/… |
The rest of HAL's writable state, rooted at /var/lib/hal or /root/local on a board |
HAL_CODEX_WORKSPACE_DIR |
$CODEX_HOME/workspace |
The realtime agent's memory.jsonl is derived from it |
These fail far from their cause, which is why they are set as a block rather
than one at a time: the TTS cache one surfaced as POST /voice/speak 409 with
the real PermissionError: /var/lib/hal buried in a background thread's
traceback. Two remaining defaults are read-only model paths
(/root/local/models, /opt/piper) — absent on a laptop, the feature that
needs them simply stays off. POST /audio/volume answering 503 is also
expected: macOS has no ALSA mixer.
Put the credentials in that config.json (Settings in the web UI writes the same
file). llm_api_key + llm_base_url alone cover LLM, AutonomousSTT, TTS,
image description and Gemini Live — the realtime key falls back to llm_api_key
and its endpoint to llm_base_url + /ws/gemini (hal/config.py), so no
separate Google credential is involved. deepgram_api_key is optional.
Copying a real device's config.json is the fastest way to a full-option laptop,
but blank two keys first: telegram_bot_token (one bot cannot have two pollers —
the laptop would steal the device's messages) and mqtt_endpoint (the laptop
would subscribe the device's own topics). Neither is an AI capability, so
nothing above is lost.
Servo has no physical body here: http://127.0.0.1:5001/simulator is the
readout, driving the same /servo/* and /led/* endpoints a skill calls.
Two things to know on macOS:
- Microphone and Camera access must be granted to the terminal app running HAL
(System Settings > Privacy & Security). Enumeration is not permission — the
device list is populated either way and only the first real read fails — so
HAL probes both at boot and falls back to the virtual device with a logged
[sim-media]reason rather than failing a turn later. - AirPlay Receiver also listens on
*:5000. os-server binds127.0.0.1:5000, but a request tolocalhost:5000can still land on AirTunes — turn the receiver off (System Settings > General > AirDrop & Handoff) or changehttpPort. presync.shregeneratesconfig.tomlon every boot and keeps only[mcp_servers.*].os-dev-seed.shcopies a pre-existing one toconfig.toml.pre-os-devonce, so pointingCODEX_HOMEat a real install is not a one-way door.
HAL_LOG_LEVEL in the shared /opt/hal/.env controls the level for HAL,
OS Server, and bootstrap. Allowed values are DEBUG, INFO (the default),
WARN, and ERROR. OS Server writes records at that level and higher to stdout
and the rotating local file /var/log/os-server.log (2 MB per file, retaining
the 10 newest backups).
When GELF_URL is configured, OS Server ships records at the same configured
level and higher to that central collector through one worker with a bounded queue
of 256 records. Logging never blocks the request path or creates a goroutine per
record: when the collector is slow or unavailable and the queue is full, newly
produced GELF records are dropped (with rate-limited stderr notices) while console
and local rotating-file logging continue. On shutdown, the worker flushes queued
records for up to five seconds before cancelling any remaining delivery.
When receiving a voice_command, voice_followup, or voice event, the OS server checks local intent first (~50ms):
| Command | Action |
|---|---|
| "turn on light" | /led/solid warm + happy emotion |
| "turn off light" | /led/off + idle emotion |
| "reading mode" | scene:reading |
| "focus mode" | scene:focus |
| "relax" | scene:relax |
| "movie mode" | scene:movie |
| "goodnight" | scene:night + sleepy emotion |
| "brighter" | scene:energize |
| "happy" | emotion:happy |
| "sad" | emotion:sad |
| "volume up" | volume 100 |
| "volume down" | volume 30 |
| "mute speaker" | POST /speaker/mute (silent — no TTS confirm) |
| "unmute speaker" | POST /speaker/unmute + "Speaker on!" |
Keyword matching is whole-phrase with ASCII word boundaries — "unmute speaker" does not trigger the "mute speaker" rule. The chitchat rules (greeting / farewell / thanks, matched per language) use the same boundary test: a plain substring match let the two-letter phrase "hi" fire inside "this", "his" and "machine", so ordinary sentences like "What is this?" were answered locally with "Hi there!" and never reached the agent.
Chitchat is off while the realtime voice agent is enabled — the model receives every voice turn before os-server does and answers social talk itself, in character. Leaving both on meant a canned reply in a different voice barging in on the turns the model happened to stay silent for. Command rules above stay on either way; they genuinely beat a model round-trip. The gate follows realtime.enabled live, so toggling it in Settings needs no restart.
No match → forward to OpenClaw.
On startup (after persona migration) os-server retires people from every
runtime's USER.md once their face/voice enrollment is gone.
USER.md is a bootstrap file — injected into the agent's system prompt on every
turn — but nothing on the device ever wrote it: the agent records what it learns
in KNOWLEDGE.md and memory/*.md, neither of which OpenClaw loads. The file
that is always read was the one never written, so a device that changed hands
kept greeting its previous owner by name (lamp-ac82, 2026-09-03).
- The rule: a name is stale only when
usercanon.Resolvemaps it to no directory under/root/local/users/. Absence is never the trigger — a person away for a day or a year keeps their enrollment, and therefore their profile. Only/face/remove,/speaker/removeor a factory reset removes one. - Writes only on change.
USER.mdsits in the ~28k-token cached prompt prefix, so an unconditional rewrite would cost a prompt-cache miss on the next turn of every boot. The normal pass reads and writes nothing. - Observe-only by default.
user_profile_reconcileinconfig.jsongates writes; unset/false logs what it would retire and changes nothing. - Writes are atomic (temp + rename) because the gateway is live during the pass.
- An empty enrollment store (fresh device) is a no-op; an unreadable one is an error that changes nothing, rather than a guess.
They cost differently, so they are bounded differently.
| In the system prompt? | Billed | Cap | |
|---|---|---|---|
USER.md |
yes — a bootstrap file | every turn | 12000 chars (bootstrapMaxChars), then truncated tail-first |
KNOWLEDGE.md |
no — OpenClaw does not know the file | once per session, when the agent reads it | none by construction |
KNOWLEDGE.md had no cap at all: the daily synthesis appends a ## YYYY-MM-DD
block per active day and nothing ever removed one. Measured on lamp-ac82 at
~666 B/day, a year of use reaches ~166 KB (~42k tokens) re-read every session.
The heartbeat instruction now caps it: keep the 14 most recent dated blocks,
fold anything older into the distilled top sections (Hardware / Users / Skills &
APIs / Mistakes Made), delete the block. That uses the structure already there —
the top section is "Distilled from daily memory logs", the dated blocks are raw
material — and the raw day still exists in memory/YYYY-MM-DD.md.
The heartbeat pass has a second step after knowledge synthesis: carry what was
learned about people into USER.md.
Both steps are catch-up driven, not clock driven. The synthesis used to be
gated on current time >= 21:00, which silently never fires on a device switched
off at the end of the working day. Device-observed 2026-09-03 on lamp-ac82:
three days of flow logs ended 18:39 / 17:57 / 17:34, and memory/2026-08-24.md
was never distilled because 21:00 never arrived. The gate is now "is there a day
BEFORE today with a memory file but no ## YYYY-MM-DD header?", so the first
heartbeat after the device is switched on clears the backlog on any schedule.
This exists because of an asymmetry that caused a real bug. KNOWLEDGE.md is
the agent's own file — OpenClaw does not load it; it only reaches the model
when the agent tool-reads it. USER.md is a bootstrap file and is injected into
the system prompt on every turn. So the file the agent wrote daily was the
one rarely read, and the file always read was never written: a device that
changed hands kept greeting its previous owner for two months.
The instruction lives in heartbeatMDBlock (runtimes/<name>/onboarding.go) and
is byte-identical across openclaw / codex / opencode / picoclaw — a runtime
switch must not silently drop it.
Rules the agent is given, and why each one is load-bearing:
| Rule | Why |
|---|---|
One bullet per person under ## Users, as - **<label> (friend)** — call: …; notes: … |
<label> is the enrollment label from [context: current_user=…], which is what the OS reconcile keys on. The (friend) parenthetical is what distinguishes a person from a form field — without it, **Notes:** … would parse as a person named "Notes:" and get deleted. |
Short key: value segments, not prose; call: first |
The template's own fields are singular (one **Name:**, one **Timezone:**) and cannot describe two people, but nesting them per person does not survive the file: parseEntries → serialize flattens every bullet to - …, so indented sub-fields detach from their person. Segments keep the form's idea — separated, labelled facts — in one prunable entry. The first attempt was flowing prose and produced a ~600-char paragraph with the address form buried in sentence four. |
Never guess call:, pronouns or timezone |
The agent sees a face label and a voiceprint. Neither says anything about how someone wants to be addressed. Record them only when the person has said so; otherwise omit the segment. |
| Each entry under ~400 chars | USER.md is billed on every turn, and past bootstrapMaxChars (12000) OpenClaw truncates with text.slice(0, cutPoint) — head kept, tail cut — and ## Users is the tail. An oversized profile silently loses exactly the person data. ReconcileUserProfiles warns at 9000. |
| Strangers get no entry | ## Users is keyed by enrollment label; a passing face has none. Desk traffic belongs in KNOWLEDGE.md. |
| Only write what was observed about that person | The original failure was two people fused into one profile (Long/Leo). Never move one person's habits onto another. |
| Update and add only — never delete | Absence is not departure. Retiring a person is the OS's job (ReconcileUserProfiles, keyed on enrollment), not the agent's. |
Do not fill **Name:** or the other single-value fields |
They are singular and cannot represent a multi-user device — filling them from the day's observations would thrash between users. Who is present comes from the per-turn tag. |
TestHeartbeatPeopleSyncFormatMatchesTheReconciler pins the written format
against the reconciler's parser, so the two cannot drift apart into entries
nobody can prune.