Key takeaways
- Claude’s five-hour session and weekly limits meter how much you use, not when. Nothing built in stops an agent at the end of your day.
- Hooks are the right layer:
UserPromptSubmitruns on every prompt andPreToolUseon every tool call, so a running task stops at its next step instead of being killed mid-write. - A 20-line shell script that exits
2outside your hours works for both Claude Code and Codex. Every script here was tested at the boundaries, including night shifts that cross midnight. - Daily limits, breaks and overtime need state, locking and care. You can build them, or let AgentUnion handle them from the menu bar.
Why your agents need a closing time
An agent never gets tired. You do. The moment you can hand a task to Claude Code or Codex and watch it work, “one more prompt” stops having a natural end.
That isn’t just a feeling. Developers describe agentic coding’s fast feedback loop as a slot machine that pulls them into nights and weekends, and one study of more than 500 developers found out-of-hours commits rising alongside merged pull requests. We cover the research in Agentic coding and burnout. This guide is the practical half: how to make your agents stop when your day does.
There’s a second, quieter reason. A long-running agent at 1 AM is an agent nobody is supervising. If it decides to rewrite a migration or force-push a branch, you find out over breakfast. Working hours keep the agent’s work inside the hours when you can actually review it.
Built-in limits are not a schedule
Claude Code on a Pro or Max plan has two limits. Anthropic’s help center says that both plans have a five-hour session limit and a weekly limit, shared between Claude and Claude Code. Its article on Max plan usage explains that a session starts with your first message and resets five hours later.
Both are about volume. The five-hour window starts whenever you start, so it follows you into the evening rather than ending your day. The weekly limit caps the total, which might stop you on Thursday afternoon and leave you free at 2 AM on Monday. Community guides even describe sending a small prompt early in the morning to move the window, which shows how little it has to do with a working day. Check your own remaining allowance with /status in Claude Code; the exact allowances change, so we won’t quote numbers that may already be out of date.
Codex has its own plan limits, and the same logic applies: a usage cap tells you when you’ve used too much, not when it’s time to stop.
Three ways to stop an agent at night
1. Willpower and OS settings
The default is discipline, and the problem is obvious: the agent is most tempting exactly when you’re tired. macOS Focus modes filter notifications; they don’t stop a process. Screen Time can limit apps on a schedule, but it works on whole apps, so blocking your terminal blocks you too, and it doesn’t reach an agent running over SSH, in a container or on a remote dev box.
2. Cron jobs and launcher wrappers
The next idea is a scheduled kill: a cron job or launchd task that runs pkill on the agent at 6 PM, or a wrapper script that refuses to start claude or codex outside your hours. Both have real gaps. A wrapper only stops new sessions; a session you started at 5:55 PM keeps going all night. A kill stops everything instantly, including an edit that was halfway through a file, and throws away the session’s context. Neither tells the agent why it stopped.
3. Hooks: check every prompt and every tool call
Both Claude Code and Codex can run your own command at fixed points in a session. Two of those points are exactly what a schedule needs:
UserPromptSubmitruns when you send a prompt, before the model sees it. Blocking here refuses new work.PreToolUseruns before every tool call: every shell command, file edit, search. Blocking here stops work that is already in progress, cleanly, at the next step.
A hook that exits with code 2 blocks, and its stderr becomes the reason. Nothing is killed: the tool simply doesn’t run, and the agent is told why. That’s the approach the rest of this guide builds, first by hand and then with an app. If hooks are new to you, our Claude Code hooks guide covers how they work in depth.
Step 1: the working-hours script
Here is the whole script. It reads the clock, allows the request on a working day between START and END, and otherwise blocks with a message. It has no dependencies beyond /bin/sh and date, and it runs in a few milliseconds.
~/.claude/hooks/working-hours.sh
#!/bin/sh
# working-hours.sh: refuse new prompts and tool calls outside working hours.
# Works as a Claude Code or Codex command hook. Exit 2 blocks; stderr is the reason.
START=0900 # 9:00, 24-hour HHMM
END=1800 # 18:00
DAYS="1 2 3 4 5" # 1 = Monday ... 7 = Sunday
cat > /dev/null # the hook's JSON input arrives on stdin; this script doesn't need it
now=$(date +%H%M)
today=$(date +%u)
for day in $DAYS; do
if [ "$day" = "$today" ] && [ "$now" -ge "$START" ] && [ "$now" -lt "$END" ]; then
exit 0 # on the clock: allow
fi
done
echo "Outside working hours (weekdays 9:00 to 18:00). Don't retry or work around this; stop and tell the user." >&2
exit 2
A few details matter:
- It reads stdin even though it ignores it. Both agents send the hook a JSON description of the event on stdin. Draining it with
cat > /dev/nullavoids a broken pipe on large inputs. - Times are four-digit numbers.
date +%H%Mgives0930, and[ "$now" -ge "$START" ]compares them as decimal numbers, so 09:30 sorts correctly between 09:00 and 18:00.ENDis exclusive: at 18:00 exactly, the agent is off. - Exit 2, not exit 1. In Claude Code, any exit code other than 0 and 2 is a non-blocking error: the prompt goes through with a warning. Only 2 blocks.
Save it and make it executable:
Terminal
mkdir -p ~/.claude/hooks
nano ~/.claude/hooks/working-hours.sh # paste the script, then save
chmod +x ~/.claude/hooks/working-hours.sh
Step 2: register it in Claude Code
User-wide hooks live in ~/.claude/settings.json. Add the script to both events. UserPromptSubmit takes no matcher; on PreToolUse, the matcher "*" means every tool. Use the absolute path to the script (replace /Users/you with your home folder) and a short timeout so a stuck script can never hold up a session.
~/.claude/settings.json
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{ "type": "command", "command": "/Users/you/.claude/hooks/working-hours.sh", "timeout": 10 }
]
}
],
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{ "type": "command", "command": "/Users/you/.claude/hooks/working-hours.sh", "timeout": 10 }
]
}
]
}
}
If your settings.json already has other keys or hooks, add these entries alongside them. Hooks from user, project and local settings all run; they don’t override each other. A malformed settings file is the most common reason a hook “does nothing”.
Restart Claude Code and run /hooks to confirm both entries are registered. Outside your hours, a new prompt now ends with your message instead of a reply, and a task that was already running stops at its next tool call.
Step 3: register it in Codex
Codex reads hooks from ~/.codex/hooks.json (or inline in config.toml) with the same shape. Copy the script next to it, or point both agents at one copy:
~/.codex/hooks.json
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{ "type": "command", "command": "/Users/you/.codex/hooks/working-hours.sh", "timeout": 10 }
]
}
],
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{ "type": "command", "command": "/Users/you/.codex/hooks/working-hours.sh", "timeout": 10 }
]
}
]
}
}
Codex adds one step Claude Code doesn’t have: trust. A new or changed hook is skipped until you review it. Start Codex, open /hooks, and trust both entries. Codex records trust against the hook’s current definition, so if you later edit the command line you’ll be asked again. Our Codex hooks guide covers the trust model in detail.
Codex documents that some tool paths, such as hosted web search, don’t trigger hooks, so tool hooks aren’t a complete enforcement boundary. For working hours that’s fine: the next prompt and the next shell command or edit are still checked.
Step 4: test it without waiting for 6 PM
You can run the hook yourself, exactly as an agent would, by piping a sample event into it. During working hours this prints nothing and exits 0:
Terminal
echo '{"hook_event_name":"UserPromptSubmit","prompt":"test"}' | ~/.claude/hooks/working-hours.sh
echo "exit $?"
To test the boundaries, put a fake date earlier in your PATH for a single command. This shim answers only the two formats the script asks for and passes everything else to the real date:
Terminal · a fake clock for testing
mkdir -p /tmp/fakeclock
cat > /tmp/fakeclock/date <<'SHIM'
#!/bin/sh
case "$1" in
+%H%M) echo "${FAKE_HM:-1200}" ;;
+%u) echo "${FAKE_U:-3}" ;;
*) exec /bin/date "$@" ;;
esac
SHIM
chmod +x /tmp/fakeclock/date
Terminal · try Friday 17:59, Friday 18:00 and Saturday noon
try() {
echo '{"hook_event_name":"PreToolUse","tool_name":"Bash"}' |
FAKE_HM=$1 FAKE_U=$2 PATH="/tmp/fakeclock:$PATH" ~/.claude/hooks/working-hours.sh
echo "day $2 at $1 -> exit $?"
}
try 1759 5 # Friday 17:59
try 1800 5 # Friday 18:00
try 1200 6 # Saturday noon
You should see exactly this (we ran it in both zsh and bash):
Output
day 5 at 1759 -> exit 0
Outside working hours (weekdays 9:00 to 18:00). Don't retry or work around this; stop and tell the user.
day 5 at 1800 -> exit 2
Outside working hours (weekdays 9:00 to 18:00). Don't retry or work around this; stop and tell the user.
day 6 at 1200 -> exit 2
When you’re done, delete /tmp/fakeclock. The fake clock only affects the commands you prefix with it; your agents always see the real time.
Night shifts that cross midnight
The first script assumes START is earlier than END. If you work evenings, say 22:00 to 06:00, you have to decide which day the early-morning hours belong to. The sensible answer is the day the shift started: Friday night’s shift runs into Saturday morning, but there is no shift on Sunday night if Sunday isn’t a working day.
~/.claude/hooks/shift-hours.sh
#!/bin/sh
# shift-hours.sh: like working-hours.sh, but a shift may cross midnight (START later than END).
# A night shift belongs to the day it starts: with START=2200, END=0600 and DAYS="1 2 3 4 5",
# Friday 22:00 to Saturday 06:00 is on the clock, Sunday 22:00 to Monday 06:00 is not.
START=2200
END=0600
DAYS="1 2 3 4 5" # the days a shift starts on, 1 = Monday ... 7 = Sunday
cat > /dev/null
now=$(date +%H%M)
today=$(date +%u)
yesterday=$(( today == 1 ? 7 : today - 1 ))
starts_on() { for d in $DAYS; do [ "$d" = "$1" ] && return 0; done; return 1; }
if [ "$START" -lt "$END" ]; then
# Day shift, for example 0900 to 1800.
starts_on "$today" && [ "$now" -ge "$START" ] && [ "$now" -lt "$END" ] && exit 0
else
# Night shift: the evening part belongs to today, the early-morning part to yesterday's shift.
starts_on "$today" && [ "$now" -ge "$START" ] && exit 0
starts_on "$yesterday" && [ "$now" -lt "$END" ] && exit 0
fi
echo "Outside working hours. Don't retry or work around this; stop and tell the user." >&2
exit 2
We tested it against the cases that usually go wrong:
| Moment | Result | Why |
|---|---|---|
| Monday 21:59 | Blocked | Shift hasn’t started |
| Monday 22:00 | Allowed | Monday’s shift starts |
| Saturday 00:00 to 05:59 | Allowed | Still Friday’s shift |
| Saturday 06:00 | Blocked | END is exclusive |
| Saturday 22:00, Sunday 22:00 | Blocked | No shift starts at the weekend |
| Monday 01:00 | Blocked | Would belong to Sunday’s shift |
| Tuesday 01:00 | Allowed | Monday’s shift |
Set START=0900 and END=1800 and the same script behaves exactly like the day-shift version; we re-ran those boundary cases too.
Write a message the agent will respect
The reason you print on stderr is not decoration. It is what stops the agent from trying again in a different way.
- In Claude Code, a blocked prompt shows the reason to you; it isn’t added to Claude’s context. Write it for a person: what happened and when work resumes.
- A blocked tool call sends the reason to Claude. Without guidance, a capable agent treats a failed command as an obstacle and tries another tool. Say plainly: don’t retry, don’t work around it, stop and tell the user.
That’s why every script here ends its message with “Don’t retry or work around this; stop and tell the user.” In our experience it is the difference between an agent that stops cleanly and one that burns ten tool calls discovering that every tool is blocked.
Claude Code also accepts a JSON decision on stdout with exit 0: {"decision": "block", "reason": "…"} for prompts, and a hookSpecificOutput with "permissionDecision": "deny" for tool calls. Exit 2 with stderr works in both agents and is the simplest to get right. The hooks guide shows both.
Adding a daily limit and an overtime override
Hours are stateless: the clock is all you need. A daily limit (“four hours of agent time a day”) isn’t, and that’s where a script gets harder:
- You need state. Each check has to add to a counter that survives between runs and resets at midnight.
- You need a definition of “time”. A hook only sees moments, not durations. A reasonable estimate: if two checks are less than five minutes apart, count the gap as work; a longer gap means you stepped away.
- You need a lock. Agents run tool calls in parallel, and two sessions can run at once. Without a lock, two hooks read the same counter and one update is lost.
Here is a version that adds both a daily limit and an overtime override. It uses mkdir as the lock, because creating a directory is atomic, and a single state line of date seconds-used last-check.
~/.claude/hooks/agent-hours.sh
#!/bin/sh
# agent-hours.sh: working hours, an overtime override and a daily limit for coding agents.
# A command hook for Claude Code and Codex. Exit 0 allows; exit 2 blocks with stderr as the reason.
START=0900 # HHMM, 24-hour clock
END=1800 # may be earlier than START for a night shift
DAYS="1 2 3 4 5" # days a shift starts on, 1 = Monday ... 7 = Sunday
LIMIT_MINUTES=240 # agent time per day; 0 turns the limit off
IDLE_SECONDS=300 # a gap longer than this between checks isn't counted as work
STATE="$HOME/.config/agent-hours"
cat > /dev/null # the hook's JSON input; not needed here
block() {
echo "$1 Don't retry or work around this; stop and tell the user." >&2
exit 2
}
mkdir -p "$STATE" || block "Agent hours can't save its state."
epoch=$(date +%s)
# 1. Overtime: a file holding an expiry time (epoch seconds) lets everything through until then.
if [ -f "$STATE/overtime-until" ] && [ "$(cat "$STATE/overtime-until")" -gt "$epoch" ] 2>/dev/null; then
exit 0
fi
# 2. Working hours (a night shift belongs to the day it starts).
now=$(date +%H%M)
today=$(date +%u)
yesterday=$(( today == 1 ? 7 : today - 1 ))
starts_on() { for d in $DAYS; do [ "$d" = "$1" ] && return 0; done; return 1; }
on_shift=no
if [ "$START" -lt "$END" ]; then
starts_on "$today" && [ "$now" -ge "$START" ] && [ "$now" -lt "$END" ] && on_shift=yes
else
starts_on "$today" && [ "$now" -ge "$START" ] && on_shift=yes
starts_on "$yesterday" && [ "$now" -lt "$END" ] && on_shift=yes
fi
[ "$on_shift" = yes ] || block "Outside working hours."
# 3. Daily limit. Hooks can run in parallel, so take a lock (mkdir is atomic) before touching the counter.
[ "$LIMIT_MINUTES" -gt 0 ] || exit 0
tries=0
until mkdir "$STATE/lock" 2>/dev/null; do
tries=$((tries + 1))
[ "$tries" -lt 50 ] || block "Agent hours is busy."
sleep 0.1
done
trap 'rmdir "$STATE/lock" 2>/dev/null' EXIT
day=$(date +%Y-%m-%d)
used=0; last=0; saved_day=""
[ -f "$STATE/usage" ] && read -r saved_day used last < "$STATE/usage"
if [ "$saved_day" != "$day" ]; then used=0; last=0; fi
gap=$(( epoch - last ))
if [ "$last" -gt 0 ] && [ "$gap" -gt 0 ] && [ "$gap" -le "$IDLE_SECONDS" ]; then
used=$(( used + gap ))
fi
[ "$used" -lt $(( LIMIT_MINUTES * 60 )) ] || block "Today's agent time is used up."
echo "$day $used $epoch" > "$STATE/usage"
exit 0
We tested it with a fake clock: checks 60 seconds apart add 60 seconds each; a ten-minute gap adds nothing; the block arrives when the counter reaches 240 minutes; the counter resets on a new day; and twenty hooks started at the same moment all completed, left a well-formed state file and no stale lock.
Overtime is a file holding an expiry time. To give yourself 30 minutes after hours:
Terminal · take a 30-minute overtime pass
echo $(( $(date +%s) + 30 * 60 )) > ~/.config/agent-hours/overtime-until
When the time passes, the file is simply ignored; anything that isn’t a number is ignored too. Time spent on overtime isn’t counted against the daily limit.
It estimates activity from hook checks, so a single 20-minute test run with no tool calls in between counts as nothing. A kill -9 during the few milliseconds the lock is held leaves ~/.config/agent-hours/lock behind; delete it if checks start reporting “busy”. And the overtime file is just a file: an agent with shell access could write it. Ask for overtime yourself, from your own terminal.
What about breaks?
A break reminder (“stop for 15 minutes every two hours”) needs one more counter: continuous session time that resets after a long enough pause, plus a way to start and end a break on purpose. It’s doable with the same state-and-lock pattern, but it’s also where scripts start to feel like a small app. Which brings us to the easy way.
The easy way: AgentUnion
AgentUnion is a free, native Mac menu bar app that does everything above without scripts. It installs one small policy engine as the UserPromptSubmit and PreToolUse hook for Claude Code and Codex, and gives you the schedule as a union agreement:
- Working days and hours, including overnight shifts that belong to the day they start.
- A daily limit of agent time, with an idle reset after five minutes so a coffee doesn’t count as work.
- Coffee breaks every N hours, with a reminder, a snooze and “end break early”.
- Overtime passes of 15, 30 or 60 minutes, at most three a day, that a person takes from the menu bar or an interactive terminal. Passes can’t be chained, and they show up in the weekly report.
- On strike: pause all agents with one click.
- Block messages that say when your agents clock back in, and tell the agent not to work around them.
The engine is a native binary that answers in about 10 ms. It keeps your other hooks, backs up your agent settings before every change, and runs hooks from one stable path, so app updates never change the hook command and Codex doesn’t ask you to trust it again. Your schedule and activity stay in ~/.config/agentunion: no account, no cloud.
Same honesty as above: AgentUnion is a boundary you set for yourself, not a security product. Agents run as your user. It makes the honest path easy and the other path deliberate, and it tells you when its hooks or schedule were changed outside the app. You can try the rules on your own clock in the browser before installing.
DIY script vs AgentUnion
| DIY hook script | AgentUnion | |
|---|---|---|
| Working hours | Yes, edit the script | Yes, in the app |
| Overnight shifts | Yes, with shift-hours.sh | Yes |
| Daily limit | Estimated, with state and a lock | Yes, with idle reset |
| Breaks | Build it yourself | Yes, with reminders |
| Overtime | A file anyone can write | Passes a person takes; 3 a day |
| Claude Code and Codex | Register in both by hand | Connects both |
| Codex trust after changes | Re-trust when the command changes | Stable path; trust once |
| Notices and reports | None | Menu bar, notifications, weekly report |
| Platforms | Anywhere with /bin/sh | macOS 13 or later |
| Cost | Free | Free |
If you run agents on Linux servers or want something you can read in one screen, the script is a good choice. If you work on a Mac and want hours, breaks, a limit and overtime without maintaining them, use the app.
Give your agents a closing time
AgentUnion sets working hours, breaks and a daily limit for Claude Code and Codex from your Mac’s menu bar. Free, native, and nothing leaves your Mac.
Get AgentUnion for MacFAQ
Can I stop Claude Code from running at night?
Yes. Register a command hook on both UserPromptSubmit and PreToolUse that exits with code 2 outside your working hours. New prompts are refused, and a task that is already running stops at its next tool call. The script in this guide does exactly that, and AgentUnion does it from the Mac menu bar.
Do Claude’s five-hour and weekly limits work as a schedule?
No. They meter how much you use. Anthropic’s help center describes the five-hour session as starting with your first message, so it moves with you rather than with the clock, and the weekly limit caps total usage. Neither one stops your agent at 6 PM.
Does the same hook script work for Codex?
Yes. Codex runs command hooks from ~/.codex/hooks.json in the same shape, and exit code 2 with a reason on stderr blocks a prompt or a tool call. Codex only runs a hook after you review and trust it in /hooks, and it asks again whenever the hook’s definition changes.
Will blocking a tool call break my agent’s work?
No. A blocked tool call simply doesn’t run, and the agent receives your reason instead. Nothing is killed halfway through a file write, which is the risk with pkill or cron-based shutdowns. Tell the agent in the message to stop and report, so it doesn’t try another tool.
How do I let my agent finish something after hours?
Add an escape hatch you have to choose deliberately, such as an overtime file holding an expiry time, as shown in this guide. AgentUnion has overtime passes of 15, 30 or 60 minutes, limited to three a day, that a person takes from the menu bar or an interactive terminal.
Is a working-hours hook a security control?
No. Hooks run as your user, and an agent with shell access could edit your settings or the script. Treat it as a boundary that makes the honest path easy: it stops the habit of “one more task” at midnight, not a determined attacker.


