Config File

The Capslox keymap is defined in a single file: keymap.jsonc.

JSONC = JSON with Comments. You can add // line comments and /* */ block comments. (New to JSON? See MDN's quick intro.)

File location

Platform Path
macOS ~/.config/capslox/keymap.jsonc
Windows %USERPROFILE%\.config\capslox\keymap.jsonc

Hot-reload: Save the file and changes apply instantly. No restart needed. If the file has syntax errors, Capslox shows an error dialog and keeps the previous config.

OS-specific override

If you sync your config directory across operating systems (e.g. via iCloud Drive or Dropbox), you can place an OS-specific override alongside the main file:

Platform Override file
macOS keymap.macos.jsonc
Windows keymap.windows.jsonc

When Capslox starts, it uses the platform-specific file if it exists, otherwise it falls back to keymap.jsonc. This is useful when you manage your config directory across operating systems — for example via iCloud Drive, Dropbox, GNU Stow, or chezmoi — and need different bindings per platform.

Priority: keymap.macos.jsonc / keymap.windows.jsonc > keymap.jsonc


Schema Overview

Top-level structure:

{
  "version": "1.0",
  "custom_modifiers": { "caps": { "alias": "cx", "tap": "esc" } },
  "vars": {
    "apps_emacs_keybindings": ["com.apple.Terminal", "com.googlecode.iterm2"]
  },
  "layers": [
    {
      "id": "base",
      "on_miss": "pass_to_os",
      "bindings": {
        "cx-e": "up",
        "cx-d": "down",
        "cx-a": {
          "action": "send_keys",
          "keys": "opt-left",
          "when": { "apps": { "$ref": "/vars/apps_emacs_keybindings" }, "action": "ctrl-[ b" }
        }
      }
    }
  ]
}

A config has:

  • version — schema version string (e.g. "1.0").
  • custom_modifiers — optional. Maps physical keys to modifier aliases. The default config uses "cx" (short for Capslox). See Custom Modifiers.
  • vars — optional. Define reusable values (arrays, strings) referenced elsewhere with { "$ref": "/vars/name" }. Useful for app ID lists shared across multiple bindings.
  • layers — array of layers. Each layer has id, optional name, optional activation, on_miss, and bindings.

Layers

A layer is a named group of bindings. Layers form a stack. When a key is pressed, the engine searches top-down: the first matching binding wins. When a layer has no match, its on_miss field decides what happens next.

Each layer is an object with these fields:

  • id (required) — unique layer identifier.
  • bindings (required) — map of trigger keys to actions.
  • on_miss (required) — miss behavior; see Miss Behavior.
  • activation — how the layer activates. Base layers don't need this; manual layers use { "type": "manual" }; auto-app layers use { "type": "auto_app", "bundle_ids": [...] }. See the sections below.
  • name — human-readable label, shown in UI surfaces like Preferences → Keymap.
  • enabled — boolean, defaults to true. When false, the engine skips this layer entirely, as if it didn't exist. Handy for temporarily disabling a layer without deleting it.

Base Layer

Always active. Cannot be pushed or popped at runtime. Identified by "id": "base" — no explicit activation field needed.

{
  "id": "base",
  "on_miss": "pass_to_os",
  "bindings": {
    "cx-h": "left",
    "cx-j": "down",
    "cx-k": "up",
    "cx-l": "right"
  }
}

Manual Layer

Activated by layer_push, layer_toggle, or layer_swap actions from bindings. Deactivated by layer_pop or the corresponding release event.

{
  "id": "vim_normal",
  "activation": { "type": "manual" },
  "on_miss": "block",
  "bindings": {
    "h": "left",
    "j": "down",
    "k": "up",
    "l": "right",
    "i": { "action": "layer_pop", "target": "vim_normal" },
    "esc": "continue"
  }
}

In this example, on_miss is "block" — unbound keys are blocked, making it behave like Vim normal mode. The "esc": "continue" binding lets esc fall through to the next layer (bypassing the block).

AutoApp Layer

Activates automatically when a matching app gains focus. Deactivates when the app loses focus.

{
  "id": "terminal",
  "name": "Terminal Overrides",
  "activation": {
    "type": "auto_app",
    "bundle_ids": ["com.apple.Terminal", "com.googlecode.iterm2"]
  },
  "on_miss": "continue",
  "bindings": {
    "cx-a": "ctrl-a",
    "cx-e": "ctrl-e"
  }
}

App identifiers

Platform Format Example
macOS Bundle ID com.apple.Terminal
Windows Executable name WindowsTerminal.exe

Wildcard matching: the * in an app identifier matches one or more dot-bounded path segments. "com.jetbrains.*" matches com.jetbrains.intellij and com.jetbrains.pycharm, but NOT com.jetbrainsxyz (because xyz isn't a separate segment). This dot-boundary rule applies everywhere apps lists are used, including when clauses on bindings.


Bindings

A binding maps a trigger key to one or more actions. Bindings live inside a layer's bindings object. The key is the trigger (using key syntax), the value is the action or binding type.

"bindings": {
  "cx-h": "left",
  "cx-j": "down",
  "caps": { "type": "mod_tap", "tap": "esc" },
  "space": {
    "type": "long_press",
    "short": "space",
    "long": { "action": "layer_push", "target": "space_layer" },
    "threshold_ms": 500
  }
}

The binding value's "type" field declares the binding type (e.g. "type": "mod_tap", "type": "long_press"). When the binding type is "action" (a direct action — the most common case), the "action" field then names the specific action to perform (e.g. "action": "send_keys", "action": "layer_push").

Binding Value Forms

A binding value is, in full, an object. When type is "action", the object also carries an action field naming the action type.

Three equivalent forms

"type": "action" and "action": "send_keys" appear frequently across bindings. For convenience, Capslox lets you omit them — all three forms below produce the same binding:

"cx-h": { "type": "action", "action": "send_keys", "keys": "left" }  // full form
"cx-h": { "action": "send_keys", "keys": "left" }                    // omit type (action is the implied binding type)
"cx-h": "left"                                                       // omit the whole object (send_keys is the implied action) — recommended

Mod Tap & Long Press

Trigger different actions based on how the key is pressed. The "type" field must be specified:

"caps": { "type": "mod_tap", "tap": "esc" }
"space": { "type": "long_press", "short": "space", "long": { "action": "layer_push", "target": "space_layer" } }

See Mod Tap and Long Press for details.

Directive keywords

Three special strings are not key syntax — they control per-key miss behavior within a layer:

  • "continue" — check the next layer (ignores this key's layer on_miss)
  • "block" — swallow the key
  • "pass_to_os" — skip remaining layers, send to OS
// In a layer with on_miss: "block", let Escape pass through to the next layer
"esc": "continue"

Which form to use

Scenario Form
Send key presses "left" (string shorthand)
Pass through / block / send to OS "continue" / "block" / "pass_to_os" (directive keywords)
Layer control, clipboard, window mgmt { "action": "layer_toggle", ... } (action object)
Need when condition Action object + when
Hold for modifier, tap for Esc { "type": "mod_tap", ... } (key mode)
Long press to push layer { "type": "long_press", ... } (key mode)

Mod Tap

Hold for modifier, tap for action. Dual-purpose key.

Parameters:

  • tap — any action (string shorthand or object form).
  • timeout_ms — tap validity window in milliseconds. Default: 200.

Example:

"caps": { "type": "mod_tap", "tap": "esc", "timeout_ms": 200 }

Resolution: Interrupt-based, not duration-based.

Condition Result
Release within timeout, no other key pressed Tap — fires the tap action
Other key pressed while held (any time) Hold — activates as modifier
Release after timeout, no other key pressed Nothing — prevents accidental taps

The hold modifier identity is inferred: if the key has a custom_modifiers entry, that alias is used. Otherwise, the physical key name itself becomes the modifier.

Long Press

Short press for one action, long press (hold past threshold) for another. Pure duration-based — no other key interaction needed.

Parameters:

  • short — action when released before threshold.
  • long — action when held past threshold.
  • threshold_ms — duration threshold in milliseconds. Default: 500.

Example:

"space": {
  "type": "long_press",
  "short": "space",
  "long": { "action": "layer_push", "target": "space_layer" },
  "threshold_ms": 500
}

When Clause

Make a binding behave differently based on the frontmost app. Add a when field to any direct binding.

// Single condition
"cx-a": {
  "action": "send_keys",
  "keys": "opt-left",
  "when": {
    "apps": ["com.apple.Terminal", "com.googlecode.iterm2"],
    "action": "ctrl-[ b"
  }
}

When the frontmost app matches one of the apps bundle IDs, the when action is used instead of the default action.

Using $ref for shared app lists

"cx-a": {
  "action": "send_keys",
  "keys": "opt-left",
  "when": {
    "apps": { "$ref": "/vars/apps_emacs_keybindings" },
    "action": "ctrl-[ b"
  }
}

This references the apps_emacs_keybindings array defined in the top-level vars object.

Multiple conditions

Checked in order, first match wins:

"cx-cmd-a": {
  "action": "send_keys",
  "keys": "opt-left*5",
  "when": [
    {
    "apps": { "$ref": "/vars/apps_emacs_keybindings" },
      "action": "(ctrl-[ b)*5"
    },
    {
      "apps": ["com.jetbrains.*"],
      "action": "alt-left*5"
    }
  ]
}

Wildcards in apps use the same dot-boundary matching defined under AutoApp Layer.

If no when clause matches, the binding's default action is used.

Special when actions

Use "noop" as a when action to suppress a key in specific apps without letting it fall through:

"cx-i": {
  "action": "send_keys",
  "keys": "shift-up",
  "when": {
    "apps": { "$ref": "/vars/apps_emacs_keybindings" },
    "action": "noop"
  }
}

Relationship to AutoApp layers: when is for "same trigger key, different output per app." AutoApp layers are for "completely different binding set in a specific app." Both can coexist.

Miss Behavior

The on_miss field on each layer controls what happens when a key has no matching binding in that layer.

Value Behavior Typical use
"continue" Check the next layer in the stack (default) Overlay layers (e.g. app-specific overrides) that handle a few keys and let the rest fall through
"block" Block the key — it does nothing Manually-activated layers (game maps, IDE command modes) where any unbound key should be swallowed to avoid mistakes
"pass_to_os" Skip remaining layers, send the original key to the OS The base layer, so unbound keys still reach the OS normally
{
  "id": "vim_normal",
  "activation": { "type": "manual" },
  "on_miss": "block",
  "bindings": { ... }
}

Individual bindings can ignore the layer's on_miss for that key using directive keywords:

// Even though on_miss is "block", Escape falls through to the next layer
"esc": "continue"

// Force a specific key to pass directly to OS, bypassing all remaining layers
"f5": "pass_to_os"

See Binding Value Forms above for the full list of directive keywords.


Actions

Every binding triggers an action. Actions use the "action" field as the type discriminator. Actions appear on the value side of bindings and inside key mode fields (tap, short, long).

Available actions

Capslox General

Action Description
show_preferences Open Capslox preferences
edit_keymap Open the built-in keymap editor

Layer switching

Action Description
layer_push Push a layer (deactivates on trigger key release)
layer_pop Pop a layer (or the top of stack)
layer_toggle Toggle a layer on or off
layer_swap Replace the top non-base layer with another

Composition & control

Action Description
shell_action Run a shell command
action_sequence Execute multiple actions in order
noop Suppress the key (do nothing)

Input & state

Action Description
send_keys Send key presses
toggle_caps_lock Toggle Caps Lock on or off

Window

Action Description
bind_window Bind the current window to a slot
activate_window Activate or minimize the window bound to a slot

Clipboard

Action Description
clipboard_copy Copy to a named clipboard slot
clipboard_cut Cut to a named clipboard slot
clipboard_paste Paste from a named clipboard slot
clipboard_paste_plain Paste as plain text from a clipboard slot

show_preferences

Open the Capslox preferences window.

Example:

"cx-shift-,": { "action": "show_preferences" }

edit_keymap

Open the built-in keymap editor.

Example:

"cx-shift-/": { "action": "edit_keymap" }

layer_push

Push a layer onto the stack. The layer deactivates automatically when the trigger key is released.

Parameters:

  • target (string) — layer id to push.

Example:

"cx-v": { "action": "layer_push", "target": "vim_normal" }

layer_pop

Pop a layer from the stack.

Parameters:

  • target (string, optional) — layer id to remove. Omit to pop the top; specify to remove that id from anywhere in the stack.

Example:

"i":   { "action": "layer_pop", "target": "vim_normal" }
"esc": { "action": "layer_pop" }

layer_toggle

Toggle a layer: activate it if currently inactive, deactivate it if currently active.

Parameters:

  • target (string) — layer id to toggle.

Example:

"cx-t": { "action": "layer_toggle", "target": "symbols" }

layer_swap

Atomically remove the top non-base layer and push the target layer in its place. If only the base layer is active, swap pushes the target as a new top layer.

Parameters:

  • target (string) — layer id to push as the new top.

Example:

"cx-s": { "action": "layer_swap", "target": "numbers" }

shell_action

Run a shell command. Fire-and-forget — the command runs in the background without capturing output.

Parameters:

  • command (string) — shell command to run.

Example:

// ───── macOS (POSIX shell, /bin/sh) ─────

// Launch apps, URLs, folders
"cx-cmd-t":   { "action": "shell_action", "command": "open -a Terminal" }
"cx-shift-c": { "action": "shell_action", "command": "open -a 'Google Chrome'" }
"cx-shift-g": { "action": "shell_action", "command": "open https://github.com" }
"cx-shift-h": { "action": "shell_action", "command": "open ~" }
"cx-shift-p": { "action": "shell_action", "command": "code ~/path/to/your/repo" }

// Read / write the clipboard
"cx-shift-s": { "action": "shell_action", "command": "open \"https://www.google.com/search?q=$(pbpaste)\"" }
"cx-shift-i": { "action": "shell_action", "command": "open \"$(pbpaste)\"" }
"cx-shift-d": { "action": "shell_action", "command": "date | pbcopy" }

// Screenshot, sleep, notify
"cx-shift-4": { "action": "shell_action", "command": "screencapture -i ~/Desktop/screen-$(date +%Y%m%d-%H%M%S).png" }
"cx-shift-l": { "action": "shell_action", "command": "pmset displaysleepnow" }
"cx-shift-b": { "action": "shell_action", "command": "osascript -e 'display notification \"Build done\" with title \"Capslox\"'" }

// Toggle macOS dark mode
"cx-shift-m": {
  "action": "shell_action",
  "command": "osascript -e 'tell app \"System Events\" to tell appearance preferences to set dark mode to not dark mode'"
}

// ───── Windows (cmd.exe) — escape backslashes as \\ in JSON ─────

// Launch URLs, folders, files (`start` opens the target with its default handler)
"cx-shift-g": { "action": "shell_action", "command": "start \"\" https://github.com" }
"cx-shift-h": { "action": "shell_action", "command": "explorer \"%USERPROFILE%\"" }
"cx-shift-i": { "action": "shell_action", "command": "start \"\" \"%USERPROFILE%\\Documents\\notes.md\"" }
"cx-shift-p": { "action": "shell_action", "command": "code \"%USERPROFILE%\\projects\\myrepo\"" }

// Copy a literal string to the clipboard (`clip` is built into Windows)
"cx-shift-c": { "action": "shell_action", "command": "echo Hello from Capslox| clip" }

// Lock the screen
"cx-shift-l": { "action": "shell_action", "command": "rundll32.exe user32.dll,LockWorkStation" }

How it works

OS Shell Command
macOS /bin/sh (login shell) /bin/sh -l -c "{command}"
Windows cmd.exe cmd /C {command}
  • Runs from your home directory ($HOME on macOS, %USERPROFILE% on Windows).
  • On macOS, /bin/sh is a POSIX shell (typically bash in sh-compatibility mode on most systems). It does not use your default shell (e.g. fish, zsh) — it always invokes /bin/sh.
  • On Windows, cmd.exe is not bash — it does not support bash's $(...) command substitution, POSIX glob expansion, or single-quote string syntax. If you need a pipeline that consumes a dynamic value (e.g. clipboard contents), call PowerShell explicitly: powershell -NoProfile -Command "<pipeline>".
  • PATH and environment:
    • macOS: /bin/sh -l reads .profile / .bash_profile. If your everyday shell is fish or zsh and you set PATH there, commands like npm, cargo, or code may not be found by shell_action unless they are also available in /bin/sh's PATH.
    • Windows: cmd.exe does not read any shell rc file. PATH comes from the System and User environment variables (Settings → "Edit the system environment variables"). After adding a tool to PATH, restart Capslox so the new value is picked up.
  • No output is captured. Exit codes are logged internally (ShellAction exited with code N) if the command fails.
  • Maximum one-shot: the command is spawned and forgotten. There is no way to kill or wait for it from Capslox.

Not suitable for interactive commands, long-running watchers, or commands that depend on a specific shell environment.


action_sequence

Execute multiple actions in order. Use this when no single action type covers what you need.

Parameters:

  • actions (array) — non-empty list of actions. Each element can be a string shorthand (resolves to send_keys) or a full action object.

Example:

// Put the current date on the clipboard, then paste it.
"cx-shift-d": {
  "action": "action_sequence",
  "actions": [
    { "action": "shell_action", "command": "date | pbcopy" },
    "cmd-v"
  ]
}

noop

Suppress the key. Nothing happens.

Example:

"cx-q": "noop"

Useful in overlay layers to block specific keys without letting them fall through. Also commonly used in when clauses to suppress a binding in specific apps where the default action has no meaningful equivalent.


send_keys

Send one or more key events. The most common action type.

Parameters:

  • keys (string) — a key syntax string. One or more combos, with optional repeat and grouping. See key syntax below.

Example:

// String shorthand (recommended — send_keys is the default action, so the whole object can be omitted)
"cx-h":     "left"
"cx-cmd-a": "alt-left*5"

// Object form (required when you need a `when` clause)
"cx-a": {
  "action": "send_keys",
  "keys": "opt-left",
  "when": { "apps": { "$ref": "/vars/apps_emacs_keybindings" }, "action": "ctrl-[ b" }
}

Key syntax

  • Single combo: left, super-shift-4.
  • Multi-combo (space-separated, executed in sequence): super-shift-left backspace.
  • Repeat (*N after a combo): alt-left*5.
  • Group repeat ((combos)*N): (ctrl-[ b)*5.

Rules:

  • *N without parens repeats the immediately preceding combo only.
  • (combos)*N groups combos and repeats the entire group.
  • No nesting: ((...)...)*N is invalid.
  • Maximum repeat: 100.

Common combos

Excerpted from the default keymap, ready to copy.

macOS:

// Cursor movement
"cx-e":     "up"
"cx-d":     "down"
"cx-s":     "left"
"cx-f":     "right"
"cx-a":     "opt-left"       // word back
"cx-g":     "opt-right"      // word forward
"cx-p":     "cmd-left"       // line start
"cx-;":     "cmd-right"      // line end
"cx-cmd-p": "cmd-up"         // document start
"cx-cmd-;": "cmd-down"       // document end

// Text selection
"cx-i":     "shift-up"       // select line up
"cx-k":     "shift-down"     // select line down
"cx-j":     "shift-left"     // select char left
"cx-l":     "shift-right"    // select char right
"cx-h":     "shift-opt-left" // select word back
"cx-.":     "shift-opt-right"// select word forward
"cx-u":     "shift-cmd-left" // select to line start
"cx-o":     "shift-cmd-right"// select to line end

// Deletion
"cx-w":     "backspace"      // delete char back
"cx-r":     "delete"         // delete char forward
"cx-cmd-w": "opt-backspace"  // delete word back
"cx-cmd-r": "opt-delete"     // delete word forward

Windows:

// Cursor movement
"cx-e":     "up"
"cx-d":     "down"
"cx-s":     "left"
"cx-f":     "right"
"cx-a":     "ctrl-left"      // word back
"cx-g":     "ctrl-right"     // word forward
"cx-p":     "home"           // line start
"cx-;":     "end"            // line end
"cx-alt-p": "ctrl-home"      // document start
"cx-alt-;": "ctrl-end"       // document end

// Text selection
"cx-i":     "shift-up"
"cx-k":     "shift-down"
"cx-j":     "shift-left"
"cx-l":     "shift-right"
"cx-h":     "shift-ctrl-left"  // select word back
"cx-.":     "shift-ctrl-right" // select word forward
"cx-u":     "shift-home"       // select to line start
"cx-o":     "shift-end"        // select to line end

// Deletion
"cx-w":     "backspace"
"cx-r":     "delete"
"cx-alt-w": "ctrl-backspace"   // delete word back
"cx-alt-r": "ctrl-delete"      // delete word forward

toggle_caps_lock

Toggle the Caps Lock state of the OS.

Example:

"cx-shift-c": { "action": "toggle_caps_lock" }

Bind windows to named slots so you can instantly switch to (or minimize) a specific window.

bind_window

Bind the current window to a named slot.

Parameters:

  • slot (string) — window slot name.

Example:

"cx-cmd-1": { "action": "bind_window", "slot": "1" }

activate_window

Activate the window bound to a named slot. If the bound window is already the frontmost window, this action minimizes it instead (toggle behavior).

Parameters:

  • slot (string) — window slot name.

Example:

"cx-1": { "action": "activate_window", "slot": "1" }

Capslox provides named clipboard slots independent of the system clipboard. A slot name is any string.

clipboard_copy

Copy the current selection to a named clipboard slot.

Parameters:

  • slot (string) — clipboard slot name.

Example:

"cx-c": { "action": "clipboard_copy", "slot": "1" }

clipboard_cut

Cut the current selection to a named clipboard slot.

Parameters:

  • slot (string) — clipboard slot name.

Example:

"cx-x": { "action": "clipboard_cut", "slot": "1" }

clipboard_paste

Paste from a named clipboard slot, preserving formatting.

Parameters:

  • slot (string) — clipboard slot name.

Example:

"cx-v": { "action": "clipboard_paste", "slot": "1" }

clipboard_paste_plain

Paste from a named clipboard slot as plain text, stripping formatting.

Parameters:

  • slot (string) — clipboard slot name.

Example:

"cx-shift-v": { "action": "clipboard_paste_plain", "slot": "1" }

Key Vocabulary

Complete key vocabulary. All names are lowercase. These are used in binding keys, key sequence strings, and custom_modifiers.

Letters: a b c d e f g h i j k l m n o p q r s t u v w x y z

Digits: 0 1 2 3 4 5 6 7 8 9

Arrow keys: up down left right

Navigation: home end pageup pagedown

Function keys: f1 f2 f3 f4 f5 f6 f7 f8 f9 f10 f11 f12

Control keys

Key Description
space Space bar
enter Return / Enter
tab Tab
esc Escape
backspace Delete backward (macOS: Delete key)
delete Delete forward (macOS: Fn+Delete)
caps Caps Lock

Safe symbol keys

Use the literal character.

Key Character
[ Left bracket
] Right bracket
/ Forward slash
= Equals
, Comma
. Period
; Semicolon
' Apostrophe

Named symbol keys

These three characters have special meaning in key syntax, so their physical keys use word names instead.

Key name Physical key Why named
minus - combo separator
backslash \ JSON escape character
backtick ` visually ambiguous

Keypad keys

Key Description
kp_0 through kp_9 Keypad digits
kp_enter Keypad Enter
kp_plus Keypad +
kp_minus Keypad -
kp_multiply Keypad *
kp_divide Keypad /
kp_decimal Keypad .
kp_equal Keypad =
kp_clear Keypad Clear

Key syntax: Combine modifiers and keys with -:

"ctrl-shift-a"    // Ctrl + Shift + A
"cmd-left"        // Command + Left Arrow
"shift-/"         // Shift + /
"cmd-minus"       // Command + -

Modifier order: Modifiers can be written in any order — ctrl-shift-a and shift-ctrl-a are equivalent. The recommended order is: custom modifiers → capsctrlaltshiftcmd (following Apple HIG).

Modifiers

Modifier keys are used as prefixes in key syntax: modifier-key. Each platform has its own conventional names; both work, but prefer the name native to your platform — it reads more naturally for users on that platform:

Modifier macOS Windows
Control ctrl ctrl
Shift shift shift
Option / Alt opt alt
Command / Win cmd win

Append _left / _right to any modifier name to match a specific side — e.g. opt_left, cmd_right, ctrl_left. No suffix = either side.

"ctrl_right-a"   // matches only Right Ctrl + A
"ctrl-a"         // matches either side

Cross-platform alias super matches both macOS cmd and Windows win, useful for sharing keymaps across platforms.

Windows note: the OS reserves Win-key combinations (Win+R, Win+D, …) as system shortcuts. Per the Microsoft docs, keyboard shortcuts involving Win are reserved for the OS. So on Windows, prefer non-conflicting modifiers like alt over win in your shortcuts.

Custom Modifiers

Any physical key can become a modifier via custom_modifiers.

Fields:

  • alias — the modifier name used in bindings (e.g. cx-h).
  • tap — action to fire on a quick tap-and-release. Accepts any action: a string shorthand (resolves to send_keys, e.g. "esc") or a full action object (required for engine-native actions like toggle_caps_lock). Default: the key itself.
  • timeout_ms — tap validity window in milliseconds (default: 200).

Three forms

To keep the syntax short, custom_modifiers supports the same kind of progressive omissions used elsewhere — all three forms below are accepted:

// Full form
"custom_modifiers": {
  "caps": { "alias": "cx", "tap": { "action": "toggle_caps_lock" }, "timeout_ms": 300 }
}

// Omit defaults (equivalent to { "alias": "cx", "tap": "caps", "timeout_ms": 200 } — tapping Caps Lock sends Caps Lock)
"custom_modifiers": {
  "caps": { "alias": "cx" }
}

// String shorthand (when you only need the alias — tap keeps the original key behavior) — recommended
"custom_modifiers": {
  "caps": "cx"
}

Automatic mod_tap fallback

A key declared in custom_modifiers automatically picks up mod_tap behavior — hold for the modifier, tap for the configured tap action. You don't need a separate mod_tap binding for it; the redundancy is taken care of:

{
  "custom_modifiers": { "caps": { "alias": "cx", "tap": "esc" } },
  "layers": [{
    "id": "base",
    "on_miss": "pass_to_os",
    "bindings": {
      // No "caps": { "type": "mod_tap", ... } needed!
      // Hold Caps = cx modifier, Tap Caps = Escape (from custom_modifiers)
      "cx-h": "left",
      "cx-j": "down"
    }
  }]
}

An explicit binding for the same key in some layer wins over the fallback.

Many-to-one aliases

Several physical keys can share one alias. The most practical case: the default keymap uses caps as the cx modifier, but compact keyboards like the HHKB have no Caps Lock key. Assigning tab to cx as well lets the keyboard inherit every existing binding without redoing the config:

"custom_modifiers": {
  "caps": "cx",
  "tab": "cx"
}

Now both caps and tab activate the cx modifier. cx-h fires whether you hold Caps or Tab.

The alias can be any name that follows the key naming convention (lowercase letters, digits, underscores). cx is the default by convention — short for Capslox.


Common Mistakes

Quick checks when a binding doesn't behave as expected:

  • Physical key names, not Shift-produced characters — write shift-/ (not ?), shift-[ (not {), shift-1 (not !). Keys are named by their physical position on US QWERTY (see Key Vocabulary).
  • Three keys need word namesminus (not -), backslash (not \), backtick (not `); each collides with the combo separator, JSON escape, or TS template literal.
  • Joining rule — use - between a modifier and its key. cx-h means cx + h.
  • Engine-native actions need the object formtoggle_caps_lock, layer_push, shell_action, and the clipboard/window actions can't be written as a bare string. String shorthand is reserved for send_keys (any key syntax), "noop", and the three directive keywords "continue" / "block" / "pass_to_os".