---
title: "Scheduled Tasks Overview"
description: "Headless Claude Code sessions triggered by systemd timers using runner.sh template framework and custom dispatcher scripts. Covers task layout, settings reference, cost safety, and monitoring."
type: context
domain: scheduled-tasks
tags: [claude-code, systemd, timers, automation, headless, runner, issue-poller, pr-reviewer, backlog-triage, sync-kb, sync-config]
---

# Scheduled Tasks — Headless Claude Sessions on a Timer

Headless Claude Code sessions triggered by systemd timers. Runs `claude -p` with task-specific prompts, tools, and cost limits — no TTY required.

For a guide on **creating new tasks**, see: `~/.claude/skills/create-scheduled-task/SKILL.md`

## File Layout

```
~/.config/claude-scheduled/
├── runner.sh                       # Universal task runner (template-based tasks)
├── disabled                        # Touch to globally pause all template tasks
├── tasks/
│   ├── backlog-triage/             # Example template task
│   │   ├── prompt.md               # What Claude should do
│   │   ├── settings.json           # Model, budget, tools, working dir
│   │   └── mcp.json                # MCP servers (optional)
│   ├── issue-poller/               # Used by issue-poller.sh (not runner.sh)
│   ├── issue-worker/               # Agent instructions + settings
│   └── pr-reviewer/                # Agent instructions + settings
├── issue-poller.sh                 # Custom dispatcher for issue scanning
├── issue-dispatcher.sh             # Dispatches AI agent per issue
├── pr-reviewer-dispatcher.sh       # Dispatches AI reviewer per open PR
└── repos.json                      # Gitea repos to scan

~/.config/systemd/user/
├── claude-scheduled@.service       # Template service (used by runner.sh tasks)
├── claude-scheduled@backlog-triage.timer
├── claude-scheduled@sync-config.timer  # Daily 02:00
├── claude-issue-poller.service/timer   # Every 30 min
├── claude-pr-reviewer.service/timer
├── claude-issue-dispatcher.service
├── claude-daily-report.service/timer
├── sync-kb.service/timer               # Every 2 hours (plain bash, no Claude)

~/.local/share/claude-scheduled/
└── logs/<task-name>/               # Timestamped .log files (last 30 kept)
```

Source files are in `~/dotfiles/claude-scheduled/` and symlinked into place.

## Three Execution Patterns

### 1. runner.sh (Template Framework)

Used by tasks with only a prompt and settings — no custom logic needed.

```
claude-scheduled@<task>.timer
    → claude-scheduled@.service
        → runner.sh <task>
            reads tasks/<task>/{prompt.md,settings.json,mcp.json}
            invokes: claude -p <prompt> --model --effort --max-budget-usd
                          --allowedTools --output-format json
            optionally POSTs to Discord webhook
```

**Kill switch**: `touch ~/.config/claude-scheduled/disabled` pauses all runner.sh tasks.

### 2. Custom Dispatcher Scripts

Used for tasks that need conditional dispatch, parallel agents, or complex logic.

```
claude-issue-poller.timer (every 30 min)
    → issue-poller.sh
        → scans repos.json for new open issues
        → calls issue-dispatcher.sh per issue
            → spawns claude -p (issue-worker agent)

claude-pr-reviewer.timer
    → pr-reviewer-dispatcher.sh
        → scans open PRs needing review
        → spawns claude -p (pr-reviewer agent) per PR

claude-daily-report.timer
    → daily-report.py
        → generates summary, posts to Discord
```

### 3. Plain Bash Scripts

Used for tasks that don't need AI — just shell commands with their own systemd units.

```
sync-kb.timer (every 2 hours)
    → sync-kb.service
        → tasks/sync-kb/sync-kb.sh
            → git add/commit/push .md files in claude-home
            → push triggers kb-rag webhook reindex via Gitea Actions
```

Logs go to `~/.local/share/claude-scheduled/logs/sync-kb/` (last 30 kept).

## Active Tasks

| Task | Trigger | Type | Description |
|------|---------|------|-------------|
| `backlog-triage` | Weekdays 09:15 | runner.sh | Scan Gitea issues, prioritize, suggest focus |
| `sync-config` | Daily 02:00 | runner.sh | Commit and push `~/.claude` and `~/dotfiles` to Gitea |
| `issue-poller` | Every 30 min | custom | Find new issues, dispatch AI workers |
| `issue-dispatcher` | On-demand | custom | Fix a single issue, open a PR |
| `pr-reviewer` | On timer | custom | Review open PRs, post formal reviews |
| `daily-report` | Daily | custom | Summarize activity, post to Discord |
| `sync-kb` | Every 2 hours | bash script | Commit and push claude-home KB changes to Gitea (triggers kb-rag reindex) |

## Settings Reference (`settings.json`)

```json
{
  "model": "sonnet",
  "effort": "medium",
  "max_budget_usd": 0.75,
  "allowed_tools": "Read(*) Glob(*) Grep(*)",
  "working_dir": "/mnt/NV2/Development/claude-home",
  "timeout_seconds": 300,
  "notify_webhook": "https://discord.com/api/webhooks/..."
}
```

| Field | Default | Notes |
|-------|---------|-------|
| `model` | `sonnet` | Model alias. Use `sonnet` for cost efficiency. |
| `effort` | `medium` | `low`, `medium`, `high` — controls reasoning depth |
| `max_budget_usd` | `0.25` | Hard cost ceiling per session |
| `allowed_tools` | `Read(*) Glob(*) Grep(*)` | Principle of least privilege |
| `working_dir` | `claude-home` | `cd` here before running — loads that project's CLAUDE.md |
| `timeout_seconds` | `300` | Hard timeout via `timeout(1)` |
| `notify_webhook` | — | Discord webhook URL for result posting (optional) |

## Auth and Environment

- Uses Cal's Claude Max subscription via **OAuth** (no API key needed)
- `runner.sh` unsets `CLAUDECODE` to allow nested sessions (running from within Claude Code)
- MCP servers configured per-task via `mcp.json` + `--strict-mcp-config`

## Monitoring

```bash
# List all active timers
systemctl --user list-timers 'claude*'

# Check logs for a task
journalctl --user -u claude-scheduled@backlog-triage.service --since today

# Latest log file for runner.sh task
ls -t ~/.local/share/claude-scheduled/logs/backlog-triage/ | head -1

# Manual test run
~/.config/claude-scheduled/runner.sh backlog-triage
```

## Session Resumption

Tasks can opt into session persistence for multi-step workflows:

```json
{
  "session_resumable": true,
  "resume_last_session": true
}
```

When `session_resumable` is `true`, runner.sh saves the `session_id` to `$LOG_DIR/last_session_id` after each run. When `resume_last_session` is also `true`, the next run resumes that session with `--resume`.

Issue-poller and PR-reviewer capture `session_id` in logs and result JSON for manual follow-up.

See also: [Agent SDK Evaluation](agent-sdk-evaluation.md) for CLI vs SDK comparison.

## Cost Safety

- Per-task `max_budget_usd` cap — runner.sh detects `error_max_budget_usd` and warns
- `--allowedTools` restricts what Claude can do (read-only tasks can't Edit/Write/Bash)
- `timeout_seconds` kills hung sessions
- Global kill switch: `touch ~/.config/claude-scheduled/disabled`
- Typical cost: $0.15–0.30 per Sonnet run with MCP tools
