A cool physical status light for Codex.
This project connects Codex lifecycle hooks to a Moonside lamp, so the lamp changes color or animation as Codex starts working, asks for approval, or finishes a turn.
It is intentionally small:
- Codex hooks stay fast and quiet.
- A shell hook writes the desired lamp state.
- One background Python daemon owns the Bluetooth connection.
- The daemon sends simple Moonside BLE commands.
Codex hook event
-> codex_lamp_hook.sh
writes /tmp/codex_lamp_state
starts the daemon if needed
-> codex_lamp_daemon.py
keeps one BLE connection alive
watches for state changes
sends commands to the lamp
| Codex moment | Lamp state | Default lamp effect |
|---|---|---|
| Codex starts, resumes, or clears a session | idle |
Warm amber solid light |
| You submit a prompt | working |
Animated blue/white working theme |
| Codex runs a supported tool | working |
Animated blue/white working theme |
| Codex asks for approval | input |
Purple solid light |
| Codex finishes a turn | idle |
Warm amber solid light |
| Manual off or idle timeout | off |
LED off |
The default effects are defined in codex_lamp_daemon.py:
COLOR_IDLE = "COLOR255180050"
WORKING_CMD = "THEME.BEAT2.255,255,255,0,0,140,"
COLOR_INPUT = "THEME.WAVE1.255,100,0,255,26,214,"You can change these commands to any Moonside command your lamp accepts.
| File | Purpose |
|---|---|
hooks.json |
Example Codex hook configuration. Copy or merge this into ~/.codex/hooks.json. |
codex_lamp_hook.sh |
Fast Codex hook entrypoint. It writes the state file and starts the daemon. |
codex_lamp_daemon.py |
Background BLE daemon. It watches the state file and controls the lamp. |
codex_lamp_test.py |
Manual BLE tester for scanning, colors, themes, and raw commands. |
README.md |
Setup, usage, and troubleshooting guide. |
- macOS
- Python 3.10 or newer
- A Moonside lamp that accepts Nordic UART Service commands
- Bluetooth enabled
- The Python package
bleak - Codex with local hooks support
Install bleak into the Python environment that Codex can reach:
python3 -m pip install bleakThe hook auto-detects Python in this order:
CODEX_LAMP_PYTHON, if setpython3/opt/homebrew/bin/python3$CONDA_PREFIX/bin/python3, if Conda is active
From this project folder:
python3 codex_lamp_test.py scanYou should see something like:
MOONSIDE-O101 A2F26067-F4DB-DAD8-FB91-70D6A2E9CCC0
Then test a few direct commands:
python3 codex_lamp_test.py off
python3 codex_lamp_test.py color 255 180 50
python3 codex_lamp_test.py color 255 0 0
python3 codex_lamp_test.py theme BEAT2 --colors 255,255,255,0,0,140If these work, Bluetooth, Python, bleak, and the lamp are all talking.
Still from this project folder:
bash codex_lamp_hook.sh working
bash codex_lamp_hook.sh input
bash codex_lamp_hook.sh idle
bash codex_lamp_hook.sh offThe lamp should react to each state.
Check the daemon log if something does not respond:
tail -n 80 /tmp/codex_lamp_daemon.logCopy the scripts into a stable location under your Codex home:
mkdir -p ~/.codex/codex-lamp
cp codex_lamp_hook.sh codex_lamp_daemon.py codex_lamp_test.py ~/.codex/codex-lamp/
chmod +x ~/.codex/codex-lamp/codex_lamp_hook.sh
chmod +x ~/.codex/codex-lamp/codex_lamp_daemon.py
chmod +x ~/.codex/codex-lamp/codex_lamp_test.pyCopy or merge the example hook config:
cp hooks.json ~/.codex/hooks.jsonIf you already have ~/.codex/hooks.json, merge the hooks entries instead of
overwriting your existing file.
Current Codex builds enable hooks by default. To be explicit, you can add this
to ~/.codex/config.toml:
[features]
hooks = trueOlder Codex builds used the deprecated alias:
[features]
codex_hooks = truePrefer hooks = true for current Codex versions.
After changing hook configuration, restart Codex.
Codex may ask you to review non-managed command hooks before they run. In the Codex CLI, use:
/hooks
Review the command paths and trust the hooks if they match your local install.
The included hooks.json maps Codex events to lamp states:
| Codex hook | When it fires | Lamp state |
|---|---|---|
SessionStart |
Codex starts, resumes, or clears a session | idle |
UserPromptSubmit |
You send a prompt to Codex | working |
PreToolUse |
Codex is about to run a supported tool | working |
PostToolUse |
Codex finished a supported tool call | working |
PermissionRequest |
Codex is about to ask for approval | input |
Stop |
Codex finished the turn | idle |
The PermissionRequest hook is intentionally narrow. It only fires when Codex
is about to ask for approval, such as a sandbox escalation or managed network
approval. It does not fire every time Codex is waiting for your next message.
You can drive the lamp without Codex:
bash ~/.codex/codex-lamp/codex_lamp_hook.sh working
bash ~/.codex/codex-lamp/codex_lamp_hook.sh input
bash ~/.codex/codex-lamp/codex_lamp_hook.sh idle
bash ~/.codex/codex-lamp/codex_lamp_hook.sh offThese are useful for testing, filming demos, or resetting the lamp.
PermissionRequest is not common during normal use because Codex only asks for
approval when it crosses a permission boundary.
For a predictable demo, start Codex in a stricter mode and ask it to do a harmless write:
codex --sandbox read-only --ask-for-approval on-requestThen prompt Codex:
Create a harmless file named permission_demo.txt in this folder with the text
"Codex lamp demo". Since this session is read-only, request approval before
making the edit.
Expected lamp flow:
prompt submitted -> working
approval needed -> input
approved action -> working
turn complete -> idle
The scripts can be configured with environment variables.
| Variable | Default | Purpose |
|---|---|---|
CODEX_LAMP_PYTHON |
auto-detect | Python executable that has bleak installed. |
CODEX_LAMP_DAEMON |
script next to the hook | Override daemon script path. |
CODEX_LAMP_NAME_PREFIX |
MOONSIDE |
BLE device name prefix to scan for. |
CODEX_LAMP_ADDRESS |
unset | Pin one lamp by BLE address or macOS UUID. |
CODEX_LAMP_IDLE_TIMEOUT |
1800 |
Seconds before idle daemon turns the lamp off and exits. |
CODEX_LAMP_STATE_FILE |
/tmp/codex_lamp_state |
Shared desired-state file. |
CODEX_LAMP_PID_FILE |
/tmp/codex_lamp_daemon.pid |
Daemon PID file. |
CODEX_LAMP_LOCK_FILE |
/tmp/codex_lamp_daemon.lock |
Lock file that prevents duplicate daemons. |
CODEX_LAMP_LOG_FILE |
/tmp/codex_lamp_daemon.log |
Hook and daemon log file. |
If you have multiple BLE devices nearby, pin the lamp address:
export CODEX_LAMP_ADDRESS="A2F26067-F4DB-DAD8-FB91-70D6A2E9CCC0"
bash ~/.codex/codex-lamp/codex_lamp_hook.sh idleFor permanent use, set the environment variable wherever your Codex shell environment is configured.
By default, the daemon turns the lamp off after 30 minutes of idle time. For a shorter timeout:
export CODEX_LAMP_IDLE_TIMEOUT=120That means:
Codex finishes -> idle light
2 minutes pass -> lamp off
Edit these constants in codex_lamp_daemon.py:
WORKING_CMD = "THEME.BEAT2.255,255,255,0,0,140,"
COLOR_IDLE = "COLOR255180050"
COLOR_INPUT = "COLOR200000255"For example, to make approval requests use an animated wave instead of a solid purple color:
COLOR_INPUT = "THEME.WAVE1.255,100,0,255,26,214,"After changing the installed daemon file, restart the daemon:
bash ~/.codex/codex-lamp/codex_lamp_hook.sh off
bash ~/.codex/codex-lamp/codex_lamp_hook.sh idleThis project sends ASCII commands over the Nordic UART Service write characteristic:
6e400002-b5a3-f393-e0a9-e50e24dcca9e
Examples:
| Action | Command |
|---|---|
| LED on | LEDON |
| LED off | LEDOFF |
| Solid color | COLOR255180050 |
| Brightness | BRIGH060 |
| Theme | THEME.BEAT2.255,255,255,0,0,140, |
Use the tester to try raw commands:
python3 codex_lamp_test.py raw "LEDOFF"
python3 codex_lamp_test.py raw "THEME.WAVE1.255,100,0,255,26,214,"This version is a single-lamp, shared-state design.
All Codex sessions write to the same state file:
/tmp/codex_lamp_state
That means the latest hook event wins.
Example:
Session A -> working
lamp -> working
Session B -> idle
lamp -> idle
Even if Session A is still working, Session B's later idle event can overwrite
the lamp state.
For one main Codex session, this is simple and works well. For heavy multi-session
use, the next design would parse Codex's hook JSON from stdin, track each
session_id, and compute a global state:
if any session needs approval -> input
else if any session is working -> working
else -> idle
For multiple lamps, a future router could assign each active session_id to a
specific lamp.
Codex currently exposes Stop as a turn-level hook, not a true
session/window-close hook.
So this project can reliably detect:
turn finished -> idle
It cannot detect perfectly:
terminal window closed
desktop app window closed
session abandoned
The practical workaround is the idle timeout. If Codex stops producing hook events, the daemon eventually turns the lamp off.
Run:
python3 codex_lamp_test.py scan --allIf you see the lamp with a different name, use:
python3 codex_lamp_test.py --name-prefix YOUR_PREFIX scanIf scanning is flaky, pin the address:
CODEX_LAMP_ADDRESS="YOUR-LAMP-ADDRESS" python3 codex_lamp_test.py offFind the Python that has bleak:
python3 -c "import sys, bleak; print(sys.executable)"Then point the hook at it:
export CODEX_LAMP_PYTHON="/path/to/python3"Check these in order:
~/.codex/hooks.jsoncontains the hook commands.- The hook paths point to the installed script.
- The script is executable.
- Codex has reviewed/trusted the hook with
/hooks. - Hooks are not disabled in
~/.codex/config.toml. - The daemon log has useful clues:
tail -n 120 /tmp/codex_lamp_daemon.logYou can also manually run the exact installed command:
bash "$HOME/.codex/codex-lamp/codex_lamp_hook.sh" workingTurn the lamp off through the hook first:
bash ~/.codex/codex-lamp/codex_lamp_hook.sh offIf needed, stop the daemon manually:
kill "$(cat /tmp/codex_lamp_daemon.pid)"
rm -f /tmp/codex_lamp_daemon.pid /tmp/codex_lamp_daemon.lockThen start it again:
bash ~/.codex/codex-lamp/codex_lamp_hook.sh idleStart by testing with the Codex CLI, because hook behavior is easiest to inspect
there with /hooks and the local logs.
If your Desktop build does not appear to trigger hooks, keep the hook project as
is and consider a separate Desktop watcher that observes Codex logs or app
activity and calls the same codex_lamp_hook.sh states. That keeps the BLE code
centralized and avoids duplicating lamp control logic.
- These scripts control your own Bluetooth lamp at your own risk.
- Codex hooks run local commands, so read the scripts before trusting them.
- The hook is intentionally quiet and exits
0so lamp failures do not break Codex turns. - The daemon writes temporary state, PID, lock, and log files under
/tmpby default.
This project was inspired by bobek-balinek/claude-lamp, a Claude hook project for controlling a Moonside lamp from Claude activity.
- Codex hooks documentation: https://developers.openai.com/codex/hooks
- Codex configuration basics: https://developers.openai.com/codex/config-basic
- Bleak documentation: https://bleak.readthedocs.io/