Introduction
I built a tiny voice‑controlled home automation server for my own website. The goal was simple: speak a command, have the server execute a safe action (turn a light on, start a backup, etc.). The tricky part is making sure the spoken text can’t be turned into an arbitrary shell command. My solution is a two‑step filter – a strict regular‑expression whitelist first, then a language‑model (LLM) to fill in the details.
Why regex first?
Regular expressions give me a deterministic gate. Anything that doesn’t match a known pattern is rejected before it ever reaches the LLM. This prevents prompt injection attacks where a user tries to sneak a malicious instruction into the natural‑language prompt. In my experience the overhead of a few regex checks is negligible compared to the round‑trip to the OpenAI API.
Designing the command grammar
I started by listing the actions I actually want to expose:
light <room> on|offbackup nowstatus <service>
From that list I wrote a single regex that captures the intent and the arguments:
// commandRegex.js export const commandRegex = /^(light)\s+(living|kitchen|bedroom)\s+(on|off)$|^(backup)\s+now$|^(status)\s+(nginx|postgres)$/i; The pattern is deliberately simple – each branch matches one of the allowed commands. If a spoken phrase doesn’t match, the request is dropped with a 400 response.
Hooking up the speech recognizer
On the client side I use the Web Speech API because it works in Chrome without any extra libraries. The recognizer sends the raw transcript to my Node server via a POST request.
// public/voice.js const btn = document.getElementById('talk'); const recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)(); recognition.lang = 'en-US'; recognition.interimResults = false; btn.onclick = () => recognition.start(); recognition.onresult = e => { const transcript = e.results[0][0].transcript.trim(); fetch('/api/command', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({text: transcript}) }) .then(r => r.json()) .then(console.log); }; The server receives {"text":"light kitchen on"} and runs it through the regex.
Using a language model for intent
Once the regex validates the shape, I still need to translate the captured groups into concrete parameters (e.g., which GPIO pin controls the kitchen light). I keep that mapping in a JSON file and ask the LLM to produce the final command string. The prompt is static and never includes user‑provided text directly.
// intentResolver.js import {Configuration, OpenAIApi} from 'openai'; import {commandRegex} from './commandRegex.js'; import pinMap from './pinMap.json'; const openai = new OpenAIApi(new Configuration({apiKey: process.env.OPENAI_API_KEY})); export async function resolveIntent(rawText) { const match = rawText.match(commandRegex); if (!match) throw new Error('Invalid command'); // Extract intent and args from the regex groups const [_, intent, room, state, backup, statusService] = match; // Build a deterministic prompt – no user text interpolation const prompt = `You are a helper that converts structured intent data into a shell command. Intent: ${intent || backup || statusService} Parameters: ${JSON.stringify({room, state, service: statusService})} Return only the command, nothing else.`; const response = await openai.createChatCompletion({ model: 'gpt-4o-mini', messages: [{role: 'user', content: prompt}], temperature: 0, }); const command = response.data.choices[0].message.content.trim(); return command; } Because the prompt never contains the raw transcript, the LLM cannot be tricked into echoing back malicious text. The only variable data it sees are the intent name and a sanitized JSON object.
Safety checks
Even after the LLM returns a command, I run a final whitelist of allowed shell snippets. This is a second line of defense in case the model hallucinates.
// safeCommands.js export const safeCommands = [ /^gpio write \d+ (1|0)$/, // turn a pin on/off /^rsync -avz .* backup$/, // run a backup /^systemctl status (nginx|postgres)$/ ]; export function isSafe(cmd) { return safeCommands.some(rx => rx.test(cmd)); } If isSafe returns false, the request is logged and rejected.
Putting it together
The Express route ties all pieces together:
// server.js import express from 'express'; import {resolveIntent} from './intentResolver.js'; import {isSafe} from './safeCommands.js'; import {exec} from 'child_process'; const app = express(); app.use(express.json()); app.post('/api/command', async (req, res) => { const {text} = req.body; try { const cmd = await resolveIntent(text); if (!isSafe(cmd)) throw new Error('Command failed safety check'); exec(cmd, (err, stdout, stderr) => { if (err) return res.status(500).json({error: err.message}); res.json({output: stdout || stderr}); }); } catch (e) { console.error(e); res.status(400).json({error: e.message}); } }); app.listen(3000, () => console.log('Voice command server listening on :3000')); All the heavy lifting is deterministic: regex → intent → LLM prompt → safety whitelist → exec.
Deployment considerations
I run this server on a Raspberry Pi at home, but the same code works on any Linux box. A few practical tips:
- Environment isolation – use a systemd service that runs the Node process as a non‑root user.
- Rate limiting – the Web Speech API can be noisy; a simple token bucket middleware prevents abuse.
- Logging – store the raw transcript, the resolved command, and the safety check result. This makes debugging prompt‑injection attempts easier.
- Model cost – because the prompt is tiny and temperature is 0, the cost per request is a few fractions of a cent.
Conclusion
By front‑loading a strict regex filter, I keep the attack surface small enough that the LLM can be used safely for natural‑language expansion. The pattern works for any limited command set: define a grammar, whitelist the resulting shell, and let the model fill in the boring details. I’ve been running this for months with no false positives, and the approach scales nicely when I add new devices – just extend the regex and the safe‑command list.
I build sites and automations for small businesses at dreamfuturestech.com.
