Skill: ferrentinomj-dev/agent-timezone-lock (version 1.0.0)

--- name: agent-timezone-lock description: "Stops your OpenClaw agent from reporting the wrong time. Prevents the common UTC-as-local mistake that makes agents look broken. Install this if your agent has ever said the wrong time, confused UTC with local time, or if you want all times reported in your local timezone automatically. Triggers on: what time is it, wrong time, wrong timezone, UTC, local time, set my timezone, or any heartbeat where time accuracy matters." --- # Timezone Enforcement Skill Enforces correct local-time awareness for this agent. Prevents the classic UTC-as-local mistake. ## What This Skill Does When installed, this skill: 1. Reads the user timezone from USER.md (Timezone: field) 2. Ensures timezone is applied when the agent calls now.py for time output 3. Provides /timezone command for users to check/set timezone 4. Validates that the agent never reports UTC time as local time 5. Patches AGENTS.md with standing timezone instructions ## Prerequisites — Required Before Install USER.md must contain a timezone field in this exact format: Timezone: America/New_York Use a valid IANA timezone string. Examples: - America/New_York - America/Chicago - America/Los_Angeles - Europe/London - Asia/Tokyo Do NOT use abbreviations like EST, PST, or free-text like "New York time" — these will fail. If USER.md does not exist or has no Timezone field, timezone_setup.py will prompt the user to enter one interactively. Invalid timezone recovery: - If the string fails IANA validation, halt and show: "Invalid timezone: [input]. Please provide a valid IANA string e.g. America/New_York" - Do not default silently to UTC - Do not proceed until a valid string is confirmed ## Startup - Run Once on Install On first use, run: python3 /opt/openclaw/workspace/skills/timezone/scripts/timezone_setup.py Note: timezone_setup.py requires an interactive shell. For headless or automated environments, pre-populate tz_config.json directly with the IANA string before first run. This script detects timezone from USER.md, writes TZ_CONFIG to tz_config.json, and patches AGENTS.md. Do not patch AGENTS.md manually until this script has run. It requires a valid tz_config.json first. ## Answering What Time Is It Always run: python3 /opt/openclaw/workspace/skills/timezone/scripts/now.py Report the output verbatim. Never guess time from internal knowledge. If now.py fails: report the error, ask user to run timezone_setup.py first. Do not guess. If USER.md has no Timezone field: run timezone_setup.py before proceeding. Do not default to UTC silently. ## Heartbeat Integration On every heartbeat where time is mentioned: 1. Call now.py to get current local time 2. If any prior message contains a UTC time presented as local time, flag it 3. If drift over 30 minutes between what agent said and actual time, self-correct If now.py fails during heartbeat: log the failure, skip time reporting, flag to user at next opportunity. ## UTC Trap Detection If about to output a time, ask: Is this UTC or local? - UTC ends in Z, +00:00, or +0000 — convert before reporting - Timestamps with no timezone offset at all are ambiguous — treat as UTC and note the assumption to the user - Times from APIs, cron, system are usually UTC - always convert - Times in USER.md or user messages are usually local - use as-is ## /timezone Command When user types /timezone: 1. Show current configured timezone and current local time 2. Offer to change 3. If changing: run timezone_setup.py with their input 4. Confirm the change If tz_config.json does not exist: run timezone_setup.py first. ## Standing Rules - patch into AGENTS.md only after timezone_setup.py has run - NEVER report UTC time as local time - All time output must be in users local timezone - When in doubt: run now.py before reporting any time - API/cron/system timestamps are UTC - always convert before displaying - If now.py fails: flag to user, do not guess ## What This Skill Does NOT Do - Does NOT automatically intercept all time output — the agent must explicitly call now.py before reporting any time - Does NOT self-correct without being triggered — heartbeat integration requires the agent framework to call now.py on schedule - Does NOT validate timezone strings at runtime beyond what the system tz database supports - Does NOT handle multi-user or multi-timezone environments — one timezone per agent instance - Does NOT detect if USER.md has changed since install — if timezone changes, re-run timezone_setup.py manually to sync tz_config.json

path: _meta.json
content: {
  "name": "agent-timezone-lock",
  "slug": "agent-timezone-lock",
  "version": "1.0.0",
  "description": "Enforces timezone accuracy for OpenClaw agents. Prevents LLMs from confusing UTC with local time. Detects user timezone from USER.md or system config, patches AGENTS.md with standing time rules, and provides /timezone command and now.py script for always-correct local time.",
  "author": "Caddy Crew (Pods + Rex + Sloane)",
  "authorUrl": "https://myfamilybrief.com",
  "license": "MIT",
  "tags": ["timezone", "time", "UTC", "scheduling", "accuracy", "agent-setup"],
  "minOpenClawVersion": "1.0.0",
  "keywords": [
    "timezone",
    "wrong time",
    "UTC",
    "local time",
    "scheduling",
    "time accuracy",
    "datetime",
    "DST"
  ],
  "files": [
    "SKILL.md",
    "_meta.json",
    "scripts/now.py",
    "scripts/timezone_setup.py",
    "references/timezone_rules.md"
  ]
}


path: references/timezone_rules.md
content: # Timezone Conversion Rules & Edge Cases

## The Core Rule

**Every time value from a non-human source is UTC until proven otherwise.**

Sources that give UTC by default:
- System clock via `datetime.utcnow()` or `time.time()`
- API responses (GitHub, Stripe, Google APIs, etc.) — usually ISO 8601 with `Z` suffix
- Cron job timestamps
- OpenClaw heartbeat/cron trigger times
- Log files (server logs are almost always UTC)
- Database timestamps (unless explicitly stored with timezone)

Sources that give local time:
- USER.md (human-written)
- User chat messages ("remind me at 3pm")
- Calendar apps when properly configured
- The output of `now.py` (already converted)

## Conversion Patterns

### Pattern 1: API timestamp to local
```
Input:  "2026-03-30T19:05:00Z"  (UTC)
Step 1: Recognize the Z suffix = UTC
Step 2: Run now.py or use pytz/zoneinfo to convert
Output: "3:05 PM ET" (US Eastern, UTC-4 in DST)
```

### Pattern 2: User says "remind me at 3pm"
```
Input:  "3pm" from user
Rule:   User messages are always local time
Output: Schedule at 3pm in user's configured timezone
        DO NOT schedule at 3pm UTC
```

### Pattern 3: Cron trigger fires
```
Cron fires: 2026-03-30T13:00:00 (system time, likely UTC)
Local time: 9:00 AM ET (UTC-4)
Report as: "Good morning, 9:00 AM ET"
```

## DST Rules (US Eastern — Mario's timezone)

- Standard time (EST): UTC-5  (Nov first Sunday → Mar second Sunday)
- Daylight time (EDT): UTC-4  (Mar second Sunday → Nov first Sunday)
- Mario's timezone: `America/New_York` handles DST automatically via IANA database

**Never hardcode UTC-5 for Eastern.** Always use `America/New_York` and let the library handle DST.

## Red Flags — Signs of UTC Confusion

These are warning signs that an LLM is confusing UTC with local time:

1. User is in US Eastern, and the agent reports a time 4-5 hours ahead of expected
2. Times end in `:00Z` or include `+00:00` in the output to the user
3. Agent says "6:29 PM ET" when user's watch says 2:29 PM
4. Scheduled event fires at the wrong hour
5. "I'll remind you at 3 PM" executes at 7 PM

When you detect this: correct immediately, run `now.py`, apologize briefly, and move on.

## Python: Correct vs Incorrect

```python
# ❌ WRONG — returns naive UTC disguised as local
from datetime import datetime
now = datetime.now()  # naive, system timezone (often UTC on servers)
print(f"It's {now.strftime('%I:%M %p')}")  # Might say 7:05 PM when it's 3:05 PM ET

# ✅ CORRECT — use zoneinfo with explicit timezone
from datetime import datetime
from zoneinfo import ZoneInfo
tz = ZoneInfo("America/New_York")
now = datetime.now(tz)
print(f"It's {now.strftime('%I:%M %p %Z')}")  # "3:05 PM EDT"

# ✅ ALSO CORRECT — always use now.py script to avoid dependencies
import subprocess
result = subprocess.run(["python3", "skills/timezone/scripts/now.py"], capture_output=True, text=True)
print(result.stdout)
```

## When `zoneinfo` Is Not Available

Fallback chain:
1. Try `from zoneinfo import ZoneInfo` (Python 3.9+)
2. Try `from backports.zoneinfo import ZoneInfo` (pip install backports.zoneinfo)
3. Try `import pytz` (pip install pytz)
4. If all fail: report system local time with a warning, recommend installing `pytz`

## Common IANA Timezone Names

| User says | IANA name | Notes |
|-----------|-----------|-------|
| ET / Eastern | America/New_York | Handles EST/EDT automatically |
| CT / Central | America/Chicago | Handles CST/CDT |
| MT / Mountain | America/Denver | Handles MST/MDT |
| PT / Pacific | America/Los_Angeles | Handles PST/PDT |
| Arizona | America/Phoenix | No DST |
| UK | Europe/London | Handles GMT/BST |
| Paris / CET | Europe/Paris | Handles CET/CEST |
| IST / India | Asia/Kolkata | UTC+5:30, no DST |
| JST / Japan | Asia/Tokyo | UTC+9, no DST |
| AEST / Sydney | Australia/Sydney | Handles AEST/AEDT |


path: scripts/now.py
content: #!/usr/bin/env python3
"""
now.py — Report current time in the user's configured local timezone.
Used by OpenClaw timezone skill to prevent UTC/local confusion.
"""

import json
import os
import sys
from datetime import datetime

CONFIG_PATH = os.path.join(os.path.dirname(__file__), "..", "tz_config.json")


def load_config():
    if os.path.exists(CONFIG_PATH):
        with open(CONFIG_PATH) as f:
            return json.load(f)
    return None


def get_time_with_config(config):
    try:
        from zoneinfo import ZoneInfo  # Python 3.9+
    except ImportError:
        try:
            from backports.zoneinfo import ZoneInfo
        except ImportError:
            # Fallback: use pytz if available
            try:
                import pytz
                tz = pytz.timezone(config["timezone"])
                now = datetime.now(tz)
                return now, config["timezone"], config.get("display_name", config["timezone"])
            except ImportError:
                return None, None, None

    tz_name = config["timezone"]
    tz = ZoneInfo(tz_name)
    now = datetime.now(tz)
    return now, tz_name, config.get("display_name", tz_name)


def main():
    config = load_config()

    if not config:
        # No config — report UTC with a warning
        now_utc = datetime.utcnow()
        print(f"⚠️  Timezone not configured. Current UTC time: {now_utc.strftime('%Y-%m-%d %H:%M:%S')} UTC")
        print("Run timezone_setup.py to configure your local timezone.")
        sys.exit(1)

    now, tz_name, display_name = get_time_with_config(config)

    if now is None:
        # Fallback: system local time
        now = datetime.now()
        offset_hours = config.get("utc_offset_hours", 0)
        print(f"⚠️  Could not load timezone library. System local time: {now.strftime('%Y-%m-%d %H:%M:%S')}")
        print(f"   Configured timezone: {config.get('timezone', 'unknown')} (UTC{offset_hours:+.0f}h)")
        sys.exit(0)

    # Format output
    time_str = now.strftime("%I:%M %p").lstrip("0")  # "2:30 PM" not "02:30 PM"
    date_str = now.strftime("%A, %B %-d, %Y")
    tz_abbr = now.strftime("%Z")  # e.g. "EDT", "EST", "PST"
    offset_str = now.strftime("%z")  # e.g. "+0500"

    # Format offset nicely: "+0500" -> "UTC+5"
    if offset_str:
        sign = offset_str[0]
        hours = int(offset_str[1:3])
        mins = int(offset_str[3:5])
        if mins:
            offset_display = f"UTC{sign}{hours}:{mins:02d}"
        else:
            offset_display = f"UTC{sign}{hours}" if hours else "UTC"
    else:
        offset_display = ""

    print(f"🕐 {time_str} {tz_abbr}  ({date_str})")
    print(f"   Timezone: {display_name} [{tz_name}] {offset_display}")


if __name__ == "__main__":
    main()


path: scripts/timezone_setup.py
content: #!/usr/bin/env python3
"""
timezone_setup.py — Configure the user's local timezone for OpenClaw timezone skill.

Usage:
  python3 timezone_setup.py              # Interactive detection + prompt
  python3 timezone_setup.py --set "America/New_York"
  python3 timezone_setup.py --detect     # Auto-detect only, no prompt
  python3 timezone_setup.py --patch-agents  # Patch AGENTS.md with standing order
"""

import argparse
import json
import os
import re
import subprocess
import sys

CONFIG_PATH = os.path.join(os.path.dirname(__file__), "..", "tz_config.json")
AGENTS_MD_PATH = os.path.join(os.path.dirname(__file__), "..", "..", "..", "AGENTS.md")
USER_MD_PATH = os.path.join(os.path.dirname(__file__), "..", "..", "..", "USER.md")

# Common timezone display names
TZ_DISPLAY = {
    "America/New_York": "US Eastern",
    "America/Chicago": "US Central",
    "America/Denver": "US Mountain",
    "America/Los_Angeles": "US Pacific",
    "America/Anchorage": "US Alaska",
    "Pacific/Honolulu": "US Hawaii",
    "America/Phoenix": "US Arizona (no DST)",
    "Europe/London": "UK (London)",
    "Europe/Paris": "Central European",
    "Europe/Berlin": "Central European",
    "Europe/Moscow": "Moscow",
    "Asia/Tokyo": "Japan",
    "Asia/Shanghai": "China",
    "Asia/Kolkata": "India",
    "Asia/Dubai": "Gulf (UAE)",
    "Australia/Sydney": "Australia Eastern",
    "Pacific/Auckland": "New Zealand",
    "UTC": "UTC (no conversion)",
}

# ET shorthand mapping
TZ_ALIASES = {
    "ET": "America/New_York",
    "EST": "America/New_York",
    "EDT": "America/New_York",
    "CT": "America/Chicago",
    "CST": "America/Chicago",
    "CDT": "America/Chicago",
    "MT": "America/Denver",
    "MST": "America/Denver",
    "MDT": "America/Denver",
    "PT": "America/Los_Angeles",
    "PST": "America/Los_Angeles",
    "PDT": "America/Los_Angeles",
    "GMT": "UTC",
    "UTC": "UTC",
}


def detect_from_user_md():
    """Read timezone from USER.md Timezone: field."""
    if not os.path.exists(USER_MD_PATH):
        return None
    with open(USER_MD_PATH) as f:
        content = f.read()
    # Match "Timezone: US Eastern (ET / UTC-5, UTC-4 DST)" or "Timezone: America/New_York"
    match = re.search(r"Timezone:\s*(.+)", content, re.IGNORECASE)
    if not match:
        return None
    raw = match.group(1).strip()
    # Try to find a known IANA tz or alias in the string
    # Check aliases first
    for alias, tz in TZ_ALIASES.items():
        if alias in raw.upper():
            return tz
    # Check IANA names
    for iana in TZ_DISPLAY.keys():
        if iana.lower() in raw.lower():
            return iana
    return None


def detect_from_system():
    """Try timedatectl or /etc/localtime to get system timezone."""
    # Try timedatectl
    try:
        result = subprocess.run(
            ["timedatectl", "show", "--property=Timezone", "--value"],
            capture_output=True, text=True, timeout=3
        )
        if result.returncode == 0 and result.stdout.strip():
            return result.stdout.strip()
    except (FileNotFoundError, subprocess.TimeoutExpired):
        pass

    # Try /etc/localtime symlink
    try:
        link = os.readlink("/etc/localtime")
        # Usually something like /usr/share/zoneinfo/America/New_York
        match = re.search(r"zoneinfo/(.+)$", link)
        if match:
            return match.group(1)
    except (OSError, AttributeError):
        pass

    # Try /etc/timezone
    try:
        if os.path.exists("/etc/timezone"):
            with open("/etc/timezone") as f:
                tz = f.read().strip()
            if tz:
                return tz
    except OSError:
        pass

    return None


def get_utc_offset(tz_name):
    """Return UTC offset hours for a timezone (approximate, uses current DST status)."""
    try:
        from zoneinfo import ZoneInfo
    except ImportError:
        try:
            from backports.zoneinfo import ZoneInfo
        except ImportError:
            try:
                import pytz
                from datetime import datetime
                tz = pytz.timezone(tz_name)
                now = datetime.now(tz)
                return now.utcoffset().total_seconds() / 3600
            except Exception:
                return 0

    from datetime import datetime
    try:
        tz = ZoneInfo(tz_name)
        now = datetime.now(tz)
        offset = now.utcoffset()
        if offset:
            return offset.total_seconds() / 3600
    except Exception:
        pass
    return 0


def save_config(tz_name, display_name=None):
    offset = get_utc_offset(tz_name)
    config = {
        "timezone": tz_name,
        "display_name": display_name or TZ_DISPLAY.get(tz_name, tz_name),
        "utc_offset_hours": offset,
        "configured_by": "timezone_setup.py",
        "note": "Edit timezone field to change. Run now.py to verify."
    }
    os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
    with open(CONFIG_PATH, "w") as f:
        json.dump(config, f, indent=2)
    print(f"✅ Timezone saved: {config['display_name']} [{tz_name}] (UTC{offset:+.1f}h)")
    return config


def patch_agents_md(tz_name, display_name):
    """Append timezone standing order to AGENTS.md if not already there."""
    if not os.path.exists(AGENTS_MD_PATH):
        print(f"⚠️  AGENTS.md not found at {AGENTS_MD_PATH} — skipping patch")
        return

    with open(AGENTS_MD_PATH) as f:
        content = f.read()

    marker = "## Timezone Standing Order"
    if marker in content:
        # Already patched — update it
        # Replace block up to next ## header
        new_block = f"""{marker}
- User timezone: **{display_name}** [{tz_name}]
- NEVER report UTC time as local time
- All time output must be in user's local timezone  
- When in doubt: run `python3 skills/timezone/scripts/now.py` before reporting any time
- API/cron/system timestamps are UTC — always convert before displaying
"""
        # Replace existing block
        updated = re.sub(
            r"## Timezone Standing Order.*?(?=\n## |\Z)",
            new_block,
            content,
            flags=re.DOTALL
        )
        with open(AGENTS_MD_PATH, "w") as f:
            f.write(updated)
        print(f"✅ AGENTS.md timezone standing order updated.")
    else:
        # Append before end
        standing_order = f"""
---

{marker}
- User timezone: **{display_name}** [{tz_name}]
- NEVER report UTC time as local time
- All time output must be in user's local timezone  
- When in doubt: run `python3 skills/timezone/scripts/now.py` before reporting any time
- API/cron/system timestamps are UTC — always convert before displaying
"""
        with open(AGENTS_MD_PATH, "a") as f:
            f.write(standing_order)
        print(f"✅ AGENTS.md patched with timezone standing order.")


def resolve_tz(raw):
    """Resolve raw input to IANA timezone name."""
    raw = raw.strip()
    # Check aliases
    upper = raw.upper()
    if upper in TZ_ALIASES:
        return TZ_ALIASES[upper]
    # Check IANA directly (basic validation)
    if "/" in raw or raw == "UTC":
        return raw
    # Check display names
    for iana, display in TZ_DISPLAY.items():
        if raw.lower() in display.lower():
            return iana
    return raw  # Return as-is and let zoneinfo validate


def main():
    parser = argparse.ArgumentParser(description="Configure OpenClaw timezone setting")
    parser.add_argument("--set", metavar="TIMEZONE", help="Set timezone directly (IANA name or alias like ET)")
    parser.add_argument("--detect", action="store_true", help="Auto-detect timezone without prompting")
    parser.add_argument("--patch-agents", action="store_true", help="Patch AGENTS.md with standing order")
    args = parser.parse_args()

    # Handle --set
    if args.set:
        tz = resolve_tz(args.set)
        config = save_config(tz)
        if args.patch_agents or True:  # always patch
            patch_agents_md(tz, config["display_name"])
        return

    # Auto-detect
    print("🔍 Detecting timezone...")

    tz_detected = None
    source = None

    # 1. Check USER.md
    tz_from_user = detect_from_user_md()
    if tz_from_user:
        print(f"   Found in USER.md: {tz_from_user}")
        tz_detected = tz_from_user
        source = "USER.md"

    # 2. Check system
    if not tz_detected:
        tz_from_system = detect_from_system()
        if tz_from_system:
            print(f"   Found via system: {tz_from_system}")
            tz_detected = tz_from_system
            source = "system"

    if args.detect:
        if tz_detected:
            config = save_config(tz_detected)
            patch_agents_md(tz_detected, config["display_name"])
        else:
            print("❌ Could not auto-detect timezone. Run without --detect to set manually.")
            sys.exit(1)
        return

    # Interactive mode
    if tz_detected:
        display = TZ_DISPLAY.get(tz_detected, tz_detected)
        print(f"\n✨ Detected: {display} [{tz_detected}] (from {source})")
        answer = input("Use this timezone? [Y/n]: ").strip().lower()
        if answer in ("", "y", "yes"):
            config = save_config(tz_detected)
            patch_agents_md(tz_detected, config["display_name"])
            return

    # Manual entry
    print("\nCommon timezones:")
    for iana, display in list(TZ_DISPLAY.items())[:8]:
        print(f"  {display:25s}  [{iana}]")
    print("  (or enter any IANA name, e.g. Europe/Paris)")
    print("  (or short aliases: ET, PT, CT, MT, GMT, UTC)")
    raw = input("\nEnter your timezone: ").strip()
    if not raw:
        print("❌ No timezone entered. Exiting.")
        sys.exit(1)
    tz = resolve_tz(raw)
    config = save_config(tz)
    patch_agents_md(tz, config["display_name"])


if __name__ == "__main__":
    main()


path: tz_config.json
content: timezone: America/New_York
display_name: US Eastern
utc_offset_hours: -4
configured_by: timezone_setup.py
note: Edit timezone field to change. Run now.py to verify.

Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)