Beyond Manual Configuration: Engineering AI Agent Cluster Setup
I run a local cluster of 5 AI agents—a main orchestrator, a blog writer, a development assistant, a smart home hub, and a communications assistant—all managed by the OpenClaw platform. All configurations lived in the runtime directory ~/.openclaw/, manually edited. The "source configuration" in the repository was merely a remnant from my Docker days. Today, when I wanted to batch-switch models, I hit the wall: the manual approach was unmanageable.
How Bad Was the Problem
Switching models required changes in 8 places across 2 JSON files:
agents.defaults.model.primary— global default- Each of the 5 agents'
model.primary - The
hephaestusagent'sheartbeat.model - The hardcoded
payload.modelwithin thedaily-articlescron job
Missing even one creates inconsistencies. I missed the cron job entry, and the daily writing task kept running on the old model.
The deeper issue was configuration drift. The config/openclaw.json in the repository still listed three non-existent agents (Analyzer, Scanner, Writer) and outdated model names like google/gemini-3-pro. The repository had lost its role as source of truth.
The Approach: Declarative Templates + Automated Deployment
The goal: the Git repository becomes the sole source of truth, and a single command safely synchronizes configurations to runtime.
The refactored project structure:
Hephaestus/
├── config/
│ ├── openclaw.json # Config template (secrets use placeholders)
│ ├── cron-jobs.json # Cron job declarations (no runtime state)
│ ├── SOUL.md # Agent persona definitions
│ └── HEARTBEAT.md # Daily workflow
├── secrets/
│ └── .env # All secrets (gitignored)
├── scripts/
│ ├── deploy.sh # One-click deployment
│ └── ctl.sh # Service management
Configuration Templating
Transforming the runtime openclaw.json into a template meant replacing sensitive information with environment variable placeholders:
{
"channels": {
"discord": {
"enabled": true,
"token": "${DISCORD_BOT_TOKEN}"
}
},
"gateway": {
"auth": {
"mode": "token",
"token": "${OPENCLAW_GATEWAY_TOKEN}"
}
}
}
Two types of fields were also stripped:
- Runtime-generated fields:
meta(version timestamps),wizard(onboarding state)—maintained by OpenClaw itself - Runtime state within cron jobs:
state.nextRunAtMs,state.lastRunAtMs, etc.
The template describes only "what I want the configuration to be," independent of environment or runtime artifacts.
Cron Jobs: Removing Hardcoded Models
The daily-articles cron job had a hardcoded model in its payload:
{
"payload": {
"kind": "agentTurn",
"message": "读取 SOUL.md...",
"model": "google-gemini-cli/gemini-3-pro-preview"
}
}
This meant the cron job would use the old model even after updating the agent's default. The fix: remove the model field from the template and let it inherit from the agent's configuration.
Isolated Secret Management
Values replaced by placeholders are stored in secrets/.env, excluded via .gitignore:
OPENCLAW_GATEWAY_TOKEN=7fd8cd94...
DISCORD_BOT_TOKEN=MTQ3Njc3...
ANTHROPIC_API_KEY=sk-ant-api03-...
The Deploy Script: Merge, Not Overwrite
deploy.sh is the workflow core. It doesn't simply copy the template over—that would destroy runtime state. It performs an intelligent merge.
Key steps:
# 1. Load secrets
source "$REPO_DIR/secrets/.env"
# 2. Placeholder substitution
GENERATED=$(cat "$TEMPLATE" | sed \
-e "s|\${DISCORD_BOT_TOKEN}|${DISCORD_BOT_TOKEN}|g" \
-e "s|\${OPENCLAW_GATEWAY_TOKEN}|${OPENCLAW_GATEWAY_TOKEN}|g"
)
# 3. Preserve runtime meta/wizard fields
FINAL_CONFIG=$(python3 -c "
import sys, json
with open('$RUNTIME_CONFIG') as f:
runtime = json.load(f)
generated = json.loads(sys.stdin.read())
for key in ('meta', 'wizard'):
if key in runtime:
generated[key] = runtime[key]
json.dump(generated, sys.stdout, indent=2, ensure_ascii=False)
" <<< "$GENERATED")
The cron job merge is more refined, reading declarative definitions from the template and state plus timestamps from the runtime, matching by id:
FINAL_CRON=$(python3 -c "
import sys, json
with open('$CRON_TEMPLATE') as f:
template = json.load(f)
with open('$RUNTIME_CRON') as f:
runtime = json.load(f)
runtime_state = {}
for job in runtime.get('jobs', []):
if 'state' in job:
runtime_state[job['id']] = job['state']
for job in template.get('jobs', []):
if job['id'] in runtime_state:
job['state'] = runtime_state[job['id']]
json.dump(template, sys.stdout, indent=2, ensure_ascii=False)
")
Deployment doesn't reset cron job timers. If a task is scheduled to run in 3 hours, it still runs in 3 hours after deployment—no countdown reset.
The final two steps are restart and health check:
# Restart gateway
launchctl kickstart -k "gui/$(id -u)/ai.openclaw.gateway"
# Wait for the service to come up
for i in $(seq 1 15); do
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 \
"http://127.0.0.1:18789/__openclaw__/canvas/" 2>/dev/null || echo "000")
if [[ "$HTTP_CODE" != "000" ]]; then
echo "Gateway is up (HTTP $HTTP_CODE)"
break
fi
sleep 2
done
Two parameters enable common workflows:
--dry-run: outputs the diff without writing—for pre-deployment review--set-model <model>: batch-replaces all agent models in one shot
Service Management: Unified Entry Point
Previously, every operation required verbose launchctl commands. Now, ctl.sh wraps all common operations:
./scripts/ctl.sh status # Full overview
./scripts/ctl.sh restart # Restart gateway
./scripts/ctl.sh tail # Real-time logs
./scripts/ctl.sh cron # Cron job overview
The status command outputs a comprehensive dashboard:
=== OpenClaw Status ===
Gateway:
Service: LOADED (pid=36397)
HTTP health: OK (HTTP 200)
Port: 18789
Agents:
main model=minimax-portal/MiniMax-M2.5-highspeed [DEFAULT]
hephaestus model=minimax-portal/MiniMax-M2.5-highspeed
dev model=minimax-portal/MiniMax-M2.5-highspeed
home model=minimax-portal/MiniMax-M2.5-highspeed
comms model=minimax-portal/MiniMax-M2.5-highspeed
Cron Jobs:
✓ daily-articles agent=hephaestus last=ok next=in 5h38min
✓ hacker-news-daily agent=main last=- next=in 9h58min
✓ quant-auto-evolve agent=main last=- next=in 14min
All agent models and cron job schedules, visible at a glance.
Results
The requirement—"switch all models from Gemini to MiniMax M2.5 Highspeed"—now takes two steps:
- Edit
config/openclaw.jsonto change the model name - Run
./scripts/deploy.sh
Done in 10 seconds, with every change tracked in Git. Or one command:
./scripts/deploy.sh --set-model minimax-portal/MiniMax-M2.5-highspeed
All agents, all heartbeats, everything updated.
Configuration templating, secret separation, and intelligent merging solved a real operational problem: consistency at scale. The pattern works because it separates concerns—repository holds intent, runtime holds state—and automates the merge.