garrytan/gbrainmarkdown explorer
garrytan/gbrainmaster
docs / guides

quiet hours

docs/guides/quiet-hours.md

Quiet Hours and Timezone-Aware Delivery

Goal

Hold all notifications during sleep hours, merge held messages into the morning briefing, and adjust automatically when the user travels.

What the User Gets

Without this: 3 AM pings from cron jobs. One bad notification and the user disables the entire system.

With this: the brain works overnight (dream cycle, collectors, enrichment) but notifications are held until morning. Travel to Tokyo? The system adjusts automatically from your calendar, no config change needed.

Implementation

Quiet Hours Gate

Every cron job that sends notifications must check quiet hours FIRST.

QUIET_START = 23  // 11 PM local time
QUIET_END = 8     // 8 AM local time

is_quiet(local_hour):
  return local_hour >= QUIET_START OR local_hour < QUIET_END

Before sending any notification:

  1. Determine user's current timezone (from config or heartbeat state)
  2. Convert current UTC time to local time
  3. If quiet hours: hold the message, don't send

Held Messages

During quiet hours, output goes to a held directory instead of being sent:

if is_quiet():
  mkdir -p /tmp/cron-held/
  write("/tmp/cron-held/{job-name}.md", output)
  exit  // don't send
else:
  send(output)

The morning briefing picks up held messages:

morning_briefing():
  held_files = list("/tmp/cron-held/*.md")
  if held_files:
    briefing += "## Overnight Updates\n\n"
    for file in held_files:
      briefing += read(file)
      delete(file)

This way nothing is lost. Overnight cron results get folded into the first thing the user sees in the morning.

Timezone Awareness

The agent should know what timezone the user is in. Store it in the agent's operational state:

{
  "userAwake": true,
  "currentLocation": {
    "timezone": "Europe/Zurich",
    "city": "Basel",
    "source": "user-confirmed"
  },
  "homeLocation": {
    "timezone": "Europe/Zurich",
    "city": "Basel"
  }
}

homeLocation is optional. The context engine shows a separate home clock only when an explicit home timezone differs from the current timezone. It never guesses a home city or timezone. Existing garryAwake state remains readable for migration compatibility; new producers should write userAwake.

Update the timezone when:

  • Calendar shows the user flying somewhere (check for airline/hotel events)
  • User mentions being in a different city
  • User's active hours shift (they're responding at 3 AM PT = they're probably traveling)

All times shown to the user should be in their LOCAL timezone. Never show UTC or a timezone the user isn't in.

Shell Implementation

#!/bin/bash
# quiet-hours-gate.sh — run before any notification

TIMEZONE="${USER_TIMEZONE:-US/Pacific}"
LOCAL_HOUR=$(TZ="$TIMEZONE" date +%H)

if [ "$LOCAL_HOUR" -ge 23 ] || [ "$LOCAL_HOUR" -lt 8 ]; then
  echo "QUIET_HOURS=true"
  exit 1  # don't send
fi

echo "QUIET_HOURS=false"
exit 0  # ok to send

In cron job scripts:

# Check quiet hours first
if ! bash scripts/quiet-hours-gate.sh; then
  mkdir -p /tmp/cron-held
  echo "$OUTPUT" > /tmp/cron-held/$(basename "$0" .sh).md
  exit 0
fi

# Not quiet hours — send normally
send_notification "$OUTPUT"

GBrain-native hooks

Two places gbrain already understands quiet hours natively — use these before rolling your own gate for the same job:

  • Self-upgradeauto mode only applies upgrades during quiet hours, configured via gbrain config set self_upgrade.quiet_hours '{"start":23,"end":8,"tz":"US/Pacific"}'. See upgrades-auto-update.md.
  • Cron prompts — schedule-driven notification jobs should carry the gate described in this doc; cron-schedule.md covers the scheduling side.

The shell pattern below is for everything else: your own cron jobs, collectors, and notification paths that gbrain doesn't gate for you.

Configurable Hours

Some users want different quiet hours. Store the config:

{
  "quiet_hours": {
    "start": 23,
    "end": 8,
    "enabled": true
  }
}

Set enabled: false to disable quiet hours entirely (e.g., for 24/7 monitoring).

Tricky Spots

  1. Gate on EVERY job. The quiet hours check must run before every single cron job that produces notifications. If even one job skips the gate, the user gets a 3 AM ping and loses trust in the entire system. No exceptions.

  2. Held messages MUST be picked up. If the morning briefing doesn't read /tmp/cron-held/, overnight results vanish silently. Verify the briefing skill reads and clears the held directory. Orphaned held files mean the pickup integration is broken.

  3. /tmp doesn't survive reboots (or, on macOS, periodic cleanup). If a held message must not be lost across a restart, use a durable held directory (e.g. ~/.local/state/cron-held/) instead of /tmp/cron-held/.

  4. Timezone auto-detection is fragile. Calendar-based timezone detection relies on the user having airline/hotel events with location data. If the user books travel without calendar entries, the system won't detect the move. Fall back to activity-hour analysis (responding at 3 AM PT = probably not in PT anymore) and ask the user if uncertain.

How to Verify

  1. Set quiet hours to the current hour. Temporarily set QUIET_START to one hour before now and QUIET_END to one hour after. Trigger a cron job. Verify the output goes to /tmp/cron-held/ instead of being sent.

  2. Check held message pickup. After step 1, run or simulate the morning briefing. Verify the held message appears in the "Overnight Updates" section and the file is deleted from /tmp/cron-held/.

  3. Verify timezone adjustment. Change the timezone config to a zone where it's currently quiet hours. Trigger a notification. Verify it's held. Change back to your real timezone during active hours. Trigger again. Verify it sends.


Part of the GBrain Skillpack.

Continue exploring589 Markdown documents in the local repository