By Jason MacDonald • February 25, 2026 • Forge Architecture
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.
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
source call./opt/forge/scripts/lib/forge-std.shsudo systemctl enable --now./opt/forge/Templates/timer-service-template/scripts/new-timer-skill.shbash scripts/new-timer-skill.sh <name> "<description>" "<schedule>"/opt/forge/config/dnd.yamlThis table maps OpenClaw's 11 prompt categories (~30 schedulable sub-tasks) to existing or planned Forge timer+skill pairs.
EXISTS Already built and running PARTIAL Some coverage, needs completion PLANNED Identified, ready to build NEW Designed here for coverage parity
| # | 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 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 |
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 |
Ordered by domino value (what unblocks the most downstream work) and effort.
Everything depends on these. Build once, use forever.
| # | Item | Effort | Why 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. |
No new logic — just add timer+skill pair to scripts that already exist.
| # | Item | Effort | Notes |
|---|---|---|---|
| 4 | followup-checker → timer+skill | 5 min | Script exists. Add timer (daily 15:00 UTC) + skill wrapper. |
| 5 | daily-brief → skill | 5 min | Timer exists. Add skill wrapper for on-demand /daily-brief --now. |
| 6 | weekly-assessment → skill | 5 min | Timer exists. Add skill wrapper for on-demand /weekly-assessment --now. |
| 7 | heartbeat → skill | 5 min | Timer exists. Add skill wrapper for on-demand /heartbeat --now. |
| 8 | youtube-intel → skill | 5 min | Timer exists. Add skill wrapper. |
| 9 | market-intel → skill | 5 min | Timer exists. Add skill wrapper. |
| 10 | backup → timer+skill | 5 min | Script exists. Add timer (daily 04:00) + skill. |
| 11 | vps-audit → skill | 5 min | Script exists. Add skill wrapper. |
New logic required. Each is a self-contained Ralph task.
| # | Item | Effort | Description |
|---|---|---|---|
| 12 | evening-digest.sh | 20 min | Aggregate day's activities into summary. Similar to daily-brief but backward-looking. |
| 13 | dep-check.sh | 15 min | npm audit + pip-audit on key services, parse JSON, alert on high/critical. |
| 14 | cert-monitor.sh | 10 min | openssl s_client against asapai.net domains, alert if <14 days to expiry. |
| 15 | pr-monitor.sh | 10 min | gh pr list, alert on PRs older than 3 days or with failing checks. |
| 16 | cost-report.sh | 15 min | Query budget_log from Supabase, aggregate weekly, flag >25% spend on any model. |
| 17 | contact-ingest.sh | 25 min | Blocked on Comms service activation (Google OAuth steps 7-8). |
| 18 | content-drain.sh | 15 min | Blocked on Content Pipeline systemd activation. |
| # | Item | Effort | Notes |
|---|---|---|---|
| 19 | /queue-task skill | 15 min | Natural language → ralph_queue insert. Needs LLM to parse intent. |
| 20 | /cost-report skill | 10 min | On-demand version of cost-report.sh. |
| 21 | /extract-tasks skill | 20 min | Parse meeting transcript, output task list for Task Board. |
| 22 | /humanize skill | 10 min | Pipe text through LLM with de-AI prompt. |
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"
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
}
# 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
# 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
---
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
| Component | Provider | Monthly Cost |
|---|---|---|
| forge-std.sh library | Local | $0 |
| new-timer-skill.sh | Local | $0 |
| DND config | Local | $0 |
| 12 new timer+service pairs | systemd (local) | $0 |
| 12 new skill wrappers | Local files | $0 |
| evening-digest LLM calls | Ollama/LiteLLM | ~$0.50/mo |
| cost-report Supabase queries | Supabase free tier | $0 |
| cert-monitor openssl calls | Local | $0 |
| dep-check npm/pip audit | Local | $0 |
| pr-monitor gh CLI | GitHub free tier | $0 |
| Total | ~$0.50/mo |
| Risk | Impact | Mitigation |
|---|---|---|
| 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. |
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./forge-followup) or short name (/followup)? Recommendation: short names. Skills are for humans; systemd names are for systemd.--dry-run default. Unblock activates it.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.
config/services.json (each timer gets an entry)systemctl is-active (systemd native)logs/<name>/ (standard location)