Build a Global Hotkey Daemon in Node.js: System-Wide Task Capture Without Leaving Your Flow
The fastest way to lose a task is to not capture it immediately.
You’re deep in a bug. A thought surfaces — something you need to do later, a name you need to look up, a follow-up you can’t forget. You think “I’ll add it in a second.” You don’t. It’s gone.
The problem isn’t motivation. It’s friction. Opening a task app, switching windows, finding the right project — that’s enough overhead to convince your brain it’s not worth it.
This tutorial builds a fix: a Node.js daemon that runs silently in the background, wakes up on Ctrl+Space from anywhere on your desktop, and shows a minimal terminal popup for you to type one thing and press Enter.
We’ll build it as part of a real command-line task manager, but every piece — the daemon pattern, the hotkey setup, the systemd service, the terminal popup — is something you can lift and apply anywhere.
What you’ll build
By the end of this tutorial you’ll have:
- A background daemon that starts automatically when you log in
- A global Ctrl+Space hotkey that works regardless of what application has focus
- A compact terminal popup with three capture modes:
task,backlog, andraw(brain dump) - Saves to the right place automatically — your active project or a catch-all inbox file
The full source is part of tasks-cli, a plain-text task manager built around TODO.md files. You don’t need to use the task manager to follow along — the daemon and hotkey pieces stand on their own.
Prerequisites
- Linux with an X11 session (Wayland support for xbindkeys is limited — see the note at the end)
- Node.js 18 or later (ES modules,
--experimental-vm-modulesnot required) xbindkeysinstalled:sudo apt install xbindkeys- Any of:
xterm,alacritty,kitty, orgnome-terminal
The architecture
Three pieces, each with one job:
┌──────────────────────────────────────────┐
│ systemd user service │
│ → starts tasks daemon on login │
│ │
│ tasks daemon │
│ → writes xbindkeys config │
│ → spawns + supervises xbindkeys │
│ │
│ xbindkeys (managed by daemon) │
│ → watches for Ctrl+Space keypress │
│ → runs: xterm -e tasks capture │
│ │
│ tasks capture (launched per keypress) │
│ → shows blessed TUI popup │
│ → saves to project TODO.md or inbox │
└──────────────────────────────────────────┘
The daemon doesn’t do the actual capturing. It does one thing: keep xbindkeys alive. When the hotkey fires, xbindkeys opens a terminal running tasks capture, which is a completely separate process. This separation means the daemon stays simple and the capture UI has no dependencies on the daemon being healthy.
Part 1: The daemon
PID files — knowing if you’re already running
A daemon needs to know if another copy of itself is already running. The standard pattern is a PID file: a text file containing the process ID, written at startup and deleted at shutdown.
// lib/daemon.js
import fs from 'fs';
import path from 'path';
import os from 'os';
const DATA_DIR = path.join(os.homedir(), '.local', 'share', 'tasks');
const PID_FILE = path.join(DATA_DIR, 'daemon.pid');
export function isDaemonRunning() {
if (!fs.existsSync(PID_FILE)) return false;
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
try {
process.kill(pid, 0); // signal 0 = existence check, no actual signal sent
return true;
} catch {
return false; // ESRCH = process doesn't exist
}
}
export function stopDaemon() {
if (!isDaemonRunning()) { console.log('not running'); return; }
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
process.kill(pid, 'SIGTERM');
}
process.kill(pid, 0) is the idiomatic way to check if a process is alive. It doesn’t actually send anything — it just asks the OS whether the PID exists and is yours to signal. If the process is gone it throws ESRCH.
The reason you need this check: if the machine crashes, the PID file is left behind with a stale PID. The next startup should detect this and proceed normally rather than falsely reporting “already running.”
Writing the xbindkeys config
xbindkeys reads a config file that maps key combos to shell commands. The format is:
"command to run"
keysym-modifier+keysym-key
We generate this file programmatically so the command contains the absolute path to our binary:
import { fileURLToPath } from 'url';
import { execFileSync } from 'child_process';
const __filename = fileURLToPath(import.meta.url);
const TASKS_BIN = path.resolve(path.dirname(__filename), '../bin/tasks.js');
const HOTKEY_CONF = path.join(os.homedir(), '.config', 'tasks', 'hotkey.xbindkeysrc');
function findTerminal(nodeExec, tasksBin, captureArg) {
const candidates = [
['alacritty', '--class', 'float,float',
'--option', 'window.dimensions.columns=66',
'--option', 'window.dimensions.lines=10',
'-e', nodeExec, tasksBin, captureArg],
['kitty', '--class', 'float',
'-e', nodeExec, tasksBin, captureArg],
['xterm', '-geometry', '66x10',
'-title', 'Quick Capture',
'-bg', '#0f172a', '-fg', '#f8fafc',
'-e', nodeExec, tasksBin, captureArg],
['gnome-terminal', '--geometry=66x10', '--', nodeExec, tasksBin, captureArg],
];
for (const [bin, ...rest] of candidates) {
try {
execFileSync('which', [bin], { stdio: 'ignore' });
return [bin, ...rest];
} catch {}
}
return null;
}
function writeHotkeyConfig(hotkey) {
const termArgs = findTerminal(process.execPath, TASKS_BIN, 'capture');
if (!termArgs) throw new Error('No terminal emulator found');
// Single-quote args that contain spaces. Double quotes would break xbindkeys'
// outer "..." delimiter and silently truncate the command — the terminal would
// open but node would receive no script path and exit instantly.
const shellCmd = termArgs.map(a => a.includes(' ') ? `'${a}'` : a).join(' ');
const config = `"${shellCmd}"\n ${hotkey}\n`;
fs.mkdirSync(path.dirname(HOTKEY_CONF), { recursive: true });
fs.writeFileSync(HOTKEY_CONF, config);
}
Three things worth noting here.
First, import.meta.url gives us the URL of the current module file. We need to convert it with fileURLToPath to get a filesystem path. This is the ES module replacement for __dirname. From there we can construct the absolute path to tasks.js reliably, regardless of where the process was invoked from or whether it was run through a symlink.
Second, the terminal list is tried in order and the first one found wins. We use execFileSync('which', [bin]) rather than spawning which bin in a shell, which avoids shell injection and is faster. The { stdio: 'ignore' } option suppresses output — we only care about the exit code.
Third, the binary path and the subcommand ('capture') are kept as separate array entries — not joined into a single string like TASKS_BIN + ' capture'. This matters because of how we build the xbindkeys command string. The xbindkeys config format wraps the entire command in double quotes: "command". If any arg inside that command also contains a double quote, the outer delimiter breaks early and xbindkeys silently executes a truncated command. The symptom: the terminal window flashes open and closes instantly, because node received no script path and exited. Keeping the args separate means none of them contain spaces, so no quoting is needed at all.
Why Ctrl+Space — and why not Left Ctrl alone
You might expect “global hotkey” to mean pressing a single key — like tapping Left Ctrl twice to bring up a capture UI, which is how some apps work.
The honest answer is that xbindkeys can’t do this, and neither can any X11 key-grab-based tool. When you press a modifier key like Ctrl by itself, X11 doesn’t generate a KeyPress event for the modifier until another key is pressed. The modifier is tracked internally, but no application can intercept it as a standalone trigger via the normal key-grab API.
The workaround is to read raw keyboard events directly from /dev/input/ (evdev), but that requires the user to be in the input group. For a general-purpose tool, that’s an unnecessary installation burden.
Ctrl+Space is a common capture shortcut — Notion, Obsidian, Raycast on Mac all use something similar — and nothing in a typical Linux development environment uses it by default.
Supervising xbindkeys
The daemon’s main loop spawns xbindkeys and restarts it if it exits unexpectedly:
export function daemonMain({ hotkey = 'control+space' } = {}) {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(PID_FILE, String(process.pid));
writeHotkeyConfig(hotkey);
// Kill any leftover xbindkeys from a previous daemon instance
try { execFileSync('pkill', ['-x', 'xbindkeys'], { stdio: 'ignore' }); } catch {}
let xbk;
let stopping = false;
function startXbindkeys() {
if (stopping) return;
// -n = no-daemon (foreground); -f = config file path
xbk = spawn('xbindkeys', ['-n', '-f', HOTKEY_CONF], {
stdio: ['ignore', 'pipe', 'pipe'],
});
xbk.stdout.on('data', d => log(d.toString().trim()));
xbk.stderr.on('data', d => log(d.toString().trim()));
xbk.on('exit', code => {
if (stopping) return;
log(`xbindkeys exited (${code ?? 'signal'}), restarting in 3s`);
setTimeout(startXbindkeys, 3000);
});
}
startXbindkeys();
function shutdown() {
stopping = true;
if (xbk) try { xbk.kill(); } catch {}
try { fs.unlinkSync(PID_FILE); } catch {}
process.exit(0);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
// Keep the event loop alive — without this Node exits immediately
setInterval(() => {}, 60 * 60 * 1000);
}
The stopping flag prevents the restart loop from firing after an intentional shutdown. Without it: you call tasks daemon stop, the daemon kills xbindkeys and exits, the exit handler fires and schedules a restart 3 seconds later — but the daemon is already dead and that restart never runs. Actually that’s fine. But without stopping, the shutdown sequence is: send SIGTERM → shutdown() runs → kills xbk → exit handler fires → schedules restart → then we call process.exit(0) before the timeout fires. Still fine. But with stopping = true you make the intent explicit and avoid subtle ordering issues if you ever add cleanup code between the kill and the exit.
The setInterval at the end is essential. Node.js exits when the event loop has nothing to wait for. Since xbindkeys is a child process managed by the OS, Node won’t automatically stay alive watching it. The interval is a keepalive with a 1-hour tick — it never actually runs any meaningful code, it just prevents the process from exiting.
Part 2: The capture UI
The capture UI is a completely separate command — tasks capture. It’s what the terminal emulator runs when xbindkeys fires the hotkey.
// lib/capture.js
import blessed from 'blessed';
import fs from 'fs';
import path from 'path';
import os from 'os';
const INBOX_FILE = path.join(os.homedir(), '.local', 'share', 'tasks', 'inbox.md');
const today = new Date().toISOString().slice(0, 10);
const MODES = ['task', 'backlog', 'raw'];
const C = {
bg: '#0f172a',
accent: '#6366f1',
dim: '#475569',
fg: '#f8fafc',
modes: { task: '#6366f1', backlog: '#94a3b8', raw: '#f59e0b' },
};
The UI layout
export function launchCapture() {
let modeIdx = 0;
const screen = blessed.screen({
smartCSR: true, title: 'Quick Capture', fullUnicode: true,
});
const box = blessed.box({
top: 'center', left: 'center', width: 66, height: 7,
border: { type: 'line' },
style: { bg: C.bg, border: { fg: C.accent } },
tags: true,
});
const modeBar = blessed.box({
parent: box, top: 0, left: 1, right: 1, height: 1,
style: { bg: C.bg }, tags: true,
});
const input = blessed.textbox({
parent: box, top: 2, left: 2, right: 2, height: 1,
style: { bg: C.bg, fg: C.fg },
inputOnFocus: true,
});
const hint = blessed.box({
parent: box, top: 4, left: 1, right: 1, height: 1,
style: { bg: C.bg }, tags: true,
});
screen.append(box);
The layout renders as:
┌─── Quick Capture ──────────────────────────────────────┐
│ [task] backlog raw │
│ │
│ > _ │
│ │
│ Tab: cycle Enter: save → my-project Esc: cancel │
└────────────────────────────────────────────────────────┘
The mode bar at the top shows which mode is active (highlighted) and the border color changes to match — indigo for task, gray for backlog, amber for raw.
Mode cycling and saving
input.key('tab', () => {
modeIdx = (modeIdx + 1) % MODES.length;
render(); // updates modeBar + border color + hint text
});
input.on('submit', val => {
const text = val?.trim();
if (text) save(text, MODES[modeIdx]);
screen.destroy();
process.exit(0);
});
input.on('cancel', () => {
screen.destroy();
process.exit(0);
});
screen.key(['escape', 'C-c'], () => {
screen.destroy();
process.exit(0);
});
The save function decides where to write based on the current mode:
function save(text, mode) {
if (mode === 'raw') {
appendToInbox(text);
return;
}
const proj = activeProject(); // first pinned, or first registered
if (!proj) { appendToInbox(`[${mode}] ${text}`); return; }
const p = todoPath(proj.dir);
const content = readFile(p) ?? initTodo(proj.name);
const section = mode === 'task' ? 'current' : 'backlog';
writeFile(p, addTask(content, text, section));
}
Three outcomes:
task→ writes to## Currentin the active project’sTODO.mdbacklog→ writes to## Backlograw→ appends a timestamped line to~/.local/share/tasks/inbox.md
The inbox is intentionally unstructured. It’s a brain dump. You process it later when you have time, promoting things to real tasks.
Part 3: The systemd user service
User services vs system services
Most systemd tutorials show system services — daemons that run as root, start at boot, and are managed with sudo systemctl. User services are different:
- They run as your user, with your permissions
- They start when you log in, not at boot
- They’re managed with
systemctl --user(no sudo) - They live in
~/.config/systemd/user/
For a desktop tool that needs to open X11 windows, a user service is exactly right. A system service running as root has no X11 session to connect to and couldn’t open a terminal window even if it wanted to.
Writing the unit file
// lib/service.js
const unit = `[Unit]
Description=Tasks CLI — hotkey capture daemon
After=graphical-session.target
PartOf=graphical-session.target
[Service]
Type=simple
ExecStart=${process.execPath} ${TASKS_BIN} daemon --hotkey ${hotkey}
Restart=on-failure
RestartSec=3s
Environment=HOME=${os.homedir()}
PassEnvironment=DISPLAY XAUTHORITY
[Install]
WantedBy=graphical-session.target
`;
After=graphical-session.target means systemd waits until the X session is established before starting our daemon. Without this, the daemon starts before X11 is ready, xbindkeys can’t connect to the display, and it immediately exits.
PassEnvironment=DISPLAY XAUTHORITY is the critical detail most tutorials miss. Systemd user services don’t automatically inherit your shell environment. Even though $DISPLAY is set to :0 in your terminal, the service starts in a clean environment where $DISPLAY doesn’t exist. xbindkeys needs $DISPLAY to know which X server to connect to — without it, it exits with an error.
PassEnvironment tells systemd to forward those specific variables from the user’s session environment into the service. But there’s a catch: you also need to tell systemd what those variables are before the service starts. We do that at install time:
execSync('systemctl --user import-environment DISPLAY XAUTHORITY');
This registers the current values so they’re available to future service starts.
Installing the service
export function installService(hotkey = 'control+space') {
fs.mkdirSync(SYSTEMD_DIR, { recursive: true });
fs.writeFileSync(SERVICE, unit);
execFileSync('systemctl', ['--user', 'daemon-reload']);
execFileSync('systemctl', ['--user', 'enable', 'tasks-daemon']);
execFileSync('systemctl', ['--user', 'start', 'tasks-daemon']);
}
daemon-reload is required after writing a new unit file — without it, systemd doesn’t know the file exists. enable creates the symlink that makes the service start on login. start starts it immediately without waiting for the next login.
Part 4: Wiring it into the CLI
Our existing CLI dispatch was a synchronous switch statement. The new commands use lib/daemon.js, lib/capture.js, and lib/service.js — and we want to import them lazily (only load what’s needed for the current command).
That requires await import(...), which means the dispatch function needs to be async:
// bin/tasks.js
const [,, cmd, ...args] = process.argv;
async function main() {
switch (cmd) {
// ... existing sync commands ...
case 'capture': {
const { launchCapture } = await import('../lib/capture.js');
launchCapture();
break;
}
case 'daemon': {
const { daemonMain, stopDaemon, isDaemonRunning, getDaemonPid } =
await import('../lib/daemon.js');
if (args[0] === 'stop') {
stopDaemon();
} else if (args[0] === 'status') {
console.log(isDaemonRunning()
? `running (pid ${getDaemonPid()})`
: 'not running');
} else {
const hotkeyIdx = args.indexOf('--hotkey');
const hotkey = hotkeyIdx >= 0 ? args[hotkeyIdx + 1] : 'control+space';
daemonMain({ hotkey });
}
break;
}
case 'service': {
const { installService, uninstallService, serviceStatus } =
await import('../lib/service.js');
const hotkeyIdx = args.indexOf('--hotkey');
const hotkey = hotkeyIdx >= 0 ? args[hotkeyIdx + 1] : 'control+space';
if (args[0] === 'install') installService(hotkey);
else if (args[0] === 'uninstall') uninstallService();
else if (args[0] === 'status') serviceStatus();
break;
}
}
}
main();
The reason this matters beyond just “make it async”: dynamic import() in a top-level switch — not inside an async function — is valid syntax but won’t work the way you expect. The import returns a promise, and without await, you get a Promise object rather than the module. Wrapping in async main() is the correct pattern for a CLI that needs lazy loading.
Putting it all together
# Clone the repo
git clone https://github.com/techiediaries/tasks-cli
cd tasks-cli && npm install
npm link
# Initialize a project
mkdir ~/myproject && cd ~/myproject
tasks init
# Test the capture UI directly first
tasks capture
# → Tab to cycle modes, type something, Enter to save
# Install the daemon as a system service
tasks service install
# Verify it's running
tasks daemon status
# → running (pid 12345)
# Check the log if something looks wrong
tail -f ~/.local/share/tasks/daemon.log
# Now press Ctrl+Space from anywhere on your desktop
When you press Ctrl+Space, xbindkeys fires, a small terminal window appears at the center of your screen, you type, press Enter, and the window closes. If you chose raw mode, the text lands in ~/.local/share/tasks/inbox.md. If you chose task or backlog, it goes straight into your project’s TODO.md.
What to do on Wayland
xbindkeys uses X11 key grabs, which don’t work natively on Wayland compositors (Sway, GNOME on Wayland, KDE Plasma on Wayland). On those systems:
- GNOME: use
gsettingsor the keyboard shortcuts settings panel to bindCtrl+Spaceto runtasks capturein a terminal - Sway / i3wm: add a
bindsymline to your config:bindsym ctrl+space exec xterm -geometry 66x10 -e tasks capture - KDE Plasma: Settings → Shortcuts → Custom Shortcuts
In those cases you don’t need tasks service install — just run tasks daemon without the xbindkeys piece, or skip the daemon entirely and use the DE’s native shortcut system.
Going further
The capture UI deliberately has no project picker. It targets the pinned project and falls back to inbox. That’s a constraint, not an oversight — showing a project list adds a decision, which adds friction, which defeats the purpose.
But if you want to extend it, here are natural next steps:
- Process the inbox:
tasks inboxcommand that shows inbox items and lets you promote them to projects interactively - Fuzzy project switcher: open a list only when you hold Shift+Enter instead of Enter
- Notification on save:
notify-sendto flash a desktop notification confirming where it was saved - Wayland-native hotkey: detect the session type at install time and configure the right mechanism automatically
The source is at github.com/techiediaries/tasks-cli. The daemon feature lives on the feat/daemon-capture branch.
-
Date:
