Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Scripting Guide

Rebind scripts are written in Luau, running on a Rust core that dispatches input hooks up to 8,000 times a second. Your code sits between your input devices and your computer: every keypress, mouse move, and scroll can be read, transformed, blocked, or replaced. Output is standard USB HID from a Teensy 4.x device, or OS-level input in software mode.

This guide teaches the model and the common patterns. For the exact parameters of every function, see the SDK reference; to run your first script, see the quickstart.

Lifecycle

Every script follows a predictable lifecycle:

Load -> OnStart -> (OnFocus/OnBlur cycle) -> OnStop -> Unload

You implement behavior by defining hooks — global functions the runtime calls when something happens:

  • OnStart() / OnStop() — set up and tear down (start timers, cancel tasks, release keys).
  • OnFocus(window) / OnBlur(window) — fire when a targeted app gains or loses focus (see Targeting).
  • OnTick(dtMs) — runs on a fixed cadence while the script is active; default 1,000/sec, up to 8,000/sec with the tick_rate=8000 modeline. dtMs is the real time elapsed since the previous OnTick (milliseconds, fractional; 0 on the first call) — use it for frame-rate-independent motion.
  • OnDown / OnUp / OnMove / OnScroll — input events, covered next.

A script with no targeting is always active, so OnFocus/OnBlur never fire. See Hooks for the exact signatures.

Input and blocking

Input hooks fire when physical input arrives from your captured devices. Their return value decides whether your PC sees the input:

  • return true — input passes through normally.
  • return false — input is swallowed; the PC never sees it.
  • no return / return nil — same as return true.

This is how remapping works: block the original key, then send a different one with HID.

function OnDown(key)
  if key == "CapsLock" then
    HID.Down("Escape")
    HID.Up("Escape")
    return false -- swallow CapsLock; the PC sees Escape instead
  end
  return true
end

function OnUp(key, duration)
  -- duration = how many ms the key was held
  return true
end

Bind is the declarative shortcut for simple remaps and uses the opposite default — it blocks unless you return true. See Bind.

Mouse moves are handled differently, and they need hardware. Any script that defines OnMove requires a Rebind device — the relay refuses to load an OnMove script in software mode, because the OS draws the cursor upstream of anything a host process could intercept in time. (Key, button, and scroll blocking work in both modes; only mouse movement is hardware-gated.)

On a device, OnMove fires asynchronously by default — the move is forwarded to your PC immediately and the return value is ignored, so script time never sits in the mouse path. To modify or swallow moves (acceleration curves, cursor smoothing), add mouse_block=true to the modeline; then the return value matters:

--[[
  rebind: min_sdk=3.0.0
  rebind: mouse_block=true
--]]
function OnMove(dx, dy)
  return true -- now return false would swallow the move
end

Keys are identified by case-insensitive string names ("A", "LCtrl", "Mouse1", …). The complete list — every alias and which keys are input- or output-only — is in the Key reference.

Sending output

The HID namespace sends keyboard and mouse output through the active transport; it appears as real input to your PC.

  • HID.Down / HID.Up hold and release a key across time.
  • HID.Press taps a key (optional hold in ms); HID.Type types a string.
  • Use + for modifier combos: HID.Press("LCtrl+V") presses in order and releases in reverse.
HID.Down("LShift")
HID.Press("A") -- types "A" while Shift is held
HID.Up("LShift")

HID.Press("LCtrl+V") -- paste, timing handled for you

HID.Press and HID.Type use an internal Sleep, so they must run inside a Run() coroutine or an Async() handler. HID.Down/HID.Up are non-blocking and safe anywhere — including an input hook.

For multi-line or long text, paste is faster and more reliable than typing per character:

Clipboard.Set("Line one\nLine two")
HID.Press("LCtrl+V")

The full output surface (mouse movement, scroll, absolute mode) is in the HID reference.

Doing things over time

For sequences, delays, and loops, use Run() and Sleep():

function OnDown(key)
  if key == "F9" then
    Run(function()
      HID.Down("W")
      Sleep(500) -- pause this coroutine only
      HID.Up("W")
    end)
    return false
  end
  return true
end

Run() launches a coroutine that executes concurrently; Sleep() pauses that coroutine without blocking anything else, and is only valid inside a Run() block. Reach for:

  • After(ms, fn) — a one-off delay without the Run/Sleep boilerplate.
  • Async(fn) — wrap a callback so it runs in a coroutine; use it for Bind callbacks that need Sleep.
  • Timer.After / Timer.Every — simple delayed or repeated callbacks, when you don’t want to manage a coroutine loop yourself.

Coroutines are cooperative and tick-driven. Sleep resumes on the next engine tick past its deadline, so its timing is tick-granular rather than exact milliseconds, and a Run() body that never calls Sleep runs straight through on a single tick — an infinite loop with no Sleep stalls the script (input freezes) until the runtime’s per-tick budget (~200 ms) cuts it off. Always put a Sleep inside long-running loops.

Run() returns a handle (task:Cancel(), task:IsRunning()). Cancel long-running tasks in OnStop/OnBlur so they don’t leak:

local task = nil

function OnDown(key)
  if key == "F9" then
    task = Run(function()
      while Input.IsDown("F9") do
        HID.Press("Mouse1", 20)
        Sleep(100)
      end
    end)
  end
  return false
end

function OnStop()
  if task and task:IsRunning() then
    task:Cancel()
  end
end

Exact signatures are under Globals and Timer.

Reading state

When you need to know what’s happening right now rather than waiting for an event, poll it:

  • Input reports currently held keys, modifiers, and how long a key has been down — useful inside OnTick or a Run() loop.
  • System gives the current time, cursor position, screen size, and focused window, refreshed each tick.

See Input and System for the methods; the Double-tap pattern below shows System.Time in use.

Config panels

A script can define a settings panel that appears in the Rebind UI, so users tune it without editing code. UI.Schema declares the controls; you read and write values through the returned handle, and they persist automatically (keyed to the script’s file path):

local cfg = UI.Schema({
  enabled = UI.Toggle(true, { label = "Enable" }),
  speed = UI.Slider(50, { min = 0, max = 100, suffix = "%" }),
})

if cfg.enabled then
  local s = cfg.speed
end

The full widget catalog (toggles, sliders, keybinds, selects, text, plus layout and notifications) is in the UI reference.

Splitting across files (require)

As a script grows, pull shared logic into its own file and load it with require(). A module is just a .luau / .lua file that returns a value — usually a table:

-- helpers.luau, next to your script
local M = {}

function M.clamp(n, lo, hi)
  return math.max(lo, math.min(hi, n))
end

return M
-- main script
local helpers = require("helpers")
local x = helpers.clamp(value, 0, 100)

require resolves relative to the script’s own directory:

  • An extensionless name is tried as <name>.luau, then <name>.lua, then <name>/init.luau, then <name>/init.lua (so a folder with an init.luau loads as a package).
  • Dots are path separators: require("lib.math") loads lib/math.luau.
  • An explicit .lua / .luau suffix pins that exact file; a leading ./ is ignored.
  • Modules are cached: the file is evaluated once on the first require, and every later require of the same path returns that same value.

It is sandboxed to the script’s folder. A name containing .., or any path that would resolve outside the directory, is rejected (path traversal not allowed), and an unresolved name raises module '<name>' not found. There are no external packages, registries, package.path, or aliases — local files only.

Only the script you run needs a min_sdk modeline. A require’d module is loaded through that script rather than run directly, so it needs no header.

Macros

Macro.Play plays back a recorded input sequence — a table of move/press/scroll/sleep actions. Reach for a macro when you have a fixed sequence to replay; use raw HID calls when the output depends on logic. Patterns can be transformed before playback (Math.Scale, Math.Spline, Math.Resample). See the Macro reference for the action format and playback modes.

Targeting and the always-on model

Restrict a script to specific applications with the window= and process= modeline keys. window= matches a case-insensitive substring of the focused window’s title (so window=code also matches “Visual Studio Code”); process= matches the process name the same way. A script activates if any window= or any process= pattern matches — the two lists combine as OR — and it deactivates when the matching window loses focus:

-- rebind: process=Photoshop.exe
function OnFocus(window)
  Log.Info("focused: " .. window.title)
end
function OnBlur()
  Log.Info("left")
end -- clean up here

A targeted script that isn’t focused is skipped entirely — its hooks, timers, and OnTick never fire. So you can keep hundreds of scripts loaded at once and only the script(s) matching the focused window run, with no interference or wasted CPU:

Always running (no targeting):
  CapsLock to Escape
  Mouse Media Keys

Activated on demand:
  Photoshop Shortcuts   (process=Photoshop.exe)
  Browser Tab Nav       (process=chrome.exe, firefox.exe, msedge.exe)
  Terminal Helpers      (window=Terminal)

Scripts without any targeting are always active — use them for system-wide behaviors like remaps and media controls. When several scripts run at once, z_index controls which sees input first (a false return blocks lower-priority scripts), and instance controls what happens when a script loads while a copy is already running. Both are in the Modeline reference.

Patterns

Common shapes that compose the pieces above.

Toggle

local active = false

function OnDown(key)
  if key == "F9" then
    active = not active
    UI.Notify(active and "ON" or "OFF", "info")
    return false
  end
  return true
end

Hold loop

function OnDown(key)
  if key == "Mouse1" then
    Run(function()
      while Input.IsDown("Mouse1") do
        -- do something each iteration
        Sleep(100)
      end
    end)
    return false
  end
  return true
end

Double-tap

local lastTap = 0
local THRESHOLD = 250

function OnDown(key)
  if key == "W" then
    local now = System.Time()
    if now - lastTap < THRESHOLD then
      Log.Info("Double tap!")
    end
    lastTap = now
  end
  return true
end

Validation

The runtime lints your script on load and warns (in the Logs tab, without blocking the load) about common issues:

  • No hooks defined — the script loads but never runs any logic.
  • Net.Get/Net.Post in a high-frequency OnTick — synchronous HTTP blocks the main thread; move it inside Run(). WebSocket handlers (Net.WSListen/Net.WSConnect) are exempt — their I/O runs on dedicated threads.

Type checking

The SDK ships a type definition file (types/rebind.d.luau) for the Luau Language Server, giving you autocomplete, hover docs, and type checking in VS Code. Add a .luaurc next to your script:

{
    "languageMode": "nonstrict",
    "paths": ["path/to/lua-sdk/types"]
}