A self-contained recipe. Read this one file and you can reproduce the whole mechanism from scratch β a human or another LLM, knowing nothing else.
An LLM agent has no clock. It only knows what is in its context window. So:
- Inject a timestamp on everyaction β the context fills with noise. - Inject one onceat session start β it goes stale within minutes.
The goal is to inject timestamps at a human-like cadence: frequent enough that the agent stays oriented in time (and notices the passage of time β e.g. "that build took 40 minutes"), but sparse enough to be almost invisible.
Claude Code hooks are external commands the harness runs on certain events. A hook can print a line of JSON of this shape:
{"hookSpecificOutput":{"hookEventName":"<event>","additionalContext":"β± ..."}}
The additionalContext
string is injected into the model's context. That is the entire delivery channel: the hook prints that line, the model sees the timestamp on its next think.
- Verified working on
Claude Code 2.1.177. - Every hook receives a JSON object on
stdin that includes
session_id
andhook_event_name
(plus event-specific fields). additionalContext
injection works for bothUserPromptSubmit
andPostToolUse
events (the two we use).
Wire the same script into two events:
β fires once per user turn β tracks theUserPromptSubmit
human's cadence (a fresh stamp each time you talk to the agent).β fires after each tool call β tracks time during longPostToolUse
autonomous runs when the human isn't typing.
Both call one script which is internally rate-limited, so the vast majority
of PostToolUse
invocations print nothing. That is what makes it cheap to leave enabled on every tool call.
The script emits one of three things:
longββ± Monday, June 23, 2026 10:27 PM EDT
β thefirst emit of each wall-clock hour.shortββ± 10:33 PM EDT
β whenmore than 5 minutes have passed since the last short emit.noneβ silent, otherwise.
A long emit also resets the short timer, so you never get a long immediately echoed by a short.
The decision is a pure function of four numbers, with no I/O β which makes it trivially testable:
decide(now_epoch, now_hourkey, last_hourkey, last_short_epoch):
if now_hourkey != last_hourkey -> long (new state: now_hourkey, now_epoch) # incl. first run
elif (now_epoch - last_short) > 300 -> short (new state: last_hourkey, now_epoch)
else -> none (state unchanged)
hourkey
is date +%Y%m%d%H
β so a new hour, a new day, or "24h later, same hour" all register as a new hour.
| Part | Path |
|---|---|
| The script (executable) | ~/bin/time-awareness-hook (here: a symlink into ~/dotfiles/bin/ ) |
| The tests | ~/dotfiles/bin/test/time-awareness-hook_test |
| The wiring | ~/.claude/settings.json β hooks |
| Per-session state | ${XDG_STATE_HOME:-~/.local/state}/claude-time-awareness/state-<session_id> |
State is per session (keyed on the hook's session_id
), so concurrent
sessions don't interfere; the state file is two lines (hourkey
then
last_short_epoch
). Stale state files (>1 day) are pruned on each long emit.
Save the following as an executable on your PATH
(e.g. ~/bin/time-awareness-hook
),
then chmod +x
it.
#!/usr/bin/env bash
#
#
set -u
SHORT_INTERVAL=300 # seconds; "more than 5 minutes" => strict > this
_decide() {
local now_epoch="$1" now_hourkey="$2" last_hourkey="$3" last_short="${4:-0}"
if [ "$now_hourkey" != "$last_hourkey" ]; then
printf 'long %s %s\n' "$now_hourkey" "$now_epoch" # new hour (incl. first run)
elif [ $(( now_epoch - last_short )) -gt "$SHORT_INTERVAL" ]; then
printf 'short %s %s\n' "$last_hourkey" "$now_epoch" # >5 min since last short
else
printf 'none %s %s\n' "$last_hourkey" "$last_short" # too soon; state unchanged
fi
}
_fmt() { date -d "@$1" +"$2" 2>/dev/null || date -r "$1" +"$2"; }
_json_get() {
local json="$1" key="$2"
if command -v jq >/dev/null 2>&1; then
printf '%s' "$json" | jq -r --arg k "$key" '.[$k] // empty' 2>/dev/null
else
printf '%s' "$json" | grep -oE "\"$key\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" \
| head -1 | sed -E "s/.*:[[:space:]]*\"([^\"]*)\"/\1/"
fi
}
_safe() { printf '%s' "$1" | tr -c 'A-Za-z0-9_.-' '_'; }
_usage() {
cat <<'EOF'
time-awareness-hook β rate-limited timestamp injection for Claude Code
USAGE
time-awareness-hook # hook mode: reads hook JSON on stdin, emits
time-awareness-hook --decide NOW HOURKEY LAST_HOURKEY LAST_SHORT
time-awareness-hook -h | --help
time-awareness-hook --about
time-awareness-hook --test # run test suite (stdout muted; exit = #fails)
CADENCE
long : first emit of each wall-clock hour (weekday, full date, time)
short : when > 5 min since the last short (just the time)
none : otherwise (silent)
A long emit resets the short timer.
ENV (test seams)
TIMESTAMP_HOOK_NOW epoch-seconds override for "now"
TIMESTAMP_HOOK_STATE_DIR state directory (default: $XDG_STATE_HOME/claude-time-awareness)
TIMESTAMP_HOOK_SESSION session id fallback when stdin lacks one
EOF
}
_hook_main() {
local input=""
if [ ! -t 0 ]; then input="$(cat)"; fi
local session event
session="$(_json_get "$input" session_id)"
event="$(_json_get "$input" hook_event_name)"
[ -n "$session" ] || session="${TIMESTAMP_HOOK_SESSION:-default}"
[ -n "$event" ] || event="UserPromptSubmit"
local now state_dir state_file
now="${TIMESTAMP_HOOK_NOW:-$(date +%s)}"
state_dir="${TIMESTAMP_HOOK_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/claude-time-awareness}"
mkdir -p "$state_dir" 2>/dev/null || true
state_file="$state_dir/state-$(_safe "$session")"
local now_hourkey last_hourkey="" last_short="0"
now_hourkey="$(_fmt "$now" '%Y%m%d%H')"
if [ -f "$state_file" ]; then
{ read -r last_hourkey; read -r last_short; } < "$state_file" 2>/dev/null || true
[ -n "$last_short" ] || last_short="0"
fi
local emit new_hourkey new_short
read -r emit new_hourkey new_short < <(_decide "$now" "$now_hourkey" "$last_hourkey" "$last_short")
[ "$emit" = "none" ] && return 0
local tmp="$state_file.tmp.$$"
printf '%s\n%s\n' "$new_hourkey" "$new_short" > "$tmp" 2>/dev/null && mv -f "$tmp" "$state_file" 2>/dev/null
[ "$emit" = "long" ] && find "$state_dir" -maxdepth 1 -type f -name 'state-*' -mtime +0 -delete 2>/dev/null
local stamp
case "$emit" in
long) stamp="$(_fmt "$now" 'β± %A, %B %d, %Y %I:%M %p %Z')" ;;
short) stamp="$(_fmt "$now" 'β± %I:%M %p %Z')" ;;
esac
printf '{"hookSpecificOutput":{"hookEventName":"%s","additionalContext":"%s"}}\n' "$event" "$stamp"
}
case "${1:-}" in
-h|--help) _usage; exit 0 ;;
--about) echo "time-awareness-hook: rate-limited timestamp injection for Claude Code ($(uname -s) $(uname -m))"; exit 0 ;;
--decide) shift; _decide "$@"; exit 0 ;;
--test)
_tf="$HOME/dotfiles/bin/test/time-awareness-hook_test"
if [ -f "$_tf" ]; then exec bash "$_tf" >/dev/null; fi
echo "no test file at $_tf" >&2; exit 0 ;;
*) _hook_main; exit 0 ;;
esac
Note: the bash function bodies are
tab-indented. If you copy/paste, keep the tabs (or it still runs β bash doesn't care β but match your house style).
Edit ~/.claude/settings.json
(user-global) β or a project's
.claude/settings.json
β and merge this hooks
object in. If a hooks
object already exists, add these two keys to it; do not clobber sibling hooks.
{
"hooks": {
"UserPromptSubmit": [
{ "hooks": [ { "type": "command", "command": "$HOME/bin/time-awareness-hook" } ] }
],
"PostToolUse": [
{ "hooks": [ { "type": "command", "command": "$HOME/bin/time-awareness-hook" } ] }
]
}
}
- Omitting
"matcher"
onPostToolUse
matchesall tools (fine β it's rate-limited). Add"matcher": "Bash"
(etc.) to scope it to specific tools. - Use
$HOME/bin/...
(not a bare name) for portability across machines. The effect is immediate β no session restart needed.
printf '{"hook_event_name":"PostToolUse","session_id":"demo"}' \
| TIMESTAMP_HOOK_STATE_DIR="$(mktemp -d)" ~/bin/time-awareness-hook
In a live session you'll then see lines like β± 10:33 PM EDT
appear in the agent's context β long once per hour, short after >5 min, silent otherwise.
Save as ~/dotfiles/bin/test/time-awareness-hook_test
and run it directly
(bash time-awareness-hook_test
) β it exits with the number of failures. It tests the pure core at every boundary (first run, the exactly-300s vs 301s line, the hour-rollover override) and the adapter with an injected clock and an isolated temp state dir, so it's fully deterministic.
#!/usr/bin/env bash
#
#
set -u
HOOK="$HOME/dotfiles/bin/time-awareness-hook"
tests=0
fails=0
expect_eq() { # actual expected msg
((tests++))
if [ "$1" = "$2" ]; then
echo "β $3"
else
echo "β $3" >&2
echo " expected: [$2]" >&2
echo " actual: [$1]" >&2
((fails++))
fi
}
expect_contains() { # haystack needle msg
((tests++))
if printf '%s' "$1" | grep -q -- "$2"; then echo "β $3"
else echo "β $3" >&2; echo " [$1] lacks [$2]" >&2; ((fails++)); fi
}
expect_not_contains() { # haystack needle msg
((tests++))
if printf '%s' "$1" | grep -q -- "$2"; then echo "β $3" >&2; echo " [$1] unexpectedly has [$2]" >&2; ((fails++))
else echo "β $3"; fi
}
fmt() { date -d "@$1" +"$2" 2>/dev/null || date -r "$1" +"$2"; }
emit() { "$HOOK" --decide "$@" | cut -d' ' -f1; }
state() { "$HOOK" --decide "$@" | cut -d' ' -f2-; }
echo "# pure decision core"
expect_eq "$(emit 1000000000 2026062221 '' 0)" "long" "first run -> long"
expect_eq "$(state 1000000000 2026062221 '' 0)" "2026062221 1000000000" "first run state = nowhour + now"
expect_eq "$(emit 1000000100 2026062221 2026062221 1000000000)" "none" "same hour <5min -> none"
expect_eq "$(emit 1000000300 2026062221 2026062221 1000000000)" "none" "exactly 5min -> none (strict)"
expect_eq "$(emit 1000000301 2026062221 2026062221 1000000000)" "short" "just over 5min -> short"
expect_eq "$(state 1000000301 2026062221 2026062221 1000000000)" "2026062221 1000000301" "short keeps hour, bumps short epoch"
expect_eq "$(emit 1000000010 2026062222 2026062221 1000000000)" "long" "hour change overrides recent short"
expect_eq "$(state 1000000010 2026062222 2026062221 1000000000)" "2026062222 1000000010" "long resets short epoch to now"
echo "# hook adapter (injected clock + temp state)"
TMP="$(mktemp -d --tmpdir time-awareness-hook.XXXXXX)"
run() { # now_epoch session
printf '{"hook_event_name":"PostToolUse","session_id":"%s"}' "$2" \
| TIMESTAMP_HOOK_NOW="$1" TIMESTAMP_HOOK_STATE_DIR="$TMP" "$HOOK"
}
WD="$(fmt 1000000000 %A)" # weekday name only appears in the LONG format
o6="$(run 1000000000 sessA)"
expect_contains "$o6" "additionalContext" "fresh session emits additionalContext JSON"
expect_contains "$o6" "$WD" "fresh session emit is LONG (weekday present)"
expect_eq "$([ -f "$TMP/state-sessA" ] && echo yes)" "yes" "state file created for session"
expect_eq "$(run 1000000120 sessA)" "" "2 min later same hour -> no output"
o8="$(run 1000000400 sessA)"
expect_contains "$o8" "additionalContext" "6+ min later emits"
expect_not_contains "$o8" "$WD" "6+ min emit is SHORT (no weekday)"
o9="$(run 1000000400 sessB)"
expect_contains "$o9" "$WD" "different session is independent -> LONG"
expect_eq "$([ -f "$TMP/state-sessB" ] && echo yes)" "yes" "separate state file per session"
if [ "$fails" -gt 0 ]; then
echo "$fails of $tests tests FAILED" >&2
else
echo "all $tests tests passed"
fi
exit "$fails"
Bash+(GNUdate
date -d @epoch
is tried first, BSDdate -r epoch
is the fallback β so it works on both Linux and macOS).is used if present; otherwise ajq
grep
/sed
fallback parses the two fields we need from the stdin JSON. Sojq is optional.- A
Claude Code recent enough to injecthookSpecificOutput.additionalContext
forUserPromptSubmit
/PostToolUse
(confirmed on2.1.177).
Two hooks because the human and the agent move on different clocks: a user turn is the human's heartbeat; a tool call is the agent's. Covering both means the agent is oriented whether you're driving or it's running autonomously.Rate-limiting in the script, not the wiring, soPostToolUse
can stay oneverytool with negligible cost β it returns instantly with no output the vast majority of the time.Long-hourly / short->5min / silent mirrors how people actually track time: you register the hour when it rolls over, and you're vaguely aware of ~5-minute chunks in between. The long-emit-resets-short rule prevents a redundant short right after a long.The pure(no clock, no files, no stdin) is the part worth lifting into any agent harness β it's what makes the cadence testable with an injected clock, and it's language-agnostic (port the 3-line rule anywhere).--decide
corePer-session state keyed onsession_id
keeps concurrent agent sessions from stomping each other's timers.