Install
openclaw skills install openclaw-voice-patchPatch OpenClaw Control UI to add dual voice input buttons (auto-send + continuous). Use when OpenClaw voice input needs patching, after OpenClaw updates (pat...
openclaw skills install openclaw-voice-patchAdds smart voice input to the Control UI chat — same one mic button, but with hold-to-activate.
Before: One mic button. Speak → text appears → must press Enter manually.
After: Same one mic button — but smarter, with hold-to-activate:
"Voice (hold 3s for continuous)" when idle, "Stop voice auto" or "Stop voice continuous" when recording.Note: If the browser shows "microphone denied" errors, the microphone=(self) Permissions-Policy fix is also needed. This was merged into OpenClaw on April 18, 2026 — if you're on version 2026.4.18+, it's already included. On older versions, see the Troubleshooting section.
There is exactly one file to patch. It's always at:
.../openclaw/dist/control-ui/assets/index-<hash>.js
The hash in the filename changes with every OpenClaw version. Find it with:
Linux / Mac:
find / -path "*/openclaw/dist/control-ui/assets/index-*.js" 2>/dev/null | head -1
Windows (PowerShell):
Get-ChildItem -Path C:\ -Recurse -Filter "index-*.js" -ErrorAction SilentlyContinue | Where-Object { $_.FullName -match "openclaw\\dist\\control-ui\\assets" } | Select-Object -First 1 -ExpandProperty FullName
Quick alternative: Run npm root -g to find the global node_modules path, then look inside for openclaw/dist/control-ui/assets/index-*.js.
If you (the AI agent) cannot find or access the file, tell the user:
"I couldn't access the OpenClaw Control UI file. Can you help? Either:
- Copy the file into my workspace so I can patch it, then copy it back after. Run this in your terminal:
cp /path/to/openclaw/dist/control-ui/assets/index-*.js ~/.openclaw/workspace-allgemein/- Or give me write access:
sudo chown -R $(whoami) /path/to/openclaw/dist/"
Keep it simple. Don't over-explain.
cp <path-to-index-file> <path-to-index-file>.bak
Before patching, confirm these patterns exist in the file. If any are missing, the OpenClaw version has changed — do NOT proceed. Instead, inspect the file and adapt, or wait for an updated version of this skill.
| Pattern | What it is |
|---|---|
function NC(e) | Speech recognition start function |
sttRecording | Recording state variable |
N.micOff | Mic-off icon reference |
jC() | Speech recognition availability check |
Find this exact text:
sttRecording:!1,sttInterimText
Replace with:
sttRecording:!1,sttRecordingCont:!1,sttInterimText
This appears exactly once in the file.
Verify: Search for sttRecordingCont:!1 — must appear exactly once.
Find this exact text:
${X.sttRecording&&X.sttInterimText?i`<div class="agent-chat__stt-interim">${X.sttInterimText}</div>`:h}
Replace with:
${(X.sttRecording||X.sttRecordingCont)&&X.sttInterimText?i`<div class="agent-chat__stt-interim">${X.sttInterimText}</div>`:h}
This appears exactly once in the file.
Verify: Search for (X.sttRecording||X.sttRecordingCont) — must appear in the line containing stt-interim.
This is the most complex patch. Replace the ENTIRE mic button block with a single button that supports both short-click (auto-send) and hold-3s (continuous) modes.
Search for the text Stop recording or Voice input in the file. This is inside the mic button template.
The block to replace starts at ${jC()?i`` and ends at ``:h} — both on the same line as Stop recording/Voice input, or on the lines immediately surrounding it.
It contains exactly ONE <button> element with:
class containing agent-chat__input-btn@click handler referencing X.sttRecording and NC({title with Stop recording / Voice inputN.micOff and N.mic for the iconSelect everything from ${jC()?i`` through ``:h} inclusive and replace it with:
${jC()?i`
<button
class="agent-chat__input-btn ${(X.sttRecording||X.sttRecordingCont)?`agent-chat__input-btn--recording`:``}"
@mousedown=${(t)=>{if(X.sttRecording||X.sttRecordingCont)return;t.preventDefault();X._holdTimer=setTimeout(()=>{X._holdTimer=null;X._holdTriggered=!0;NC({onTranscript:(t,n)=>{if(n){let n=_(),r=n&&!n.endsWith(` `)?` `:``;e.onDraftChange(n+r+t),X.sttInterimText=``,e.onSend()}else X.sttInterimText=t;g()},onStart:()=>{X.sttRecordingCont=!0;window.__sttCont=MC;g()},onEnd:()=>{if(X.sttRecordingCont){let r=window.__sttCont;if(r){try{r.start()}catch(e){}}X.sttInterimText=``,g()}else{X.sttInterimText=``,g()}},onError:()=>{X.sttRecordingCont=!1,X.sttInterimText=``,g()}})&&(X.sttRecordingCont=!0,g())},3000)}}
@mouseup=${(t)=>{if(X._holdTimer){clearTimeout(X._holdTimer);X._holdTimer=null;if(!X._holdTriggered){NC({onTranscript:(t,n)=>{if(n){let n=_(),r=n&&!n.endsWith(` `)?` `:``;e.onDraftChange(n+r+t),X.sttInterimText=``,e.onSend(),PC(),X.sttRecording=!1}else X.sttInterimText=t;g()},onStart:()=>{X.sttRecording=!0;if(MC)MC.continuous=!1;g()},onEnd:()=>{X.sttRecording=!1,X.sttInterimText=``,g();const d=_();if(d&&d.trim())e.onSend()},onError:()=>{X.sttRecording=!1,X.sttInterimText=``,g()}})&&(X.sttRecording=!0,g())}}X._holdTriggered=!1}}
@click=${()=>{if(X.sttRecordingCont){PC(),X.sttRecordingCont=!1,window.__sttCont=null,X.sttInterimText=``,g()}}}
title=${X.sttRecordingCont?`Stop voice continuous`:(X.sttRecording?`Stop voice auto`:`Voice (hold 3s for continuous)`)}
?disabled=${!e.connected}
>
${(X.sttRecording||X.sttRecordingCont)?N.micOff:N.mic}
</button>
`:h}
Do NOT replace individual pieces. Replace the ENTIRE block as a single unit.
Note for the edit tool: Escape < → \u003c, > → \u003e, & → \u0026 in the replacement text.
mousedown starts a 3-second timer:
continuous=false, auto-send + auto-stop on final transcript or silence)onEnd restarts recognition via window.__sttCont.start(), text auto-sent after each pause)click (separate from mousedown/mouseup): If already in continuous mode, stops it (PC() + clear window.__sttCont).
Auto-send mode also auto-stops if the user says nothing for ~3 seconds — the browser's SpeechRecognition fires onEnd after silence when continuous=false, which acts as a natural "misclick" safeguard.
Search the file for ALL of these strings — every single one must be present:
sttRecordingCont — appears multiple times_holdTimer — hold timer variable_holdTriggered — hold flagVoice (hold 3s for continuous) — idle tooltipStop voice auto — auto-send active tooltipStop voice continuous — continuous active tooltipwindow.__sttCont — continuous restart mechanismMC.continuous=!1 — one-shot mode flagIf any are missing, restore from backup and retry from step 6.2.
Tell the user:
openclaw gateway restart)After the user has done both, verify:
If anything goes wrong: cp <path-to-index-file>.bak <path-to-index-file> and tell the user to restart the gateway.
| Problem | Likely Cause | Fix |
|---|---|---|
| No mic button at all | Browser doesn't support Web Speech API | Use Chrome or Edge |
| Button appears but does nothing on click | Step 6 not applied or partial | Restore backup, re-apply Step 6 |
| Short click starts mic but doesn't auto-send | e.onSend() missing in @mouseup handler | Restore backup, re-apply Step 6 |
| Short click mic stays on after pause | PC() or sttRecording=!1 missing | Restore backup, re-apply Step 6 |
| Hold 3s doesn't start continuous mode | _holdTimer / _holdTriggered not working | Restore backup, re-apply Step 6 |
| Continuous mode stops after silence | onEnd restart logic not working | Check window.__sttCont and __sttCont.start() in Step 6 replacement |
| Live transcript not showing | Step 5 not applied | Apply Step 5 |
| "Microphone denied" in browser | Permissions-Policy blocks mic | Fix microphone=() → microphone=(self) in the HTTP utils file (included in OpenClaw 2026.4.18+) |
| After OpenClaw update | Patches overwritten | Re-apply ALL steps (filename hash changes!) |