Dual-Mode Automations: Timer + Skill Pairs

By Jason MacDonald • February 25, 2026 • Forge Architecture

Summary

Every scheduled automation in Forge becomes a triple: a script (the logic), a systemd timer (the schedule), and a skill (the on-demand wrapper).

This gives us the coverage of a cron-heavy system like OpenClaw while preserving Forge's composable, skill-first architecture. Anything that runs on a schedule can also be triggered manually via /command in Claude Code or !command in Telegram.

We standardize the script interface (env loading, logging, Telegram output, exit codes) so Ralph can build new timer+skill pairs in 10-15 minutes each using templates.

19
Total Timers
7
Already Exist
12
New to Build
~$0.50
Monthly Cost

Architecture

                          DUAL-MODE AUTOMATION PATTERN
    +-------------------------------------------------------------+
    |                                                             |
    |   SCHEDULED (automatic)          ON-DEMAND (manual)         |
    |                                                             |
    |   +------------------+          +------------------+        |
    |   |  systemd timer   |          |  /skill command  |        |
    |   |  (forge-X.timer) |          |  (Claude Code)   |        |
    |   +--------+---------+          +--------+---------+        |
    |            |                              |                  |
    |   +--------v---------+          +--------v---------+        |
    |   | systemd service  |          |  skill wrapper   |        |
    |   | (forge-X.service)|          | (SKILL.md reads  |        |
    |   |  ExecStart=...   |          |  same script)    |        |
    |   +--------+---------+          +--------+---------+        |
    |            |                              |                  |
    |            +----------+------------------+                  |
    |                       |                                      |
    |              +--------v---------+                           |
    |              |  SCRIPT          |                           |
    |              |  /opt/forge/     |                           |
    |              |  scripts/X.sh    |                           |
    |              |                  |                           |
    |              |  +------------+  |  Standard interface:      |
    |              |  | forge-std  |  |  - source forge-std.sh    |
    |              |  | library    |  |  - env loading            |
    |              |  +------------+  |  - logging                |
    |              |                  |  - Telegram output        |
    |              |  +------------+  |  - exit codes             |
    |              |  | lockfile   |  |  - --dry-run / --now      |
    |              |  +------------+  |  - timeout wrapper        |
    |              |                  |  - DND hours check        |
    |              |  +------------+  |                           |
    |              |  | notify.sh  |  |                           |
    |              |  +------------+  |                           |
    |              +------------------+                           |
    |                       |                                      |
    |              +--------v---------+                           |
    |              |  OUTPUT          |                           |
    |              |  - Telegram msg  |                           |
    |              |  - Log file      |                           |
    |              |  - stdout (skill)|                           |
    |              |  - Exit code     |                           |
    |              +------------------+                           |
    +-------------------------------------------------------------+

FILE LAYOUT PER AUTOMATION:

    scripts/<name>.sh                        # The logic
    services/forge-<name>/
        forge-<name>.service                 # systemd service unit
        forge-<name>.timer                   # systemd timer unit
    skills/<name>/SKILL.md                   # Skill wrapper
    .claude/skills/<name> -> ../../skills/<name>/  # Symlink
    config/<name>.yaml                       # Config (if needed)
    logs/<name>/                             # Log directory

Components

1. forge-std.sh — Standard Script Library

2. Timer+Service Template

3. Skill Wrapper Template

4. new-timer-skill.sh — Creation Helper

5. DND Hours Gate

Coverage Map: OpenClaw → Forge

This table maps OpenClaw's 11 prompt categories (~30 schedulable sub-tasks) to existing or planned Forge timer+skill pairs.

Legend

EXISTS Already built and running   PARTIAL Some coverage, needs completion   PLANNED Identified, ready to build   NEW Designed here for coverage parity

OpenClaw Prompt Categories

# OpenClaw Category Sub-Tasks Forge Equivalent Status Timer Skill
1 Personal CRM Intelligence Daily email/calendar scan, contact scoring, learning.json updates Comms service (port 5013) + followup-checker.sh PARTIAL
1a   Contact ingestion from email Daily scan of email for new contacts, two-stage filter comms service activation PLANNED forge-contact-ingest.timer /contact-ingest
1b   Follow-up reminders Scan Task Board for overdue follow-ups, notify followup-checker.sh EXISTS (no timer) forge-followup.timer /followup
1c   Contact scoring refresh Re-score contacts based on recent interactions NEW forge-contact-score.timer /contact-score
2 Knowledge Base (RAG) Content ingestion, embedding, quality validation Knowledge service (port 5012) EXISTS
2a   Knowledge ingestion run Batch process queued URLs/content for embedding knowledge service EXISTS (on-demand) /ingest
2b   Stale content check Flag knowledge entries older than N days NEW forge-knowledge-stale.timer /knowledge-stale
3 Content Idea Pipeline Idea capture, dedup, scoring, publishing Content Pipeline (port 5014) + GSD Pipeline (port 5010) EXISTS
3a   Content queue drain Process pending content items through publish pipeline content-pipeline service PARTIAL forge-content-drain.timer /content-drain
4 Social Media Research Twitter/X monitoring, topic tracking Intelligence Router (market-intel.sh) EXISTS forge-intel-market.timer /market-intel
5 YouTube Analytics Channel monitoring, competitor tracking, new video alerts Intelligence Router (youtube-intel.sh) EXISTS forge-intel-youtube.timer /youtube-intel
5a   YouTube digest Aggregate youtube-intel results into readable summary intel digest (forge-intel-digest) EXISTS forge-intel-digest.timer /intel-digest
6 Nightly Business Briefing Multi-source intelligence synthesis Daily Brief (brief.sh) EXISTS forge-daily-brief.timer /daily-brief
6a   Evening digest End-of-day summary of what happened today PLANNED forge-evening-digest.timer /evening-digest
7 CRM/Tool Natural Language Access Chat interface for business tools Telegram bot + skill system PARTIAL (bot commands)
7a   Natural language task queuing "Queue a task for Ralph" via natural text PLANNED /queue-task
8 Content Humanization Rewrite AI text to sound natural Content Pipeline PARTIAL /humanize
9 Image Generation Create/edit images via API NEW /image-gen
10 Task Extraction from Meetings Parse transcripts, create action items PARTIAL /extract-tasks
10a   Meeting follow-up check Scan recent meeting notes for un-actioned items NEW forge-meeting-followup.timer /meeting-followup
11 AI Cost Tracking Log and report LLM API spend Budget log in Supabase + model-router.sh EXISTS /cost-report
11a   Weekly cost report Aggregate weekly spend, flag anomalies PLANNED forge-cost-report.timer /cost-report

Forge-Native Automations (No OpenClaw Equivalent)

# Forge Automation Status Timer Skill
F1 Guardian — auto-commit with actor detection EXISTS forge-guardian.timer (30s) /guardian
F2 Heartbeat — smart system health check EXISTS forge-heartbeat.timer (30min) /heartbeat
F3 Ralph Poller — queue processing EXISTS forge-ralph-poller.timer (60s) (internal)
F4 Session Tracker — Claude terminal capture EXISTS forge-session-tracker.timer (5min) /session-status
F5 Weekly Assessment — State of Forge report EXISTS forge-weekly-assessment.timer (Sun 10:00) /weekly-assessment
F6 Web Intel — web intelligence gathering EXISTS forge-intel-web.timer (12:00) /web-intel
F7 Start/Sync/Wrap-up — daily rituals EXISTS (manual) /start, /sync, /wrap-up
F8 DND Hours — notification suppression PLANNED (library, not timer)
F9 Dep Checker — dependency vulnerability scan PLANNED forge-dep-check.timer /dep-check
F10 Cert Monitor — SSL certificate expiry check PLANNED forge-cert-monitor.timer /cert-monitor
F11 GitHub PR Monitor — open PR age/status alerts PLANNED forge-pr-monitor.timer /pr-monitor
F12 Agent Handoff Memory — persist context between sessions PLANNED (library, not timer)
F13 Backup — full system backup EXISTS (script) forge-backup.timer /backup
F14 VPS Audit — disk/memory/uptime health EXISTS (script) (in heartbeat) /vps-audit

Full Timer Inventory (Target State)

After build-out, Forge will have 19 systemd timers (7 existing + 12 new):

Timer Schedule Script Category
forge-guardian.timer Every 30s forge-guardian.sh Ops
forge-ralph-poller.timer Every 60s ralph-poller.sh Ops
forge-session-tracker.timer Every 5min forge-session-tracker.sh Ops
forge-heartbeat.timer Every 30min heartbeat/check.sh Ops
forge-followup.timer Daily 15:00 UTC followup-checker.sh CRM
forge-daily-brief.timer Daily 11:00 UTC brief.sh Intel
forge-intel-market.timer Daily 11:00 UTC market-intel.sh Intel
forge-intel-web.timer Daily 12:00 UTC web-intel.sh Intel
forge-intel-youtube.timer Every 4h youtube-intel.sh Intel
forge-intel-digest.timer Daily 12:30 UTC intel-digest.sh Intel
forge-evening-digest.timer Daily 23:00 UTC evening-digest.sh Intel
forge-dep-check.timer Weekly Mon 08:00 dep-check.sh Security
forge-cert-monitor.timer Daily 06:00 UTC cert-monitor.sh Security
forge-pr-monitor.timer Every 4h pr-monitor.sh Dev
forge-cost-report.timer Weekly Sun 09:00 cost-report.sh Finance
forge-backup.timer Daily 04:00 UTC backup.sh Ops
forge-weekly-assessment.timer Weekly Sun 10:00 weekly-assessment.sh Ops
forge-contact-ingest.timer Daily 10:00 UTC contact-ingest.sh CRM
forge-content-drain.timer Daily 14:00 UTC content-drain.sh Content

Build Priority

Ordered by domino value (what unblocks the most downstream work) and effort.

TIER 1 Foundation (Build First)

Everything depends on these. Build once, use forever.

#ItemEffortWhy First
1 forge-std.sh library 15 min Every subsequent script sources this. Build once, use forever.
2 new-timer-skill.sh creation helper 15 min Automates the triple creation. Makes all remaining items 5-min tasks.
3 dnd.yaml config + check_dnd function 10 min Already built into forge-std.sh above. Just needs the config file.

TIER 2 Wrap Existing Scripts

No new logic — just add timer+skill pair to scripts that already exist.

#ItemEffortNotes
4followup-checker → timer+skill5 minScript exists. Add timer (daily 15:00 UTC) + skill wrapper.
5daily-brief → skill5 minTimer exists. Add skill wrapper for on-demand /daily-brief --now.
6weekly-assessment → skill5 minTimer exists. Add skill wrapper for on-demand /weekly-assessment --now.
7heartbeat → skill5 minTimer exists. Add skill wrapper for on-demand /heartbeat --now.
8youtube-intel → skill5 minTimer exists. Add skill wrapper.
9market-intel → skill5 minTimer exists. Add skill wrapper.
10backup → timer+skill5 minScript exists. Add timer (daily 04:00) + skill.
11vps-audit → skill5 minScript exists. Add skill wrapper.

TIER 3 New Automations

New logic required. Each is a self-contained Ralph task.

#ItemEffortDescription
12evening-digest.sh20 minAggregate day's activities into summary. Similar to daily-brief but backward-looking.
13dep-check.sh15 minnpm audit + pip-audit on key services, parse JSON, alert on high/critical.
14cert-monitor.sh10 minopenssl s_client against asapai.net domains, alert if <14 days to expiry.
15pr-monitor.sh10 mingh pr list, alert on PRs older than 3 days or with failing checks.
16cost-report.sh15 minQuery budget_log from Supabase, aggregate weekly, flag >25% spend on any model.
17contact-ingest.sh25 minBlocked on Comms service activation (Google OAuth steps 7-8).
18content-drain.sh15 minBlocked on Content Pipeline systemd activation.

TIER 4 Skills Only (No Timer Needed)

#ItemEffortNotes
19/queue-task skill15 minNatural language → ralph_queue insert. Needs LLM to parse intent.
20/cost-report skill10 minOn-demand version of cost-report.sh.
21/extract-tasks skill20 minParse meeting transcript, output task list for Task Board.
22/humanize skill10 minPipe text through LLM with de-AI prompt.

Templates

Script Template

Every automation script follows this exact pattern. Copy-paste and fill in the marked sections.

#!/usr/bin/env bash
# <name>.sh — <one-line description>
# Schedule: <cron expression or interval description>
# Dual-mode: timer (forge-<name>.timer) + skill (/<name>)
#
# Usage:
#   bash /opt/forge/scripts/<name>.sh            # normal run
#   bash /opt/forge/scripts/<name>.sh --dry-run   # log without sending
#   bash /opt/forge/scripts/<name>.sh --now        # skip schedule/DND checks

set -euo pipefail

# ============================================================
# STANDARD PREAMBLE
# ============================================================
FORGE_ROOT="/opt/forge"
SCRIPT_NAME="$(basename "$0" .sh)"
source "${FORGE_ROOT}/scripts/lib/forge-std.sh"

forge_init "$SCRIPT_NAME" "$@"

# ============================================================
# CONFIG (optional YAML config)
# ============================================================
# CONFIG_FILE="${FORGE_ROOT}/config/<name>.yaml"
# MY_SETTING=$(yq '.some.setting // "default"' "$CONFIG_FILE")

# ============================================================
# MAIN LOGIC — replace this section
# ============================================================

log "Starting ${SCRIPT_NAME}..."

# ... your automation logic here ...

RESULT="Your output message here"

# ============================================================
# OUTPUT — Telegram + log + stdout
# ============================================================
if [ "$DRY_RUN" = true ]; then
  log "DRY RUN — would send:"
  echo "$RESULT"
else
  notify "$RESULT"
  log "Sent notification (${#RESULT} chars)"
fi

log "Done"

forge-std.sh Library

Standard preamble for all Forge automation scripts. Provides env loading, logging, DND check, timeout, and Telegram output.

#!/usr/bin/env bash
# forge-std.sh — Standard preamble for all Forge automation scripts
# Source this at the top of every timer+skill script.
#
# Usage: source /opt/forge/scripts/lib/forge-std.sh
#        forge_init "script-name" "$@"

# --- Standard Variables ---
DATE=$(date +%Y-%m-%d)
TIMESTAMP=$(date -Iseconds)
NOW_HOUR=$(date +%H)
NOW_MIN=$(date +%M)

# --- Flags (set by forge_init) ---
DRY_RUN=false
SKIP_CHECKS=false
QUIET=false

# --- Paths ---
LOG_DIR=""
LOG_FILE=""

# --- Source dependencies ---
source "${FORGE_ROOT}/scripts/lib/lockfile.sh"

forge_init() {
  local name="${1:?Usage: forge_init <name> [args...]}"
  shift

  SCRIPT_NAME="$name"
  LOG_DIR="${FORGE_ROOT}/logs/${name}"
  LOG_FILE="${LOG_DIR}/${DATE}.log"
  mkdir -p "$LOG_DIR"

  # Parse flags
  for arg in "$@"; do
    case "$arg" in
      --dry-run)  DRY_RUN=true ;;
      --now)      SKIP_CHECKS=true ;;
      --quiet)    QUIET=true ;;
    esac
  done

  # Overall timeout (5 min default, override with SCRIPT_TIMEOUT)
  local timeout="${SCRIPT_TIMEOUT:-300}"
  if [ "${_FORGE_TIMEOUT_ACTIVE:-}" != "1" ]; then
    export _FORGE_TIMEOUT_ACTIVE=1
    exec timeout --kill-after=15 "$timeout" "$0" "$@"
  fi

  # Cleanup trap
  trap '_forge_cleanup' EXIT
}

log() {
  local msg="[${TIMESTAMP}] [${SCRIPT_NAME}] $1"
  echo "$msg" >> "$LOG_FILE" 2>/dev/null || true
  if [ "$QUIET" != true ]; then
    echo "$msg" >&2
  fi
}

load_env() {
  local env_file="${FORGE_ROOT}/.env.system"
  if [ ! -f "$env_file" ]; then
    log "WARNING: .env.system not found"
    return 1
  fi
  TELEGRAM_BOT_TOKEN=$(grep '^TELEGRAM_BOT_TOKEN=' "$env_file" | cut -d= -f2- | tr -d '[:space:]')
  TELEGRAM_USER_ID=$(grep '^TELEGRAM_USER_ID=' "$env_file" | cut -d= -f2- | tr -d '[:space:]')
}

check_dnd() {
  # Returns 0 if NOT in DND (safe to notify), 1 if in DND (suppress)
  local dnd_config="${FORGE_ROOT}/config/dnd.yaml"
  if [ ! -f "$dnd_config" ]; then return 0; fi

  local dnd_enabled dnd_start dnd_end
  dnd_enabled=$(yq '.dnd.enabled // false' "$dnd_config" 2>/dev/null || echo "false")
  [ "$dnd_enabled" != "true" ] && return 0

  dnd_start=$(yq '.dnd.start_hour // 22' "$dnd_config" 2>/dev/null || echo "22")
  dnd_end=$(yq '.dnd.end_hour // 7' "$dnd_config" 2>/dev/null || echo "7")

  local hour
  hour=$(date +%-H)

  if [ "$dnd_start" -gt "$dnd_end" ]; then
    if [ "$hour" -ge "$dnd_start" ] || [ "$hour" -lt "$dnd_end" ]; then
      return 1
    fi
  else
    if [ "$hour" -ge "$dnd_start" ] && [ "$hour" -lt "$dnd_end" ]; then
      return 1
    fi
  fi
  return 0
}

notify() {
  local message="$1"
  if [ "$DRY_RUN" = true ]; then
    log "DRY RUN — suppressed notification"
    return 0
  fi

  if [ "$SKIP_CHECKS" != true ] && ! check_dnd; then
    log "DND active — queuing notification"
    echo "$message" >> "${FORGE_ROOT}/logs/dnd-queue.txt"
    return 0
  fi

  [ -z "${TELEGRAM_BOT_TOKEN:-}" ] && load_env

  if [ -z "${TELEGRAM_BOT_TOKEN:-}" ] || [ -z "${TELEGRAM_USER_ID:-}" ]; then
    log "WARNING: Telegram not configured"
    return 1
  fi

  curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
    -d "chat_id=${TELEGRAM_USER_ID}" \
    --data-urlencode "text=${message}" \
    -d "parse_mode=Markdown" \
    >/dev/null 2>&1 || {
      log "WARNING: Telegram send failed"
      return 1
    }
}

_forge_cleanup() {
  local exit_code=$?
  if [ "$exit_code" -eq 124 ]; then
    log "ERROR: Script timed out"
    [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && \
      curl -sf -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
        -d "chat_id=${TELEGRAM_USER_ID}" \
        -d "text=${SCRIPT_NAME} timed out. Check logs." \
        >/dev/null 2>&1 || true
  fi
  if [ -n "${_forge_lock_name:-}" ]; then
    release_lock
  fi
}

Timer Template

# File: services/forge-<NAME>/forge-<NAME>.timer
[Unit]
Description=Forge <DESCRIPTION> timer — <SCHEDULE_HUMAN>

[Timer]
# For interval-based (every N seconds/minutes):
# OnBootSec=60
# OnUnitActiveSec=<INTERVAL>

# For calendar-based (specific times):
# OnCalendar=*-*-* HH:MM:SS
# OnCalendar=Mon *-*-* 09:00:00

Persistent=true

[Install]
WantedBy=timers.target

Service Template

# File: services/forge-<NAME>/forge-<NAME>.service
[Unit]
Description=Forge <DESCRIPTION>
After=network.target

[Service]
Type=oneshot
User=forge
Group=forge
WorkingDirectory=/opt/forge
ExecStart=/opt/forge/scripts/<NAME>.sh
KillMode=process
EnvironmentFile=/opt/forge/.env.system
StandardOutput=journal
StandardError=journal

Skill Wrapper Template

---
name: <NAME>
description: <DESCRIPTION>. Runs <NAME>.sh on demand.
  Use when user says "/<NAME>", "run <NAME>", or "check <NAME>".
---

# /<NAME> — <Title>

You are running the /<NAME> skill. This wraps the
scheduled automation for on-demand use.

## What It Does

<1-2 sentences: what this automation checks/does/produces>

## How to Run

Run the script and present its output:

```bash
bash /opt/forge/scripts/<NAME>.sh --now 2>&1
```

### Flags

| Flag       | Effect                                               |
|------------|------------------------------------------------------|
| (none)     | Normal run, respects DND, sends Telegram             |
| --dry-run  | Runs logic but suppresses Telegram                   |
| --now      | Skips DND check and schedule validation              |
| --quiet    | Suppresses stderr logging                            |

## After Running

1. Present the script output to the user
2. If errors occurred, suggest fixes
3. If --dry-run, ask if user wants to run for real

## RULES
- Always use --now flag when running on-demand
- Default to --dry-run if user just wants to see results
- Show full output — don't summarize unless user asks

Cost Estimate

ComponentProviderMonthly Cost
forge-std.sh libraryLocal$0
new-timer-skill.shLocal$0
DND configLocal$0
12 new timer+service pairssystemd (local)$0
12 new skill wrappersLocal files$0
evening-digest LLM callsOllama/LiteLLM~$0.50/mo
cost-report Supabase queriesSupabase free tier$0
cert-monitor openssl callsLocal$0
dep-check npm/pip auditLocal$0
pr-monitor gh CLIGitHub free tier$0
Total ~$0.50/mo

Risks & Mitigations

RiskImpactMitigation
Timer pile-up — too many timers firing simultaneously CPU/memory spike, overlapping runs Stagger schedules (no two timers at same minute). Lockfile prevents concurrent runs of same script.
DND queue grows too large Message flood when DND ends Cap queue at 20 messages. Oldest messages dropped with "[X older messages suppressed]" summary.
Notification fatigue — too many Telegram messages Important alerts get ignored Group related notifications. Evening digest replaces individual alerts. DND prevents overnight noise.
Scripts diverge from template Inconsistency, harder to maintain new-timer-skill.sh enforces template. Code review catches drift.
Heartbeat already does some of these checks Duplication of effort Clear separation: heartbeat = health, timers = intelligence. Heartbeat stays fast and frequent.
Existing scripts don't use forge-std.sh Two patterns in codebase Phase 2 migration: gradually refactor existing scripts. Not blocking — old scripts work fine.

Open Questions

  1. Migrate existing scripts? Should we refactor brief.sh, heartbeat/check.sh, etc. to use forge-std.sh, or leave as-is? Recommendation: leave existing scripts alone. Only new scripts use the standard.
  2. DND timezone: Config uses UTC hours. Jason is in CST (UTC-6). Keep UTC or switch to local? Recommendation: keep UTC for consistency. Document CST equivalents in comments.
  3. Skill naming: Use timer name (/forge-followup) or short name (/followup)? Recommendation: short names. Skills are for humans; systemd names are for systemd.
  4. Evening digest scope: What should it include? Recommendation: start with Task Board changes + Ralph activity summary. Expand later.
  5. Contact ingest dependency: Blocked on Google OAuth steps 7-8. Recommendation: build skeleton now with --dry-run default. Unblock activates it.

Service Convention Compliance

These are NOT new services. They are scripts + timers + skills. Service convention (health, docs, endpoints) does NOT apply to timers. Timers are registered in services.json with type: "systemd-timer" and port: null.