The agent peripheral you already own.
A DualSense on your desk is an RGB light, a haptic engine, and an approve button pointed at your hands. Steer routes all three to the shell, so the pad becomes a status light for a coding agent, a nudge when it needs you, and a trigger you squeeze to say yes. No window to watch. No extra hardware.
OpenAI sells hardware for this: the Codex Micro (with Work Louder) is six RGB keys, a joystick, and accept/reject buttons for AI coding. A DualSense has all of that, plus haptics and adaptive triggers, and it is probably already in a drawer. Steer wires it to the same job.
Plain URLs. No SDK, no daemon.
Everything runs on the documented steer:// automation surface. Anything that can run a shell command can drive it: Claude Code hooks, git hooks, CI wrappers, Makefiles, Hammerspoon. Five commands cover the whole status-light recipe.
| Command | Effect |
|---|---|
open -g "steer://led/<hex>" | Paint the light bar a static colour (bare six-digit hex, no #). A glance signal, not a latch: see the note below. |
open -g "steer://led/auto" | Hand the light bar back to Steer's own palette. |
open -g "steer://leds/<0-5|off>" | Light that many of the five white player LEDs, filled left to right like a fuel gauge. DualSense and DualSense Edge only. |
open -g "steer://micled/<off|on|pulse>" | Drive the mute-button LED. pulse blinks in firmware, with no host timer. It is faint in a normally lit room, so treat it as an option for a dim desk rather than a status light you rely on. DualSense and DualSense Edge only. |
open -g "steer://haptic/<pattern>" | Pulse the pad. Four feels across five names: pulse is one tick, double is two taps, success is one long swell, and warning and error are both three taps. Those two are the same on purpose. |
Always -g so open never steals focus from your terminal. Fire-and-forget: URLs never block and never return an error. If Steer isn't running, launching one starts it.
The light bar is Steer's own display. It repaints from your profile's palette on every state change, and holding a shoulder button, toggling gyro, a low-battery warning, or the pad settling into idle five seconds after you last touched it are all state changes. Set a colour while the pad sits untouched and it stays; pick the pad up and the bar goes back to being a controller light. The five white player LEDs are a separate channel that Steer never writes to on its own, which makes them the surface that holds a status while you keep using the pad as a pad. Drive those, and read them as a count rather than a colour. The mute-button LED is on the same untouched channel, but it is faint enough in a normally lit room that it only earns its place on a dim desk.
The player LEDs read as a count rather than a colour, so they stay legible with red-green colour blindness. steer://leds/mask/<hex> addresses the five individually, bit 0 leftmost, 00 to 1F, if you want an alternating pattern rather than a fill.
Wire it into Claude Code
Merge this into the "hooks" key of ~/.claude/settings.json (or a project's .claude/settings.json). Verified against Claude Code 2.1.x.
{
"hooks": {
"UserPromptSubmit": [
{ "hooks": [ { "type": "command", "command": "(open -g 'steer://leds/1' &)" } ] }
],
"Notification": [
{ "matcher": "permission_prompt",
"hooks": [ { "type": "command", "command": "open -g 'steer://leds/5'; open -g 'steer://haptic/warning'" } ] },
{ "matcher": "idle_prompt",
"hooks": [ { "type": "command", "command": "open -g 'steer://haptic/double'" } ] }
],
"Stop": [
{ "hooks": [ { "type": "command", "command": "open -g 'steer://leds/3'; open -g 'steer://haptic/success'" } ] }
],
"SessionEnd": [
{ "hooks": [ { "type": "command", "command": "open -g 'steer://leds/off'" } ] }
]
}
} Read the LEDs as a gauge: one lit while it works, all five when something wants approval, three when the run lands, dark when the session ends. Change the numbers to taste. Nothing here touches the light bar, so the pad stays a controller while it is also a status display.
Why the first one is wrapped in ( & ). Claude Code runs UserPromptSubmit hooks synchronously, before your prompt is processed, so anything that can block holds up the session. open goes through LaunchServices and can stall if an app is wedged. Detaching it costs nothing and means a stuck Steer can never cost you a prompt. The other hooks do not run on that path.
The lights say how many. They cannot say which.
Run more than one agent and the gauge tells you two are waiting, not which two. Session identifiers are throwaway, so no light on the pad can stand for a particular project for longer than a run.
A macOS notification can. It names the project, it survives being ignored, and it is the one channel here that carries identity. Pair it with the pad rather than replacing it: the LEDs and the buzz find you while you are looking somewhere else, and the banner tells you where to go.
Steer answers back.
The URLs above push a state onto the pad. The other half of the surface reads state out, so a script can ask Steer what is going on instead of keeping its own copy in a temp file.
Ask it
Steer ships around twenty Shortcuts actions, and six of them are questions: the current profile, the active layer, the connected controller, the battery level, what a given button is bound to, and one Get State that returns all of it in a single call. Anything that can run a Shortcut can read them. From a shell, open -g "steer://query/state" writes the same snapshot to the clipboard as JSON, with query/profiles and query/macros alongside it for the IDs and names the other URLs take.
Listen to it
Steer posts to the macOS distributed notification centre whether or not anything is subscribed, so Hammerspoon, Alfred, Raycast, or a script of your own can watch it without an SDK on either side.
| Notification | Payload |
|---|---|
com.steer.profile.changed | profileID, profileName, previousProfileID |
com.steer.layer.changed | layerID, toggled, source |
com.steer.controller.connected | controllerName, controllerType, bundleHint |
com.steer.controller.disconnected | controllerName |
com.steer.macro.fired | macroName, stepCount, durationMs |
com.steer.battery.low | level, percent, controllerName |
Delivery is forced through even when the subscribing app is backgrounded, so a sleeping Hammerspoon still gets the event. The controller tokens (dualsense, ds4, xbox, switch, mfi) are held stable across Steer releases so a pattern match keeps working.
The rest of the surface
Five commands cover the status light. The scheme has seventeen kinds of command in all. Most are fire-and-forget; a few read state back instead: switch profile by ID or by name, toggle a layer, play a saved macro, open the on-screen keyboard, the radial launcher or the menu palette, pause per-app profile switching, suspend pinned overlays, open a settings pane. It also runs the other way round without a script at all: Steer registers a macOS Focus filter, so entering a Focus mode switches the profile and leaving it puts the previous one back.
What it is, and what it isn't.
Which pad you have decides how much you get
The light bar is on the DualSense, DualSense Edge, and DualShock 4. The player LEDs and the mute LED are DualSense and DualSense Edge only. Xbox, Switch Pro, and MFi pads have no app-controllable light at all, so those URLs quietly do nothing there. The haptic half is the part that works nearly everywhere: every PlayStation, Xbox and Switch Pro pad, and most but not all MFi ones.
One light bar, many sessions
Hooks fire per session, so two parallel agents share one light. It shines for the workflow you're watching. The five player LEDs can carry a count if your wrapper keeps one, but a single colour cannot report on two sessions at once, and it doesn't pretend to.
Optional, and built on the open surface
None of this is a separate product. It's the same documented automation surface every Steer user has, you're just pointing it at your agent. Steer is a full controller-to-Mac input app first; this is one thing it happens to do beautifully.