wizardshit.ai
Lesson

Durability

The claw survives reboots, crashes, and restarts. One launchd plist file. It starts on login, comes back if it dies, and tells you when it restarts.

Kill the claw's process. It's gone. Reboot your machine. Gone. Close your laptop for the night, open it in the morning. The claw is gone and you have to manually start it again.

That's not a system. That's a script you remember to run.

This lesson makes the claw durable. It starts when you log in, restarts when it crashes, and messages you on Telegram when it comes back. One file does all of this.

launchd

macOS has a built-in process manager called launchd. It starts services on login, restarts them on crash, and manages their lifecycle. Every Mac uses it. It's how your menubar apps, background services, and system daemons stay alive.

You give launchd a .plist file that describes your service. It handles the rest.

The plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.my-claw.channel</string>

    <key>ProgramArguments</key>
    <array>
        <string>__CLAW_DIR__/run.sh</string>
        <string>channel</string>
    </array>

    <key>WorkingDirectory</key>
    <string>__CLAW_DIR__</string>

    <key>RunAtLoad</key>
    <true/>

    <key>KeepAlive</key>
    <true/>

    <key>ThrottleInterval</key>
    <integer>10</integer>

    <key>EnvironmentVariables</key>
    <dict>
        <key>PATH</key>
        <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
        <key>HOME</key>
        <string>__HOME__</string>
    </dict>

    <key>StandardOutPath</key>
    <string>__CLAW_DIR__/.claw/logs/claw.out.log</string>

    <key>StandardErrorPath</key>
    <string>__CLAW_DIR__/.claw/logs/claw.err.log</string>
</dict>
</plist>

Three keys do the work:

RunAtLoad: true starts the claw when you log in. Open your laptop, the claw is already running before you open Telegram.

KeepAlive: true restarts the claw if the process dies. Container crashes, OOM kill, whatever. launchd brings it back.

ThrottleInterval: 10 waits 10 seconds between restarts. Prevents a crash loop from eating your machine.

The __CLAW_DIR__ and __HOME__ placeholders get replaced by the install script with your actual paths.

The gather plist

A second service for the background gatherer. The channel runs continuously (it's a polling loop). The gatherer runs periodically (pull data, write digest, exit).

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.my-claw.gather</string>

    <key>ProgramArguments</key>
    <array>
        <string>__CLAW_DIR__/run.sh</string>
        <string>gather</string>
    </array>

    <key>WorkingDirectory</key>
    <string>__CLAW_DIR__</string>

    <key>StartInterval</key>
    <integer>900</integer>

    <key>EnvironmentVariables</key>
    <dict>
        <key>PATH</key>
        <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
        <key>HOME</key>
        <string>__HOME__</string>
    </dict>

    <key>StandardOutPath</key>
    <string>__CLAW_DIR__/.claw/logs/gather.out.log</string>

    <key>StandardErrorPath</key>
    <string>__CLAW_DIR__/.claw/logs/gather.err.log</string>
</dict>
</plist>

StartInterval: 900 runs the gatherer every 15 minutes. No KeepAlive because the gatherer isn't a long-running process. It runs, writes the digest, and exits. launchd fires it again in 15 minutes.

This is the payoff from lesson 02. The digest stays fresh because launchd refreshes it every 15 minutes. When you ask "any important emails?" the answer is already cached. No live API call, no waiting. Instant.

Two plists, two concerns. The channel handles messages. The gatherer handles data. Each runs independently. If the channel crashes, gathering continues. If gathering fails, the channel still works with the last cached digest.

The install script

The install script handles both services:

#!/bin/bash
set -e

CLAW_DIR="$(cd "$(dirname "$0")/.." && pwd)"
GUI_DOMAIN="gui/$(id -u)"

install_service() {
  local label="$1" template="$2"
  local plist="$HOME/Library/LaunchAgents/$label.plist"

  launchctl bootout "$GUI_DOMAIN/$label" 2>/dev/null || true

  sed -e "s|__CLAW_DIR__|$CLAW_DIR|g" \
      -e "s|__HOME__|$HOME|g" \
      "$template" > "$plist"

  launchctl bootstrap "$GUI_DOMAIN" "$plist"
  echo "  $label: installed"
}

uninstall_service() {
  local label="$1"
  local plist="$HOME/Library/LaunchAgents/$label.plist"
  launchctl bootout "$GUI_DOMAIN/$label" 2>/dev/null || true
  rm -f "$plist"
  echo "  $label: removed"
}

case "${1:-install}" in
  install)
    mkdir -p "$CLAW_DIR/.claw/logs"

    # Build Docker image if needed
    if ! docker image inspect my-claw >/dev/null 2>&1; then
      echo "Building Docker image..."
      docker build -t my-claw "$CLAW_DIR"
    fi

    install_service "com.my-claw.channel" "$CLAW_DIR/service/my-claw.plist.template"
    install_service "com.my-claw.gather" "$CLAW_DIR/service/my-claw-gather.plist.template"

    # Kick the channel to start immediately
    launchctl kickstart -k "$GUI_DOMAIN/com.my-claw.channel"

    echo "Installed. Channel runs continuously. Gather runs every 15 minutes."
    ;;

  uninstall)
    uninstall_service "com.my-claw.channel"
    uninstall_service "com.my-claw.gather"
    echo "Uninstalled."
    ;;

  status)
    echo "=== Channel ==="
    launchctl print "$GUI_DOMAIN/com.my-claw.channel" 2>&1 | head -10
    echo ""
    echo "=== Gather ==="
    launchctl print "$GUI_DOMAIN/com.my-claw.gather" 2>&1 | head -10
    ;;

  logs)
    tail -f "$CLAW_DIR/.claw/logs/claw.out.log"
    ;;
esac

./service/install.sh generates the plist, loads it into launchd, and starts the claw. ./service/install.sh uninstall reverses it. ./service/install.sh status shows whether it's running. ./service/install.sh logs tails the output.

The script uses launchctl bootstrap/bootout (the modern API), not the deprecated load/unload.

Restart awareness

The claw should know it restarted and tell the operator. Two pieces:

Track the owner's chat ID. On the first Telegram interaction, save the chat ID to .claw/owner-chat-id. This persists across restarts because .claw/ is on the host filesystem, not inside the container.

Send a notification on restart. When the channel process starts, check if a previous start was recorded. If so, it's a restart. Message the owner on Telegram.

Add the following to your existing channel.mjs, after the imports. The bot variable is the Chat instance you created in the channels lesson. The restart check runs after bot.initialize():

import { join } from "node:path";

const RESTART_FILE = join(__dirname, ".claw", "last-start");
const OWNER_CHAT_FILE = join(__dirname, ".claw", "owner-chat-id");

function checkRestart() {
  const isRestart = existsSync(RESTART_FILE);
  writeFileSync(RESTART_FILE, String(Date.now()));
  return isRestart;
}

async function notifyRestart() {
  if (!existsSync(OWNER_CHAT_FILE)) return;
  const chatId = readFileSync(OWNER_CHAT_FILE, "utf-8").trim();

  try {
    const ch = bot.channel(chatId);
    await ch.post("I'm back. Restarted and ready.");
  } catch (err) {
    console.error("[restart] Failed to notify:", err.message);
  }
}

Then after await bot.initialize() at the bottom of the file:

const wasRestart = checkRestart();
if (wasRestart) {
  setTimeout(() => notifyRestart(), 5000);
}

The 5-second delay gives the Telegram adapter time to connect before sending. The chat ID includes the adapter prefix (telegram:7718912466) so bot.channel() knows which adapter to use.

State that survives

The .claw/ directory is mounted from the host into the Docker container. Everything in it persists across container restarts:

FileSurvives restart?What it stores
.claw/sessions/*.sessionYesPer-chat Claude session IDs
.claw/MEMORY.mdYesCurated operator preferences, VIPs, projects
.claw/cache/digest.mdYesLatest email/calendar digest
.claw/interactions.jsonlYesInteraction log for memory analyzer
.claw/owner-chat-idYesTelegram chat ID for restart notifications
.claw/last-startYesTimestamp for restart detection
.claw/logs/Yesstdout and stderr logs

The only thing lost on crash is the in-flight response. If the claw was mid-sentence when the container died, that response is gone. The operator sees "I'm back" and can ask again.

The service skill

Same pattern as before: a SKILL.md so the claw knows about its own infrastructure. Create a skill for service management:

# Service Management

The claw runs as a launchd service on macOS. It starts on login and
restarts on crash.

## Commands

- `./service/install.sh` — install the launchd service
- `./service/install.sh uninstall` — remove the service
- `./service/install.sh status` — check if the service is running
- `./service/install.sh logs` — tail stdout logs

## Files

- `service/my-claw.plist.template` — launchd service definition
- `.claw/logs/claw.out.log` — stdout log
- `.claw/logs/claw.err.log` — stderr log
- `.claw/last-start` — timestamp of last start (for restart detection)
- `.claw/owner-chat-id` — Telegram chat ID for restart notifications

## When to suggest

- If the operator asks "is the claw running?"
- If the operator asks about restarts or crashes
- If the operator wants to check logs

Try it

# Install the service
./service/install.sh

# Check it's running
./service/install.sh status

# Tail the logs
./service/install.sh logs

Message the bot on Telegram. Then kill the Docker container:

docker ps | grep claw-durable | awk '{print $1}' | xargs docker kill

Wait 10 seconds. launchd restarts it. You get "I'm back. Restarted and ready." on Telegram. The claw remembers your previous conversation because the session file persisted.

Now close your laptop. Open it tomorrow. Open Telegram. The claw is already there. You didn't start it. launchd did.

What you have

A claw that survives. It starts on login, restarts on crash, and notifies you when it comes back. All state persists in .claw/. One plist file does the heavy lifting. The operator never touches a terminal.

For learners on Linux, the equivalent is a systemd user service with Restart=always. The concept is identical, the syntax is different.

For a deeper look at what production durability looks like, study OpenClaw's daemon system. They have five layers of crash recovery including in-process restart via SIGUSR1, graceful drain before restart, and restart sentinel files for resuming delivery context. That's the graduation path from what we built here.

What's missing

The claw runs, restarts, and persists. But when something goes wrong, you're tailing log files and grepping for errors. There's no structured way to ask "what happened while I was away" or "how much did the claw cost this week." Next: give the claw eyes on itself.