Debugging & Troubleshooting (Relay connections and logs)
For phone notifications and recordings to arrive, the path is phone app → hosted Relay → local daemon → written to disk. The "connect to Relay" step is initiated by the daemon at startup: the daemon connects to wss://…/message/messages/ws/plugin using the account-level api-key, then hands req frames forwarded by Relay to the Go Dispatcher to route to the daemon's gateway; HTTP-style frames are looped back to the daemon's own HTTP server. In multi-key mode, each api-key label gets its own Relay tunnel.
So the troubleshooting order is always the same three steps: does the CredentialSet have a usable key → is the daemon running → is the tunnel connected.
Three steps to confirm Relay is connected
yc auth status # 1. is an api-key / apiKeys[] in place
yc auth list-api-keys # with multiple keys, check mode, defaultLabel, label list
yc daemon status # 2. is the daemon running (also check the relay/tunnels fields)
yc tunnel status # 3. tunnel connection statustunnel status looks like this when connected:
{ "ok": true, "mode": "relay", "credentialMode": "file-multi",
"defaultLabel": "phone-a", "connected": true, "env": "production",
"relayUrl": "wss://openclaw-service.yoooclaw.com/message/messages/ws/plugin",
"enabled": true, "reconnectAttempt": 0,
"stale": false,
"currentUrl": "wss://openclaw-service.yoooclaw.com/message/messages/ws/plugin",
"expectedUrl": "wss://openclaw-service.yoooclaw.com/message/messages/ws/plugin",
"tunnels": [
{ "label": "phone-a", "default": true, "connected": true, "reconnectAttempt": 0 },
{ "label": "phone-b", "default": false, "connected": true, "reconnectAttempt": 0 }
] }How to read the fields:
| Field | Meaning |
|---|---|
mode | relay = going through the hosted tunnel; standalone-http = no tunnel — read note for which of the four causes applies (table below) |
credentialMode | env-single / file-multi / keychain-single / legacy-file-single / none |
defaultLabel | The default api-key label; used by ASR fallback and legacy single-key status fields |
env | Current environment name (development / test / production) |
connected | Whether a WebSocket to Relay is established and heartbeating |
reconnectAttempt | Cumulative reconnect count; 0 means it connected once and never dropped |
lastDisconnectReason | The reason for the most recent disconnect (e.g. error: Unexpected server response: 403) |
stale | The address the tunnel actually dials (currentUrl) differs from the one the config resolves to (expectedUrl) — usually means you just switched environments and it's reconnecting. Not a failure; wait one reconnect cycle |
currentUrl / expectedUrl | What the default tunnel actually dials / what the current config resolves to |
tunnels[] | The list of tunnels under multiple keys; each item has label, default, connected, reconnectAttempt |
note | An actionable hint when not connected |
mode: standalone-http has four causes — read note before concluding anything:
note | Meaning | What to do |
|---|---|---|
Relay 未启用,走直连 HTTP (Relay disabled, using direct HTTP) | relay.enabled=false | yc config set relay.enabled true, then restart |
Relay 已启用但当前 CredentialSet 没有可用 api-key (Relay enabled but no usable api-key) | No api-key set | Add an api-key, then yc daemon reload |
ingress=proxied:隧道由宿主代理,daemon 仅收 ingest | Expected behavior | The tunnel belongs to the host application; don't debug it here |
ingress=direct:隧道关闭,仅接受直接 POST | Expected behavior | Direct POST only |
Only when you're deliberately using the direct-HTTP fallback should you go check firewalls, reverse proxies, and the address configured on the phone.
yc tunnel status --client phone-a shows just the specified label. Relay is connected when the daemon starts: if auth status shows daemon.running: false, tunnel status won't show a connection — run yc daemon start first.
Environment / host mismatch
The tunnel, light effects, light rules, and ASR all share one cloud host. Precedence is environment variables > cloud.host > relay.url > built-in default:
| Priority | Source | Notes |
|---|---|---|
| 1 | PHONE_NOTIFICATIONS_ENV / OPENCLAW_HOST_* | Only counts when explicitly set; for temporarily flipping environments |
| 2 | config.cloud.host | Empty means follow the environment variables |
| 3 | The domain in config.relay.url | Only when it is a built-in environment's default domain; ignored when it points at a self-hosted tunnel or proxy (that machine may not serve the plugin-side API) |
| 4 | Built-in default | production |
The cloud block in daemon status reports both the result and its source:
{ "cloud": { "env": "test", "host": "…", "source": "cloud.host",
"lightApiUrl": "https://…", "lightRuleApi": "https://…" } }source is env / cloud.host / relay.url / default, which makes the usually most time-consuming question — "the config says A, why is it hitting B" — answerable at a glance.
yc config show --format json # inspect the actual cloud.host / relay.url
yc config set cloud.host openclaw-service.yoooclaw.com
yc config unset cloud.host # clear it to follow PHONE_NOTIFICATIONS_ENV
yc daemon reloadTunnel is connected but light effects / transcription return 401
This is the most common mismatch: tunnel status is green, yet light send / lightrule list return 401 Invalid plugin API Key. Api-keys are issued per environment, so the cause is almost always that the phone account and the local api-key live in different environments — not an expired key. Compare cloud.env from daemon status with the environment the phone app is on, align them via cloud.host or PHONE_NOTIFICATIONS_ENV, then run yc daemon reload. On such a 401 the CLI appends the current environment, the masked key, and the next action to the error.
Reading logs
The daemon writes file logs to ~/.yoooclaw/profiles/<profile>/daemon.log, rotated daily to daemon.log.YYYY-MM-DD. Two ways to view them:
# search (🟢 pure disk read, no daemon needed)
yc log relay # filter by keyword
yc log --level warn --limit 100 # by level / count
yc log --from 2026-05-21 # by date range
yc log +errors # error-level since yesterday
# live follow (🔵 continuous tail)
yc daemon logs -f # like tail -f
yc daemon logs -f --level error # only follow errorsYou can also just run tail -f ~/.yoooclaw/profiles/default/daemon.log directly.
Reading key log lines
A normal startup + connect roughly looks like:
| Log line (excerpt) | Meaning |
|---|---|
Port 18365 in use, trying 18366 / Bound to 18366 | The default port was taken, so it auto-incremented (see "Ports" below) |
Relay multi-tunnel started: phone-a,phone-b (mode=file-multi, default=phone-a) | The daemon has built a tunnel for each label in apiKeys[] |
CredentialSet reload(manual): mode=file-multi, default=phone-b, started=..., stopped=... | A manual reload or file watch triggered a credential hot reload |
Relay tunnel: started (url=… → in-process gateway dispatch) | The RelayDispatcher has taken over inbound frames; subsequent reqs call the daemon runtime directly |
Relay tunnel: connecting to wss://… (apiKey=ock-…) | The connection is being established; the api-key is masked |
Relay tunnel: ✔ connected, heartbeat started | Connected, heartbeat started |
→ heartbeat "ping" / ← pong received | A keepalive heartbeat every 10s; seeing this continuously means the connection is stable |
Relay tunnel: relay disconnected (…) | Disconnected, then enters exponential-backoff reconnection |
[relay-dispatcher] req id=… method=recordings.result.write | The app / cloud called a gateway method over Relay, and it's been dispatched into the daemon |
[recording-status] <id> → transcribing | A recording status change happened, and it's been appended to recordings/state/events.jsonl |
Common symptoms → fixes
| Symptom | Cause | Fix |
|---|---|---|
tunnel status shows mode: standalone-http, note says no api-key | No api-key set, so the tunnel was skipped | For a single key, yc auth set-api-key <ock_…>; for multiple keys, yc auth add-api-key <ock_…> --label <label>, then yc daemon reload |
connected: false, lastDisconnectReason contains 403 | Invalid / expired api-key, or account mismatch | Use the correct api-key; with multiple keys, first find the specific label with yc tunnel status --client <label>, then yc daemon reload |
All 🟡 commands report YOOOCLAW_DAEMON_NOT_RUNNING | The daemon isn't running | yc daemon start |
mode: standalone-http even though an api-key is set | Four possible causes, distinguished by note | See the standalone-http table above |
connected: false, reconnectAttempt keeps climbing | Network unreachable / Relay unreachable | Check the network; you can temporarily fall back to a direct HTTP connection (see below) |
stale: true, currentUrl ≠ expectedUrl | Environment just switched; the tunnel still dials the old address | Wait one reconnect cycle, or force it with yc tunnel reconnect |
Tunnel is fine but light send / lightrule list return 401 | The api-key doesn't match the environment | See "Environment / host mismatch" above |
daemon start returns YOOOCLAW_DAEMON_DISABLED_BY_PLUGIN | The storage writer lock is held by the Hermes plugin | Expected in plugin mode — the daemon shouldn't run; read-only commands still work. Disable the plugin to return to standalone mode |
A recording stays at synced with no transcript | ASR isn't configured, or the cloud model-proxy ASR is unavailable | Retry after yc recording setup-asr --mode api --language auto --non-interactive; also check yc recording events --id <id> |
A recording has a lingering lastError | A historical failure message wasn't cleared | The daemon auto-clears lastError on reaching a successful terminal state like synced / transcribed; restart the daemon and watch the event stream |
Active checks and self-tests
yc daemon reload # re-read apiKeys[] and incrementally refresh multiple Relay tunnels
yc tunnel reconnect # force disconnect and reconnect (after changing api-key or network recovery)
yc tunnel reconnect --client phone-a
yc tunnel +test # loopback self-test: the daemon sends itself an echo notification, verifying ingest + auth
yc tunnel +test --client phone-a
yc gateway test # simulate the phone calling /notifications, verifying connectivity / auth
yc doctor # environment self-check: Go runtime, directory permissions, keychain, daemon, configOnce a notification arrives, lastIngestAt / ingestCount in yc daemon status change, and yc notification search --client <label> will show the notification written for that label — these two are the final proof that "the whole chain is working."
Once a recording arrives, start by looking at the list and the latest event:
yc recording list
yc recording +latest
yc recording events --since 1h --limit 50To debug a specific recording:
yc recording status <recording-id>
yc recording events --id <recording-id> --watchThe event file lives at ~/.yoooclaw/profiles/<profile>/recordings/state/events.jsonl. It's append-only, and local queries don't depend on whether the daemon is currently running; --watch first prints matching historical events, then keeps tailing new ones.
Ports
The listen address is always the loopback 127.0.0.1, starting at port 18365 — there's no need for, and no longer any, interactive port configuration. If 18365 is taken at startup, the daemon automatically increments +1 (18366, 18367, …), writing the actual port into the log and daemon status. So when troubleshooting, trust the port in daemon status — don't assume it's always 18365.
Fallback when not using Relay
When Relay is temporarily unavailable, the daemon's HTTP server is always listening locally, so you can set up your own tunnel: use cloudflared or tailscale serve to reverse-proxy to http://127.0.0.1:<port>, and point the phone at that address plus the gateway token — no dependency on the hosted Relay.