Summary
talk.config (the Talk-mode discovery RPC that iOS / macOS / Control UI Talk overlays call on startup to learn the configured TTS provider) throws unresolved SecretRef when talk.providers.<id>.apiKey is configured as a SecretRef object. The credential itself resolves correctly — talk.realtime.session and talk.speak both work end-to-end with the same SecretRef — but the discovery handshake fails, so clients never learn what provider is configured and silently fall back to local on-device TTS (AVSpeechSynthesizer / equivalent), producing the classic "robot voice" symptom even though everything is configured correctly.
talk.providers.*.apiKey is officially documented as a supported SecretRef target in docs/reference/secretref-credential-surface.md and listed in docs/.generated/config-baseline.core.json. So this is a discovery / redaction-path bug, not a deliberate restriction.
Environment
- OpenClaw: v2026.4.24 (also reproduced on v2026.4.21–v2026.4.24)
- Platform: macOS (Apple Silicon)
- Affected surfaces: iOS Talk, macOS native Talk overlay, Control UI Talk (browser/PWA)
- Secrets backend: file-backed (
~/.openclaw/secrets.json, secrets.providers.secrets.source: file)
- Install: standard npm-installed gateway under macOS LaunchAgent
Reproduction
-
Configure file-backed secrets with an ElevenLabs (or OpenAI Realtime) API key:
{
"secrets": {
"providers": {
"secrets": { "source": "file", "path": "~/.openclaw/secrets.json", "mode": "json" }
},
"defaults": { "file": "secrets" }
}
}
-
Configure Talk to use a SecretRef for the provider apiKey:
{
"talk": {
"provider": "elevenlabs",
"interruptOnSpeech": true,
"providers": {
"elevenlabs": {
"voiceId": "<voice-id>",
"modelId": "eleven_v3",
"outputFormat": "mp3_44100_128",
"apiKey": {
"source": "file",
"provider": "secrets",
"id": "/skills/elevenlabs"
}
}
}
}
}
-
Restart the gateway and call the discovery RPC:
openclaw gateway call talk.config
Observed:
Gateway call failed: GatewayClientRequestError: Error: talk.providers.elevenlabs.apiKey: unresolved SecretRef "file:secrets:/skills/elevenlabs". Resolve this command against an active gateway runtime snapshot before reading it.
-
Confirm the credential itself is healthy:
openclaw gateway call talk.speak --params '{"text":"hello"}'
# → returns valid audioBase64 (ElevenLabs MP3, ID3 / Lavf encoder header)
openclaw gateway call talk.realtime.session
# → returns valid OpenAI Realtime ephemeral key (ek_...) when provider=openai
So the SecretRef IS resolved by the runtime — only the talk.config discovery / redaction path fails.
-
Open the iOS app or macOS native Talk overlay → robot voice (local AVSpeechSynthesizer fallback).
Expected behavior
talk.config should successfully redact and return the configured Talk provider snapshot regardless of whether talk.providers.<id>.apiKey is a literal string or a SecretRef object. The redaction path should treat unresolved SecretRef wrappers the same way it treats string secrets — render them as __OPENCLAW_REDACTED__ rather than throwing.
Actual behavior
talk.config fails fast → iOS / macOS / Control UI Talk clients receive no provider snapshot → silent fallback to local on-device TTS. No surfaced error in the client UI; users see the macOS Advanced > Talk Voice panel show the right config but hear the wrong voice.
Likely cause
The talk.config handler in src/gateway/server-methods/talk.ts reads from readConfigFileSnapshot() (raw file, no SecretRef resolution) and then calls redactConfigObject(...) from src/config/redact-snapshot.ts on the talk payload. Somewhere down that path (likely via assertSecretInputResolved in src/config/types.secrets.ts), the redactor encounters the unresolved SecretRef wrapper and throws createUnresolvedSecretInputError instead of redacting the value.
This is the same family of regressions documented in:
None of those track talk.config + talk.providers.*.apiKey specifically.
Suggested fix
In the talk.config handler / redactConfigObject path, treat unresolved SecretRef objects the same as resolved string secrets for the purpose of redaction — emit __OPENCLAW_REDACTED__ (or a structured { redacted: true } marker) instead of asserting resolution. The discovery RPC inherently runs on the raw config snapshot, so unresolved SecretRefs are an expected input shape there, not an error condition.
Optional: extend the redaction visitor with explicit SecretRef handling (one branch above the assertion) to keep the strict assertion intact for paths where unresolved refs really are a bug.
Workaround
Replace the SecretRef with a literal apiKey string or ${ENV_VAR} interpolation under talk.providers.<id>.apiKey. Both surfaces (iOS + macOS Talk) start working immediately. Downside: literal in openclaw.json, or another env var to manage in the LaunchAgent plist — neither is great for a documented SecretRef target.
Repro environment notes (no PII)
Voice IDs, API keys, and account-specific paths are placeholders in the snippets above. The bug reproduces with any non-empty SecretRef pointing at a valid ElevenLabs (or OpenAI) key, on a clean LaunchAgent-managed gateway with file-backed secrets.
Summary
talk.config(the Talk-mode discovery RPC that iOS / macOS / Control UI Talk overlays call on startup to learn the configured TTS provider) throwsunresolved SecretRefwhentalk.providers.<id>.apiKeyis configured as a SecretRef object. The credential itself resolves correctly —talk.realtime.sessionandtalk.speakboth work end-to-end with the same SecretRef — but the discovery handshake fails, so clients never learn what provider is configured and silently fall back to local on-device TTS (AVSpeechSynthesizer / equivalent), producing the classic "robot voice" symptom even though everything is configured correctly.talk.providers.*.apiKeyis officially documented as a supported SecretRef target indocs/reference/secretref-credential-surface.mdand listed indocs/.generated/config-baseline.core.json. So this is a discovery / redaction-path bug, not a deliberate restriction.Environment
~/.openclaw/secrets.json,secrets.providers.secrets.source: file)Reproduction
Configure file-backed secrets with an ElevenLabs (or OpenAI Realtime) API key:
{ "secrets": { "providers": { "secrets": { "source": "file", "path": "~/.openclaw/secrets.json", "mode": "json" } }, "defaults": { "file": "secrets" } } }Configure Talk to use a SecretRef for the provider apiKey:
{ "talk": { "provider": "elevenlabs", "interruptOnSpeech": true, "providers": { "elevenlabs": { "voiceId": "<voice-id>", "modelId": "eleven_v3", "outputFormat": "mp3_44100_128", "apiKey": { "source": "file", "provider": "secrets", "id": "/skills/elevenlabs" } } } } }Restart the gateway and call the discovery RPC:
Observed:
Confirm the credential itself is healthy:
So the SecretRef IS resolved by the runtime — only the
talk.configdiscovery / redaction path fails.Open the iOS app or macOS native Talk overlay → robot voice (local AVSpeechSynthesizer fallback).
Expected behavior
talk.configshould successfully redact and return the configured Talk provider snapshot regardless of whethertalk.providers.<id>.apiKeyis a literal string or a SecretRef object. The redaction path should treat unresolved SecretRef wrappers the same way it treats string secrets — render them as__OPENCLAW_REDACTED__rather than throwing.Actual behavior
talk.configfails fast → iOS / macOS / Control UI Talk clients receive no provider snapshot → silent fallback to local on-device TTS. No surfaced error in the client UI; users see the macOS Advanced > Talk Voice panel show the right config but hear the wrong voice.Likely cause
The
talk.confighandler insrc/gateway/server-methods/talk.tsreads fromreadConfigFileSnapshot()(raw file, no SecretRef resolution) and then callsredactConfigObject(...)fromsrc/config/redact-snapshot.tson the talk payload. Somewhere down that path (likely viaassertSecretInputResolvedinsrc/config/types.secrets.ts), the redactor encounters the unresolved SecretRef wrapper and throwscreateUnresolvedSecretInputErrorinstead of redacting the value.This is the same family of regressions documented in:
botToken, exact same error string, regressed in 2026.4.15.plugins.entries.voice-call.config.tts.providers.{openai,elevenlabs}.apiKeypaths as broken.providers.<id>schema regressed.None of those track
talk.config+talk.providers.*.apiKeyspecifically.Suggested fix
In the
talk.confighandler /redactConfigObjectpath, treat unresolved SecretRef objects the same as resolved string secrets for the purpose of redaction — emit__OPENCLAW_REDACTED__(or a structured{ redacted: true }marker) instead of asserting resolution. The discovery RPC inherently runs on the raw config snapshot, so unresolved SecretRefs are an expected input shape there, not an error condition.Optional: extend the redaction visitor with explicit SecretRef handling (one branch above the assertion) to keep the strict assertion intact for paths where unresolved refs really are a bug.
Workaround
Replace the SecretRef with a literal apiKey string or
${ENV_VAR}interpolation undertalk.providers.<id>.apiKey. Both surfaces (iOS + macOS Talk) start working immediately. Downside: literal inopenclaw.json, or another env var to manage in the LaunchAgent plist — neither is great for a documented SecretRef target.Repro environment notes (no PII)
Voice IDs, API keys, and account-specific paths are placeholders in the snippets above. The bug reproduces with any non-empty SecretRef pointing at a valid ElevenLabs (or OpenAI) key, on a clean LaunchAgent-managed gateway with file-backed secrets.