Add two Claude Code hooks: SessionStart runs a command that keeps the Mac awake, and SessionEnd runs one that lets it sleep again. With the lid open, that command can be the free built-in caffeinate. With the lid closed and no monitor, caffeinate is not enough, and the hook calls a lid-closed app instead, such as open "clamshell://arm".
SessionStartSessionEnd, last session onlycaffeinate, freeClamshell for Mac is the lid-closed half of this recipe: it keeps a MacBook awake with the lid shut and no monitor, and a hook can arm it. The lid-open half needs nothing but macOS. Free for 7 days.
Download free trialApple Silicon, macOS 14+. Notarized by Apple. 3.6 MB. Or buy now, $9.99. Reading this on your phone? Send the Mac download link to yourself.
Some newer keep-awake apps advertise that they detect coding agents and keep the Mac up only while one works. Clamshell does not detect agents. You do not need detection, though: Claude Code already announces the start and end of every session to any command you register. This recipe is useful even if you never install Clamshell, and the test notes say exactly what was run and what was not.
First, check you need this at all
On macOS, Claude Code already starts a caffeinate process of its own while it is working. Anthropic's changelog mentions it, and the open GitHub issue anthropics/claude-code #81832 describes it as caffeinate -i -t 300, restarted every four minutes with a short gap each time (both checked October 11, 2026). So:
Lid open, one terminal
You probably need nothing. If the Mac still dozes off mid-task, start the session as caffeinate -i claude. That holds one idle-sleep assertion until Claude Code exits, and it is free.
Lid open, sessions started from many places
Use the hooks below with the caffeinate backend. Every session keeps the Mac awake however it was launched: terminal, editor or a script.
Lid closed, no monitor
caffeinate cannot help: it does not survive a closed lid. Use the hooks with a lid-closed tool. This page uses Clamshell's URL scheme.
Which hook events to use
Anthropic's hooks reference (checked October 11, 2026) lists over thirty events. Four matter here:
| Event | When it fires | Catch for keep-awake | Use it? |
|---|---|---|---|
SessionStart |
A session begins or resumes. Sources: startup, resume, clear, compact, fork |
Fires again after /clear and after compaction, so the command must be safe to repeat |
Yes, to arm |
SessionEnd |
A session terminates. Reasons: clear, resume, logout, prompt_input_exit, other |
Default timeout is 1.5 seconds; also fires for /clear, when the session carries on |
Yes, to disarm |
UserPromptSubmit |
You submit a prompt, before Claude processes it | Only marks the start of a turn | Per-turn variant |
Stop |
Claude finishes responding | The docs say it "does not run if the stoppage occurred due to a user interrupt", and API errors fire StopFailure instead |
Per-turn variant |
The tempting design is per turn: arm on UserPromptSubmit, disarm on Stop. It is the wrong default for a closed lid. Once Claude finishes a turn the Mac is free to sleep, and a sleeping, closed MacBook cannot receive your next message from a phone or over SSH. Per session is the safer rule: the Mac stays reachable while a session exists, and sleeps when you end it.
Stop lets the Mac sleep between turns. With the lid closed, nothing you send from another device can start the next turn.Why two one-line hooks are not enough
You could put open "clamshell://arm" straight into SessionStart and open "clamshell://disarm" into SessionEnd. It works until you run two sessions: close the first and its end hook disarms the Mac while the second is mid-task. /clear has the same problem, since the docs list it as both a SessionEnd reason and a SessionStart source.
The fix is to count. Each session drops a marker file when it starts and removes it when it ends, and the Mac is released only when no markers are left.
session_id to the scriptThe script
Save this as ~/.claude/hooks/agent-awake.sh. It needs nothing beyond what ships with macOS: no jq, no Python.
#!/bin/bash
# agent-awake.sh arm|disarm
# Called by agent hooks. Reads the hook's JSON from stdin.
# Keeps the Mac awake while at least one agent session is open.
BACKEND="${AGENT_AWAKE_BACKEND:-clamshell}" # clamshell | caffeinate | dryrun
DIR="${TMPDIR:-/tmp}/agent-awake"
mkdir -p "$DIR/sessions"
input="$(cat)"
field() { # pull one string value out of the hook JSON, no jq needed
printf '%s' "$input" | sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n 1
}
sid="$(field session_id | tr -cd 'A-Za-z0-9_-')"
reason="$(field reason)"
[ -n "$sid" ] || exit 0
# One hook at a time: mkdir is atomic. Give up waiting after about a second.
tries=0
until mkdir "$DIR/lock" 2>/dev/null; do
tries=$((tries + 1))
[ "$tries" -ge 20 ] && break
sleep 0.05
done
trap 'rmdir "$DIR/lock" 2>/dev/null' EXIT
# A marker per session, holding the agent's process ID ($PPID is the agent).
case "$1" in
arm) echo "$PPID" > "$DIR/sessions/$sid" ;;
disarm) rm -f "$DIR/sessions/$sid" ;;
*) exit 0 ;;
esac
# Drop markers whose agent process is gone (crash, kill -9, closed terminal).
count=0
for marker in "$DIR/sessions"/*; do
[ -e "$marker" ] || continue
if kill -0 "$(cat "$marker")" 2>/dev/null; then
count=$((count + 1))
else
rm -f "$marker"
fi
done
keep_awake() {
case "$BACKEND" in
clamshell) open "clamshell://arm" ;;
caffeinate)
if ! kill -0 "$(cat "$DIR/caffeinate.pid" 2>/dev/null)" 2>/dev/null; then
nohup caffeinate -i >/dev/null 2>&1 &
echo $! > "$DIR/caffeinate.pid"
fi ;;
esac
}
allow_sleep() {
case "$BACKEND" in
clamshell) open "clamshell://disarm" ;;
caffeinate)
kill "$(cat "$DIR/caffeinate.pid" 2>/dev/null)" 2>/dev/null
rm -f "$DIR/caffeinate.pid" ;;
esac
}
if [ "$1" = arm ]; then
keep_awake
echo "$(date '+%F %T') arm $sid sessions=$count" >> "$DIR/log"
elif [ "$count" -eq 0 ] && [ "$reason" != clear ] && [ "$reason" != resume ]; then
# /clear and /resume end one session and start the next at once,
# so the start hook that follows keeps the Mac awake.
allow_sleep
echo "$(date '+%F %T') disarm $sid sessions=0" >> "$DIR/log"
else
echo "$(date '+%F %T') end $sid sessions=$count (still awake)" >> "$DIR/log"
fi
exit 0
Then make it executable:
mkdir -p ~/.claude/hooks
chmod +x ~/.claude/hooks/agent-awake.sh
Three details are deliberate. The mkdir lock makes hooks take turns, because the docs say matching hooks run in parallel and two sessions can start in the same second. The marker stores $PPID, which in testing was the Claude Code process itself, so a later run can tell a live session from a dead one. And a SessionEnd with reason clear or resume never disarms, because a SessionStart follows.
The Claude Code settings
Per the hooks reference, ~/.claude/settings.json applies to all your projects, .claude/settings.json to one project, and .claude/settings.local.json to one project on your machine only. For a keep-awake rule you want the first. Add this, merging it into any "hooks" object you already have:
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/agent-awake.sh arm"
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/agent-awake.sh disarm",
"timeout": 10
}
]
}
]
}
}
- No
matcher. Leaving it out matches every start source and end reason. "timeout": 10on the end hook.SessionEndhooks get 1.5 seconds by default, and a per-hooktimeoutraises that. In testing the script took a few hundredths of a second; the margin covers its one-second wait on a stuck lock.- Workspace trust. In an interactive session, Claude Code holds back hooks from every settings file, your own included, until you accept the trust dialog for the folder.
- Check it loaded. Type
/hooksin Claude Code to browse the hooks it has registered.
This uses the default backend, Clamshell. To use caffeinate instead, put the variable in front of both commands:
"command": "AGENT_AWAKE_BACKEND=caffeinate ~/.claude/hooks/agent-awake.sh arm"
With that backend the script starts a single caffeinate -i when the first session opens and kills it when the last one closes, so idle sleep is held off for the whole session, including while Claude waits for you. Issue #81832 reports that the built-in keep-awake lets go about 30 seconds after Claude stops being busy. Neither does anything for a closed lid.
Clamshell keeps a closed MacBook awake with no sudo and no monitor, and takes arm and disarm requests from a hook. Free for 7 days, then $9.99 once.
Apple Silicon, macOS 14+. Notarized by Apple. 3.6 MB. Also on Setapp.
Test it before you trust it
- Dry run first. Set
AGENT_AWAKE_BACKEND=dryrunin both commands. The script then keeps its markers and its log but touches nothing else. - Start and end a session. The quickest way is a one-shot run, which fires both hooks:
You should see oneclaude -p "Reply with the single word ok." cat "$TMPDIR/agent-awake/log"armline and onedisarmline with the same session ID. - Overlap two sessions. Open Claude Code in two terminals, then quit them one at a time. The first exit logs
end ... sessions=1 (still awake); only the second logsdisarm. - Switch to the real backend. Remove the variable (or set
caffeinate). Forcaffeinate, confirm withpmset -g assertions | grep caffeinatewhile a session is open. For Clamshell, open its menu and check it shows armed. - If nothing happens, start Claude Code with
claude --debug-file /tmp/claude.logand read the hook entries in that file.
open "clamshell://arm" only sends a request, and it returns before the app has acted on it. A hook that exits cleanly is not proof the Mac is armed. Look at the menu bar before you close the lid, at least until you have seen the recipe work a few times on your own Mac.
What happens if a session crashes
If Claude Code dies without running its end hook, its marker stays behind and the Mac stays awake. The script cleans up after it: every time any hook runs, markers whose process ID is no longer alive are deleted before counting. A crashed session is forgotten the next time another session starts or ends.
Tested with Claude Code 2.1.296 on October 11, 2026, by killing a running claude -p session:
SIGTERM,SIGHUP,SIGINT: theSessionEndhook still ran and the keep-awake was released.SIGHUPis what a process normally receives when its terminal window closes.SIGKILL: no hook ran. The marker and thecaffeinateprocess were left behind, and both were removed by the next session that started and ended.
The gap that remains: if the only session is killed outright and you start no other, nothing runs to clean up. Disarm by hand, from Clamshell's menu or with open "clamshell://disarm", or with pkill caffeinate for the free backend.
The per-turn variant, if you want it
With the lid open and caffeinate, per turn is reasonable: the Mac stays up while Claude works and can idle-sleep while a finished session sits in a forgotten tab. The same script handles it:
{
"hooks": {
"UserPromptSubmit": [
{ "hooks": [ { "type": "command", "command": "AGENT_AWAKE_BACKEND=caffeinate ~/.claude/hooks/agent-awake.sh arm" } ] }
],
"Stop": [
{ "hooks": [ { "type": "command", "command": "AGENT_AWAKE_BACKEND=caffeinate ~/.claude/hooks/agent-awake.sh disarm" } ] }
],
"StopFailure": [
{ "hooks": [ { "type": "command", "command": "AGENT_AWAKE_BACKEND=caffeinate ~/.claude/hooks/agent-awake.sh disarm" } ] }
],
"SessionEnd": [
{ "hooks": [ { "type": "command", "command": "AGENT_AWAKE_BACKEND=caffeinate ~/.claude/hooks/agent-awake.sh disarm", "timeout": 10 } ] }
]
}
}
Know its edges. Stop does not run when you interrupt a turn, so after pressing Esc the Mac stays awake until the next turn finishes or the session ends. StopFailure covers turns that end on an API error. Background work Claude left running after its reply is not protected once Stop has fired.
The Codex variant
Codex has the same pair of events. OpenAI's Codex hooks documentation (checked October 11, 2026) lists SessionStart and SessionEnd, read from ~/.codex/hooks.json or from [hooks] tables in ~/.codex/config.toml, and each command hook receives a JSON object with session_id on standard input. Copy the script to ~/.codex/hooks/agent-awake.sh and add:
{
"hooks": {
"SessionStart": [
{ "hooks": [ { "type": "command", "command": "~/.codex/hooks/agent-awake.sh arm" } ] }
],
"SessionEnd": [
{ "hooks": [ { "type": "command", "command": "~/.codex/hooks/agent-awake.sh disarm", "timeout": 3 } ] }
]
}
}
Three differences from Claude Code, all from OpenAI's page:
- Trust. Codex skips a new or changed hook until you review and trust it. Run
/hooksin the Codex CLI after adding these. - Time limit.
SessionEndhooks get one second by default and three at most, hence"timeout": 3. - When the end fires.
SessionEndruns when Codex closes normally, when you archive or delete an open conversation, or after a conversation has been idle and not open in any client for 30 minutes.
If both tools see the same $TMPDIR, their markers land in one folder and the sessions count together, so the Mac is released when the last of either ends. The Codex configuration is written from the documentation and has not been run for this page, so test it with the dryrun backend first. If your Codex build has no hooks, or for any other command-line agent, a shell function does the same job for one terminal:
agent-awake() (
trap 'open "clamshell://disarm"' EXIT
trap 'exit 130' INT TERM HUP
open "clamshell://arm"
"$@"
)
agent-awake codex
The parentheses run it in a subshell, so the EXIT trap sends the disarm request however the agent ends, in both zsh and bash. It does not count sessions: two wrapped agents at once will disarm when the first one exits.
What this recipe does not do
- It follows sessions, not activity. An idle session left open in a tab keeps the Mac awake. Quit sessions you are done with.
- It arms without asking. With the Clamshell backend, any open session means a closed lid no longer sleeps the Mac. Quit your sessions, or check the menu shows inactive, before the Mac goes into a bag.
- Clamshell's own rules still apply. It pauses rather than arming while an external display is connected, and its thermal safety disarms toward normal lid sleep under serious or critical thermal pressure. A hook cannot override either. Clamshell itself has no agent integration; all the logic here is your script.
What was tested for this page
- Run on a Mac, Claude Code 2.1.296, October 11, 2026: the script exactly as printed, loaded from a project-level
.claude/settings.json; both events firing inclaude -pruns; two real overlapping sessions with thecaffeinatebackend; ten simulated simultaneous sessions; a stuck lock; the four kill signals; the per-turn settings in one run; the shell function with a stand-in command in zsh and bash. - From documentation only: the user-level settings location, interactive
/clearand/resume(the script's handling was run against simulated input), interrupts and API errors in the per-turn variant, and Codex hooks. - Clamshell backend: the two
opencalls use Clamshell's documented URL scheme. Confirm the armed state in the menu on your own Mac.
Frequently asked questions
Does Claude Code keep my Mac awake by itself?
Partly. On macOS, Claude Code starts a caffeinate process while it is working, which holds off idle sleep with the lid open. It does not stop the sleep that happens when you close a MacBook lid, and a GitHub report says it follows the busy state, so a session waiting for your input is not covered. Hooks let you add your own keep-awake for the whole session.
Which Claude Code hook events should I use to keep the Mac awake?
SessionStart to start keeping the Mac awake and SessionEnd to stop. Using UserPromptSubmit and Stop instead ties it to each turn, which lets the Mac sleep between turns and misses turns you interrupt, because Stop does not run after a user interrupt.
What happens if Claude Code crashes before the SessionEnd hook runs?
The Mac stays awake until something cleans up. The script on this page removes markers whose process has gone the next time any hook runs; if no other session starts or ends, disarm by hand. In testing with Claude Code 2.1.296, only SIGKILL skipped the SessionEnd hook.
Do these hooks work with caffeinate instead of Clamshell?
Yes. Set AGENT_AWAKE_BACKEND=caffeinate and the same script starts one caffeinate -i process for as long as any session is open. That is free and built into macOS, but it only prevents idle sleep. Closing a MacBook lid still puts the Mac to sleep.
Does Codex have the same hooks?
Yes. OpenAI's Codex documentation lists SessionStart and SessionEnd hook events configured in ~/.codex/hooks.json with the same nested shape. Codex requires you to review and trust each hook with /hooks before it runs, and its SessionEnd hooks get one second by default and three seconds at most.
Related guides
- Keep a MacBook awake with the lid closed
- Keep Claude Code running with the lid closed
- What Claude Code's built-in keep-awake covers, and what it misses
- Why caffeinate does not work with the lid closed
- Keep Codex running when you close your MacBook lid
- Run AI coding agents overnight on a MacBook
Sources checked October 11, 2026: Anthropic's Claude Code hooks reference and changelog, GitHub issue anthropics/claude-code #81832, OpenAI's Codex hooks documentation, and the macOS caffeinate manual page. Hook events change between releases; check Anthropic's current hooks reference before relying on a detail. Read the script before you run it. Clamshell is an independent app, not affiliated with or endorsed by Anthropic or OpenAI. Claude and Claude Code are trademarks of Anthropic.