From 8911a79d7f6194e184d38ce21b7c120552a85a2b Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Tue, 6 Jan 2026 22:28:32 +0000
Subject: [PATCH 1/6] docs: rewrite cron jobs guide and heartbeat notes
---
CHANGELOG.md | 2 +-
docs/clawd.md | 3 +-
docs/configuration.md | 8 +-
docs/cron-jobs.md | 132 +++++++++++
docs/cron.md | 385 -------------------------------
docs/heartbeat.md | 27 ++-
docs/hubs.md | 2 +-
docs/index.md | 2 +-
docs/plans/cron-add-hardening.md | 2 +-
docs/templates/AGENTS.md | 2 +-
docs/templates/HEARTBEAT.md | 2 +-
src/agents/workspace.ts | 5 +-
12 files changed, 166 insertions(+), 406 deletions(-)
create mode 100644 docs/cron-jobs.md
delete mode 100644 docs/cron.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2c0a8ae13..31acd0c15 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,7 +16,7 @@
- Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior.
### Fixes
-- Heartbeat: default interval now 30m with a new default prompt + HEARTBEAT.md template.
+- Heartbeat: default interval 30m; clarified default prompt usage and HEARTBEAT.md template behavior.
- Onboarding: write auth profiles to the multi-agent path (`~/.clawdbot/agents/main/agent/`) so the gateway finds credentials on first startup. Thanks @minghinmatthewlam for PR #327.
- Docs: add missing `ui:install` setup step in the README. Thanks @hugobarauna for PR #300.
- Build: import tool-display JSON as a module instead of runtime file reads. Thanks @mukhtharcm for PR #312.
diff --git a/docs/clawd.md b/docs/clawd.md
index 1c62d396b..7f4115b70 100644
--- a/docs/clawd.md
+++ b/docs/clawd.md
@@ -166,6 +166,7 @@ By default, CLAWDBOT runs a heartbeat every 30 minutes with the prompt:
Set `agent.heartbeat.every: "0m"` to disable.
- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agent.heartbeat.ackMaxChars`), CLAWDBOT suppresses outbound delivery for that heartbeat.
+- Heartbeats run full agent turns — shorter intervals burn more tokens.
```json5
{
@@ -205,7 +206,7 @@ Logs live under `/tmp/clawdbot/` (default: `clawdbot-YYYY-MM-DD.log`).
- WebChat: [WebChat](https://docs.clawd.bot/webchat)
- Gateway ops: [Gateway runbook](https://docs.clawd.bot/gateway)
-- Cron + wakeups: [Cron + wakeups](https://docs.clawd.bot/cron)
+- Cron + wakeups: [Cron jobs](https://docs.clawd.bot/cron-jobs)
- macOS menu bar companion: [Clawdbot macOS app](https://docs.clawd.bot/macos)
- iOS node app: [iOS app](https://docs.clawd.bot/ios)
- Android node app: [Android app](https://docs.clawd.bot/android)
diff --git a/docs/configuration.md b/docs/configuration.md
index 8fc8a76cd..ea954c48b 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -791,11 +791,11 @@ Z.AI models are available as `zai/` (e.g. `zai/glm-4.7`) and require
- `model`: optional override model for heartbeat runs (`provider/model`).
- `target`: optional delivery provider (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`.
- `to`: optional recipient override (provider-specific id, e.g. E.164 for WhatsApp, chat id for Telegram).
-- `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`).
+- `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md if exists` line if you still want the file read.
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 30).
-Heartbeats run full agent turns. Shorter intervals burn more tokens; adjust `every`
-and/or `model` accordingly.
+Heartbeats run full agent turns. Shorter intervals burn more tokens; be mindful
+of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`.
`agent.bash` configures background bash defaults:
- `backgroundMs`: time before auto-background (ms, default 10000)
@@ -1482,7 +1482,7 @@ Template placeholders are expanded in `routing.transcribeAudio.command` (and any
## Cron (Gateway scheduler)
-Cron is a Gateway-owned scheduler for wakeups and scheduled jobs. See [Cron + wakeups](https://docs.clawd.bot/cron) for the full RFC and CLI examples.
+Cron is a Gateway-owned scheduler for wakeups and scheduled jobs. See [Cron jobs](https://docs.clawd.bot/cron-jobs) for the feature overview and CLI examples.
```json5
{
diff --git a/docs/cron-jobs.md b/docs/cron-jobs.md
new file mode 100644
index 000000000..a4ad5d5c8
--- /dev/null
+++ b/docs/cron-jobs.md
@@ -0,0 +1,132 @@
+---
+summary: "Cron jobs + wakeups for the Gateway scheduler"
+read_when:
+ - Scheduling background jobs or wakeups
+ - Wiring automation that should run with or alongside heartbeats
+---
+# Cron jobs (Gateway scheduler)
+
+Cron runs inside the Gateway and schedules background work so Clawdbot can
+wake itself up, run isolated agent jobs, and deliver reminders on time.
+
+## Update checklist (internal)
+- [x] Audit cron + heartbeat behavior in code
+- [x] Rewrite cron doc as user-facing feature
+- [x] Update heartbeat docs + templates
+- [x] Update cron links in docs
+- [x] Update changelog
+- [x] Run full gate (lint/build/test/docs)
+
+## What cron is
+- **Gateway-owned scheduler** that persists jobs under `~/.clawdbot/cron/`.
+- **Two execution modes**:
+ - **Main session jobs** enqueue `System:` events and rely on the heartbeat runner.
+ - **Isolated jobs** run a dedicated agent turn in `cron:` sessions.
+- **Wakeups** are first-class: a job can trigger the next heartbeat or run it now.
+
+## When to use it
+- Recurring reminders: “every weekday at 7:30” or “every 2h.”
+- Background chores: summarize inboxes, check dashboards, watch logs.
+- Automation that should not pollute the main chat history.
+- Scheduled wakeups that drive the heartbeat pipeline.
+
+## Schedules
+Cron supports three schedule kinds:
+- `at`: one-shot timestamp in ms.
+- `every`: fixed interval (ms).
+- `cron`: 5-field cron expression, optional IANA timezone.
+
+Cron expressions use `croner` under the hood. If a timezone is omitted, the
+server’s local timezone is used.
+
+## Job types
+
+### Main session jobs
+Main jobs enqueue a system event and optionally wake the heartbeat runner.
+They **must** use `payload.kind = "systemEvent"`.
+
+- **`wakeMode: "next-heartbeat"`** (default): the event waits for the next
+ scheduled heartbeat.
+- **`wakeMode: "now"`**: the event triggers an immediate heartbeat run.
+
+### Isolated jobs
+Isolated jobs run a dedicated agent turn in session `cron:` and can
+optionally deliver a message.
+
+Key behaviors:
+- Prompt is prefixed with `[cron: ]` for traceability.
+- A summary is posted to the main session with prefix `Cron` (or
+ `isolation.postToMainPrefix`).
+- `wakeMode: "now"` triggers an immediate heartbeat after posting the summary.
+- `payload.deliver: true` sends output to a provider; otherwise it stays internal.
+
+## Storage & history
+- Job store: `~/.clawdbot/cron/jobs.json` (JSON, Gateway-managed).
+- Run history: `~/.clawdbot/cron/runs/.jsonl` (JSONL, auto-pruned).
+- Override store path: `cron.store` in config.
+
+## Configuration
+
+```json5
+{
+ cron: {
+ enabled: true, // default true
+ store: "~/.clawdbot/cron/jobs.json",
+ maxConcurrentRuns: 1 // default 1
+ }
+}
+```
+
+Disable cron entirely:
+- `cron.enabled: false` (config)
+- or `CLAWDBOT_SKIP_CRON=1` (env)
+
+## CLI quickstart
+
+One-shot reminder (main session, wake immediately):
+```bash
+clawdbot cron add \
+ --name "Calendar check" \
+ --at "20m" \
+ --session main \
+ --system-event "Next heartbeat: check calendar." \
+ --wake now
+```
+
+Recurring isolated job (deliver to WhatsApp):
+```bash
+clawdbot cron add \
+ --name "Morning status" \
+ --cron "0 7 * * *" \
+ --tz "America/Los_Angeles" \
+ --session isolated \
+ --message "Summarize inbox + calendar for today." \
+ --deliver \
+ --provider whatsapp \
+ --to "+15551234567"
+```
+
+Manual run (debug):
+```bash
+clawdbot cron run --force
+```
+
+Run history:
+```bash
+clawdbot cron runs --id --limit 50
+```
+
+Immediate wake without creating a job:
+```bash
+clawdbot wake --mode now --text "Next heartbeat: check battery."
+```
+
+## API surface (Gateway)
+- `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`
+- `cron.run` (force or due), `cron.runs`
+- `wake` (enqueue system event + optional heartbeat)
+
+## Tips
+- Use **main session jobs** when you want the heartbeat prompt + existing context.
+- Use **isolated jobs** for noisy, frequent, or long-running work.
+- Keep messages short; cron turns are full agent runs and can burn tokens.
diff --git a/docs/cron.md b/docs/cron.md
deleted file mode 100644
index 5c76c8b8d..000000000
--- a/docs/cron.md
+++ /dev/null
@@ -1,385 +0,0 @@
----
-summary: "RFC: Cron jobs + wakeups for Clawd/Clawdbot (main vs isolated sessions)"
-read_when:
- - Designing scheduled jobs, alarms, or wakeups
- - Adding Gateway methods or CLI commands for automation
- - Adjusting heartbeat behavior or session routing
----
-
-# RFC: Cron jobs + wakeups for Clawd
-
-Status: Draft
-Last updated: 2025-12-13
-
-## Context
-
-Clawdbot already has:
-- A **gateway heartbeat runner** that runs the agent with the configured heartbeat prompt (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`) and suppresses `HEARTBEAT_OK` ([`src/infra/heartbeat-runner.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/heartbeat-runner.ts)).
-- A lightweight, in-memory **system event queue** (`enqueueSystemEvent`) that is injected into the next **main session** turn (`drainSystemEvents` in [`src/auto-reply/reply.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/auto-reply/reply.ts)).
-- A WebSocket **Gateway** daemon that is intended to be always-on ([`docs/gateway.md`](https://docs.clawd.bot/gateway)).
-
-This RFC adds a small “cron job system” so Clawd can schedule future work and reliably wake itself up:
-- **Delayed**: run on the *next* normal heartbeat tick
-- **Immediate**: run *now* (trigger a heartbeat immediately)
-- **Isolated jobs**: optionally run in their own session that does not pollute the main session and can run concurrently (within configured limits).
-
-## Goals
-
-- Provide a **persistent job store** and an **in-process scheduler** owned by the Gateway.
-- Allow each job to target either:
- - `sessionTarget: "main"`: inject as `System:` lines and rely on the main heartbeat (or trigger it immediately).
- - `sessionTarget: "isolated"`: run an agent turn in a dedicated session key (job session), optionally delivering a message and/or posting a summary back to main.
-- Expose a stable control surface:
- - **Gateway methods** (`cron.*`, `wake`) for programmatic usage (mac app, CLI, agents).
- - **CLI commands** (`clawdbot cron ...`) to add/remove/edit/list and to debug `run`.
-- Produce clear, structured **logs** for job lifecycle and execution outcomes.
-
-## Non-goals (v1)
-
-- Multi-host distributed scheduling.
-- Exactly-once semantics across crashes (we aim for “at-least-once with idempotency hooks”).
-- A full Unix-cron parser as the only schedule format (we can support it, but v1 should not require complex cron features to be useful).
-
-## Terminology
-
-- **Wake**: a request to ensure the agent gets a turn soon (either right now or next heartbeat).
-- **Main session**: the canonical session bucket (default key `"main"`) that receives `System:` events.
-- **Isolated session**: a per-job session key (e.g. `cron:`) with its own session id / session file.
-
-## User stories
-
-- “Remind me in 20 minutes” → add a one-shot job that triggers an immediate heartbeat at T+20m.
-- “Every weekday at 7:30, wake me up and start music” → recurring job, isolated session, deliver to WhatsApp.
-- “Every hour, check battery; only interrupt me if < 20%” → isolated job that decides whether to deliver; may also post a brief status to main.
-- “Next heartbeat, please check calendar” → delayed wake targeting main session.
-
-## Job model
-
-### Storage schema (v1)
-
-Each job is a JSON object with stable keys (unknown keys ignored for forward compatibility):
-
-- `id: string` (UUID)
-- `name: string` (required)
-- `description?: string` (optional)
-- `enabled: boolean`
-- `createdAtMs: number`
-- `updatedAtMs: number`
-- `schedule` (one of)
- - `{"kind":"at","atMs":number}` (one-shot)
- - `{"kind":"every","everyMs":number,"anchorMs"?:number}` (simple interval)
- - `{"kind":"cron","expr":string,"tz"?:string}` (optional; see “Schedule parsing”)
-- `sessionTarget: "main" | "isolated"`
-- `wakeMode: "next-heartbeat" | "now"`
- - For `sessionTarget:"isolated"`, `wakeMode:"now"` means “run immediately when due”.
- - For `sessionTarget:"main"`, `wakeMode` controls whether we trigger the heartbeat immediately or just enqueue and wait.
-- `payload` (one of)
- - `{"kind":"systemEvent","text":string}` (enqueue as `System:`)
- - `{"kind":"agentTurn","message":string,"deliver"?:boolean,"provider"?: "last"|"whatsapp"|"telegram"|"discord"|"signal"|"imessage","to"?:string,"timeoutSeconds"?:number}`
-- `isolation` (optional; only meaningful for isolated jobs)
- - `{"postToMainPrefix"?: string}`
-- `runtime` (optional)
- - `{"maxAttempts"?:number,"retryBackoffMs"?:number}` (best-effort retries; defaults off)
-- `state` (runtime-maintained)
- - `{"nextRunAtMs":number,"lastRunAtMs"?:number,"lastStatus"?: "ok"|"error"|"skipped","lastError"?:string,"lastDurationMs"?:number}`
-
-### Key behavior
-
-- `sessionTarget:"main"` jobs always enqueue `payload.kind:"systemEvent"` (directly or derived from `agentTurn` results; see below).
-- `sessionTarget:"isolated"` jobs create/use a stable session key: `cron:`.
-
-## Storage location
-
-Cron persists everything under `~/.clawdbot/cron/`:
-- Job store: `~/.clawdbot/cron/jobs.json`
-- Run history: `~/.clawdbot/cron/runs/.jsonl`
-
-You can override the job store path via `cron.store` in config.
-
-The scheduler should never require additional configuration for the base directory (Clawdbot already treats `~/.clawdbot` as fixed).
-
-## Enabling
-
-Cron execution is enabled by default inside the Gateway.
-
-To disable it, set:
-
-```json5
-{
- cron: {
- enabled: false,
- // optional:
- store: "~/.clawdbot/cron/jobs.json",
- maxConcurrentRuns: 1
- }
-}
-```
-
-You can also disable scheduling via the environment variable `CLAWDBOT_SKIP_CRON=1`.
-
-## Scheduler design
-
-### Ownership
-
-The Gateway owns:
-- the scheduler timer,
-- job store reads/writes,
-- job execution (enqueue system events and/or agent turns).
-
-This keeps scheduling unified with the always-on process and prevents “two schedulers” when multiple CLIs run.
-
-### Timer strategy
-
-- Maintain an in-memory heap/array of enabled jobs keyed by `state.nextRunAtMs`.
-- Use a **single `setTimeout`** to wake at the earliest next run.
-- On wake:
- - compute all due jobs (now >= nextRunAtMs),
- - mark them “in flight” (in memory),
- - persist updated `state` (at least bump `nextRunAtMs` / `lastRunAtMs`) before starting execution to minimize duplicate runs on crash,
- - execute jobs (with concurrency limits),
- - persist final `lastStatus/lastError/lastDurationMs`,
- - re-arm timer for the next earliest run.
-
-### Schedule parsing
-
-V1 can ship with `at` + `every` without extra deps.
-
-If we add `"kind":"cron"`:
-- Use a well-maintained parser (we use `croner`) and support:
- - 5-field cron (`min hour dom mon dow`) at minimum
- - optional `tz`
-- Store `nextRunAtMs` computed by the parser; re-compute after each run.
-
-## Execution semantics
-
-### Main session jobs
-
-Main session jobs do not run the agent directly by default.
-
-When due:
-1) `enqueueSystemEvent(job.payload.text)` (or a derived message)
-2) If `wakeMode:"now"`, trigger an immediate heartbeat run (see “Heartbeat wake hook”).
-3) Otherwise do nothing else (the next scheduled heartbeat will pick up the system event).
-
-Why: This keeps the main session’s “proactive” behavior centralized in the heartbeat rules and avoids ad-hoc agent turns that might fight with inbound message processing.
-
-### Isolated session jobs
-
-Isolated jobs run an agent turn in a dedicated session key, intended to be separate from main.
-
-When due:
-- Build a message body that includes schedule metadata, e.g.:
- - `"[cron:] : "`
-- Execute via the same agent runner path as other command-mode runs, but pinned to:
- - `sessionKey = cron:`
- - `sessionId = store[sessionKey].sessionId` (create if missing)
-- Optionally deliver output (`payload.deliver === true`) to the configured provider/to.
-- Isolated jobs always enqueue a summary system event to the main session when they finish (derived from the last agent text output).
- - Prefix defaults to `Cron`, and can be customized via `isolation.postToMainPrefix`.
-- If `deliver` is omitted/false, nothing is sent to external providers; you still get the main-session summary and can inspect the full isolated transcript in `cron:`.
-
-### “Run in parallel to main”
-
-Clawdbot currently serializes command execution through a global in-process queue ([`src/process/command-queue.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/process/command-queue.ts)) to avoid collisions.
-
-To support isolated cron jobs running “in parallel”, we should introduce **lanes** (keyed queues) plus a global concurrency cap:
-- Lane `"main"`: inbound auto-replies + main heartbeat.
-- Lane `"cron"` (or `cron:`): isolated jobs.
-- Configurable `cron.maxConcurrentRuns` (default 1 or 2).
-
-This yields:
-- isolated jobs can overlap with the main lane (up to cap),
-- each lane still preserves ordering for its own work (optional),
-- we retain safety knobs to prevent runaway resource contention.
-
-## Heartbeat wake hook (immediate vs next heartbeat)
-
-We need a way for the Gateway (or the scheduler) to request an immediate heartbeat without duplicating heartbeat logic.
-
-Design:
-- `startHeartbeatRunner` owns the real heartbeat execution and installs a wake handler.
-- Wake hook lives in [`src/infra/heartbeat-wake.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/heartbeat-wake.ts):
- - `setHeartbeatWakeHandler(fn | null)` installed by the heartbeat runner
- - `requestHeartbeatNow({ reason, coalesceMs? })`
-- If the handler is absent, the request is stored as “pending”; the next time the handler is installed, it runs once.
-- Coalesce rapid calls and respect the “skip when queue busy” behavior (retry soon vs dropping).
-
-## Run history log (JSONL)
-
-In addition to normal structured logs, the Gateway writes an append-only run history “ledger” (JSONL) whenever a job finishes. This is intended for quick debugging (“did the job run, when, and what happened?”).
-
-Path rules:
-- Run logs are stored per job next to the store: `.../runs/.jsonl`.
-
-Retention:
-- Best-effort pruning when the file grows beyond ~2MB; keep the newest ~2000 lines.
-
-Each log line includes (at minimum) job id, status/error, timing, and a `summary` string (systemEvent text for main jobs, and the last agent text output for isolated jobs).
-
-## Compatibility policy (cron.add/cron.update)
-
-To keep older clients working, the Gateway applies **best-effort normalization** for `cron.add` and `cron.update`:
-- Accepts wrapped payloads under `data` or `job` and unwraps them.
-- Infers `schedule.kind` from `atMs`, `everyMs`, or `expr` if missing.
-- Infers `payload.kind` from `text` (systemEvent) or `message` (agentTurn) if missing.
-- Defaults `wakeMode` to `"next-heartbeat"` when omitted.
-- Defaults `sessionTarget` based on payload kind (`systemEvent` → `"main"`, `agentTurn` → `"isolated"`).
-
-Normalization is **compat-only**. New clients should send the full schema (including `kind`, `sessionTarget`, and `wakeMode`) to avoid ambiguity. Unknown fields are still rejected by schema validation.
-
-## Gateway API
-
-New methods (names can be bikeshed; `cron.*` is suggested):
-
-- `wake`
- - params: `{ mode: "now" | "next-heartbeat", text: string }`
- - effect: `enqueueSystemEvent(text)`, plus optional immediate heartbeat trigger
-
-- `cron.list`
- - params: optional `{ includeDisabled?: boolean }`
- - returns: `{ jobs: CronJob[] }`
-
-- `cron.add`
- - params: job payload without `id/state` (server generates and returns created job)
-
-- `cron.update`
- - params: `{ id: string, patch: Partial }`
-
-- `cron.remove`
- - params: `{ id: string }`
-
-- `cron.run`
- - params: `{ id: string, mode?: "due" | "force" }` (debugging; does not change schedule unless `force` requires it)
-
-- `cron.runs`
- - params: `{ id: string, limit?: number }`
- - returns: `{ entries: CronRunLogEntry[] }`
- - note: `id` is required (runs are stored per-job).
-
-The Gateway should broadcast a `cron` event for UI/debug:
-- event: `cron`
- - payload: `{ jobId, action: "added"|"updated"|"removed"|"started"|"finished", status?, error?, nextRunAtMs? }`
-
-## CLI surface
-
-Add a `cron` command group (all commands should also support `--json` where sensible):
-
-- `clawdbot cron list [--json] [--all]`
-- `clawdbot cron add ...`
- - schedule flags:
- - `--at ` (one-shot)
- - `--every ` (e.g. `10m`, `1h`)
- - `--cron "" [--tz ""]`
- - target flags:
- - `--session main|isolated`
- - `--wake now|next-heartbeat`
- - payload flags (choose one):
- - `--system-event ""`
- - `--message "" [--deliver] [--provider last|whatsapp|telegram|discord|slack|signal|imessage] [--to ]`
-
-- `clawdbot cron edit ...` (patch-by-flags, non-interactive)
-- `clawdbot cron rm `
-- `clawdbot cron enable ` / `clawdbot cron disable `
-- `clawdbot cron run [--force]` (debug)
-- `clawdbot cron runs --id [--limit ]` (run history)
-- `clawdbot cron status` (scheduler enabled + next wake)
-
-Additionally:
-- `clawdbot wake --mode now|next-heartbeat --text ""` as a thin wrapper around `wake` for agents to call.
-
-## Examples
-
-### Run once at a specific time
-
-One-shot reminder that targets the main session and triggers a heartbeat immediately at the scheduled time:
-
-```bash
-clawdbot cron add \
- --at "2025-12-14T07:00:00-08:00" \
- --session main \
- --wake now \
- --system-event "Alarm: wake up (meeting in 30 minutes)."
-```
-
-### Run daily (calendar-accurate)
-
-Daily at 07:00 in a specific timezone (preferred over “every 24h” to avoid DST drift):
-
-```bash
-clawdbot cron add \
- --cron "0 7 * * *" \
- --tz "America/Los_Angeles" \
- --session isolated \
- --wake now \
- --message "Daily check: scan calendar + inbox; deliver only if urgent." \
- --deliver \
- --provider last
-```
-
-### Run weekly (every Wednesday)
-
-Every Wednesday at 09:00:
-
-```bash
-clawdbot cron add \
- --cron "0 9 * * 3" \
- --tz "America/Los_Angeles" \
- --session isolated \
- --wake now \
- --message "Weekly: summarize status and remind me of goals." \
- --deliver \
- --provider last
-```
-
-### “Next heartbeat”
-
-Enqueue a note for the main session but let the existing heartbeat cadence pick it up:
-
-```bash
-clawdbot wake --mode next-heartbeat --text "Next heartbeat: check battery + upcoming meetings."
-```
-
-## Logging & observability
-
-Logging requirements:
-- Use `getChildLogger({ module: "cron", jobId, runId, name })` for every run.
-- Log lifecycle:
- - store load/save (debug; include job count)
- - schedule recompute (debug; include nextRunAt)
- - job start/end (info)
- - job skipped (info; include reason)
- - job error (warn; include error + stack where available)
-- Emit a concise user-facing line to stdout when running in CLI mode (similar to heartbeat logs).
-
-Suggested log events:
-- `cron: scheduler started` (jobCount, nextWakeAt)
-- `cron: job started` (jobId, scheduleKind, sessionTarget, wakeMode)
-- `cron: job finished` (status, durationMs, nextRunAtMs)
-- When `cron.enabled` is false, the Gateway logs `cron: disabled` and jobs will not run automatically (the CLI warns on `cron add`/`cron edit`).
-- Use `clawdbot cron status` to confirm the scheduler is enabled and see the next wake time.
-
-## Safety & security
-
-- Respect existing allowlists/routing rules: delivery defaults should not send to arbitrary destinations unless explicitly configured.
-- Provide a global “kill switch”:
- - `cron.enabled: boolean` (default `true`).
- - `gateway method set-heartbeats` already exists; cron should have similar.
-- Avoid persistence of sensitive payloads unless requested; job text may contain private content.
-
-## Testing plan (v1)
-
-- Unit tests:
- - schedule computation for `at` and `every`
- - job store read/write + migration behavior
- - lane concurrency: main vs cron overlap is bounded
- - “wake now” coalescing and pending behavior when provider not ready
-- Integration tests:
- - start Gateway with `CLAWDBOT_SKIP_PROVIDERS=1`, add jobs, list/edit/remove
- - simulate due jobs and assert `enqueueSystemEvent` called + cron events broadcast
-
-## Rollout plan
-
-1) Add the `wake` primitive + heartbeat wake hook (no persistent jobs yet).
-2) Add `cron.*` API and CLI wrappers with `at` + `every`.
-3) Add optional cron expression parsing (`kind:"cron"`) if needed.
-4) Add UI surfacing in WebChat/macOS app (optional).
diff --git a/docs/heartbeat.md b/docs/heartbeat.md
index 6fb021a4d..bb7b4f73b 100644
--- a/docs/heartbeat.md
+++ b/docs/heartbeat.md
@@ -8,17 +8,28 @@ read_when:
Heartbeat runs periodic agent turns in the **main session** so the model can
surface anything that needs attention without spamming the user.
+## Defaults
+- Interval: `30m` (set `agent.heartbeat.every` to change, `0m` disables).
+- Prompt body (configurable via `agent.heartbeat.prompt`):
+ `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`
+- Heartbeat prompt text is sent **verbatim** as the user message. Clawdbot does
+ not append extra body text. The system prompt includes a Heartbeats section
+ and the run is flagged as a heartbeat internally.
+
## Prompt contract
-- Heartbeat body defaults to: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.` (configurable via `agent.heartbeat.prompt`).
- If nothing needs attention, the model should reply `HEARTBEAT_OK`.
- During heartbeat runs, Clawdbot treats `HEARTBEAT_OK` as an ack when it appears at
the **start or end** of the reply. Clawdbot strips the token and discards the
reply if the remaining content is **≤ `ackMaxChars`** (default: 30).
- If `HEARTBEAT_OK` is in the **middle** of a reply, it is not treated specially.
- For alerts, do **not** include `HEARTBEAT_OK`; return only the alert text.
-- Heartbeat prompt text is sent **verbatim** as the user message. Clawdbot does
- not append extra body text. The system prompt includes a Heartbeats section
- and the run is flagged as a heartbeat internally.
+
+## Prompt overrides
+- Overriding `agent.heartbeat.prompt` **replaces** the default body. Nothing is
+ merged for you.
+- If you still want `HEARTBEAT.md` instructions, keep a line like
+ `Read HEARTBEAT.md if exists` in your custom prompt.
+- `HEARTBEAT_OK` handling stays the same; changing the prompt won’t break acks.
### Stray `HEARTBEAT_OK` outside heartbeats
If the model accidentally includes `HEARTBEAT_OK` at the start or end of a
@@ -63,9 +74,9 @@ and final replies:
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 30).
## Cost awareness
-Heartbeats run full agent turns. Shorter intervals burn more tokens. If you
-don’t need frequent checks, increase `every`, pick a cheaper `model`, or set
-`target: "none"` to keep results internal.
+Heartbeats run full agent turns. Shorter intervals burn more tokens. Be
+intentional about `every`, keep `HEARTBEAT.md` tiny, and consider a cheaper
+`model` or `target: "none"` if you only want internal state updates.
## HEARTBEAT.md (optional)
If a `HEARTBEAT.md` file exists in the workspace, the default prompt tells the
@@ -85,6 +96,8 @@ bloat.
- Handle mundane tasks (triage inboxes, summarize queues, refresh notes).
- Nudge on open loops or reminders.
- Background monitoring (health checks, status polling, low-priority alerts).
+- Scheduled routines (use [Cron jobs](https://docs.clawd.bot/cron-jobs) when you
+ need exact schedules or isolated runs).
## Wake hook
- The gateway exposes a heartbeat wake hook so cron/jobs/webhooks can request an
diff --git a/docs/hubs.md b/docs/hubs.md
index 87e74d053..4babee171 100644
--- a/docs/hubs.md
+++ b/docs/hubs.md
@@ -80,7 +80,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
- [Tools surface](https://docs.clawd.bot/tools)
- [Bash tool](https://docs.clawd.bot/bash)
- [Elevated mode](https://docs.clawd.bot/elevated)
-- [Cron + wakeups](https://docs.clawd.bot/cron)
+- [Cron jobs](https://docs.clawd.bot/cron-jobs)
- [Thinking + verbose](https://docs.clawd.bot/thinking)
- [Models](https://docs.clawd.bot/models)
- [Agent send CLI](https://docs.clawd.bot/agent-send)
diff --git a/docs/index.md b/docs/index.md
index 862aa4b39..f39dfcc4e 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -171,7 +171,7 @@ Example:
- [Linux app](https://docs.clawd.bot/linux)
- Ops and safety:
- [Sessions](https://docs.clawd.bot/session)
- - [Cron + wakeups](https://docs.clawd.bot/cron)
+ - [Cron jobs](https://docs.clawd.bot/cron-jobs)
- [Security](https://docs.clawd.bot/security)
- [Troubleshooting](https://docs.clawd.bot/troubleshooting)
diff --git a/docs/plans/cron-add-hardening.md b/docs/plans/cron-add-hardening.md
index 2ba67ea66..f3277e8e3 100644
--- a/docs/plans/cron-add-hardening.md
+++ b/docs/plans/cron-add-hardening.md
@@ -43,7 +43,7 @@ Recent gateway logs show repeated `cron.add` failures with invalid parameters (m
- [x] Fix UI CronStatus type to match gateway (`jobs` instead of `jobCount`).
- [x] Update cron UI provider select to include Discord/Slack/Signal/iMessage.
- [x] Update macOS CronJobEditor provider picker + enum to include Slack/Signal/iMessage.
-- [x] Document cron compatibility normalization policy in [`docs/cron.md`](https://docs.clawd.bot/cron).
+- [x] Document cron compatibility normalization policy in [`docs/cron-jobs.md`](https://docs.clawd.bot/cron-jobs).
### Phase 2 — Input normalization + tooling hardening
- [x] Add shared cron input normalization helpers (`normalizeCronJobCreate`/`normalizeCronJobPatch`).
diff --git a/docs/templates/AGENTS.md b/docs/templates/AGENTS.md
index 051f19c00..34dbd3465 100644
--- a/docs/templates/AGENTS.md
+++ b/docs/templates/AGENTS.md
@@ -120,7 +120,7 @@ When you receive a heartbeat poll (message matches the configured heartbeat prom
Default heartbeat prompt:
`Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`
-You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small.
+You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small to limit token burn.
**Things to check (rotate through these, 2-4 times per day):**
- **Emails** - Any urgent unread messages?
diff --git a/docs/templates/HEARTBEAT.md b/docs/templates/HEARTBEAT.md
index 4d300f421..45d86581f 100644
--- a/docs/templates/HEARTBEAT.md
+++ b/docs/templates/HEARTBEAT.md
@@ -5,4 +5,4 @@ read_when:
---
# HEARTBEAT.md
-Keep this file empty unless you want a tiny checklist for heartbeat runs. Keep it small.
+Keep this file empty unless you want a tiny checklist. Keep it small.
diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts
index a27dc7e5f..f29e97271 100644
--- a/src/agents/workspace.ts
+++ b/src/agents/workspace.ts
@@ -87,10 +87,9 @@ It does not define which tools exist; Clawdbot provides built-in tools internall
Add whatever else you want the assistant to know about your local toolchain.
`;
-const DEFAULT_HEARTBEAT_TEMPLATE = `# HEARTBEAT.md - Optional heartbeat notes
+const DEFAULT_HEARTBEAT_TEMPLATE = `# HEARTBEAT.md
-Keep this file small. Leave it empty unless you want a short checklist or reminders
-to follow during heartbeat runs.
+Keep this file empty unless you want a tiny checklist. Keep it small.
`;
const DEFAULT_BOOTSTRAP_TEMPLATE = `# BOOTSTRAP.md - First Run Ritual (delete after)
From 825a692390dcde5a26287172ee75e60cf4f9268c Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Tue, 6 Jan 2026 22:32:01 +0000
Subject: [PATCH 2/6] docs: add cron redirect
---
docs/docs.json | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/docs/docs.json b/docs/docs.json
index 8802512e9..0bd5a3eb2 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -20,6 +20,16 @@
"url": "https://github.com/clawdbot/clawdbot/releases"
}
],
+ "redirects": [
+ {
+ "source": "/cron",
+ "destination": "/cron-jobs"
+ },
+ {
+ "source": "/cron/",
+ "destination": "/cron-jobs"
+ }
+ ],
"navigation": {
"groups": [
{
From 5939363eed48bc741ac152c7c7b021a395d732a7 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Tue, 6 Jan 2026 22:34:02 +0000
Subject: [PATCH 3/6] fix: include telegram group sender in envelope headers
---
CHANGELOG.md | 1 +
src/telegram/bot.test.ts | 43 ++++++++++++++++++++++++++++++++++++++++
src/telegram/bot.ts | 32 +++++++++++++++++++++++++-----
3 files changed, 71 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 31acd0c15..5386ac4e2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,7 @@
- Browser: fix `browser snapshot`/`browser act` timeouts under Bun by patching Playwright’s CDP WebSocket selection. Thanks @azade-c for PR #307.
- Browser: add `--browser-profile` flag and honor profile in tabs routes + browser tool. Thanks @jamesgroat for PR #324.
- Telegram: stop typing after tool results. Thanks @AbhisekBasu1 for PR #322.
+- Telegram: include sender identity in group envelope headers. (#336)
- Messages: stop defaulting ack reactions to 👀 when identity emoji is missing.
- Auto-reply: require slash for control commands to avoid false triggers in normal text.
- Auto-reply: flag error payloads and improve Bun socket error messaging. Thanks @emanuelst for PR #331.
diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts
index e9553508e..99e6634c1 100644
--- a/src/telegram/bot.test.ts
+++ b/src/telegram/bot.test.ts
@@ -241,6 +241,49 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.WasMentioned).toBe(true);
+ expect(payload.Body).toMatch(
+ /^\[Telegram Test Group id:7 from Ada id:9 2025-01-09T00:00Z\]/,
+ );
+ });
+
+ it("includes sender identity in group envelope headers", async () => {
+ onSpy.mockReset();
+ const replySpy = replyModule.__replySpy as unknown as ReturnType<
+ typeof vi.fn
+ >;
+ replySpy.mockReset();
+
+ loadConfig.mockReturnValue({
+ telegram: { groups: { "*": { requireMention: false } } },
+ });
+
+ createTelegramBot({ token: "tok" });
+ const handler = onSpy.mock.calls[0][1] as (
+ ctx: Record,
+ ) => Promise;
+
+ await handler({
+ message: {
+ chat: { id: 42, type: "group", title: "Ops" },
+ text: "hello",
+ date: 1736380800,
+ message_id: 2,
+ from: {
+ id: 99,
+ first_name: "Ada",
+ last_name: "Lovelace",
+ username: "ada",
+ },
+ },
+ me: { username: "clawdbot_bot" },
+ getFile: async () => ({ download: async () => new Uint8Array() }),
+ });
+
+ expect(replySpy).toHaveBeenCalledTimes(1);
+ const payload = replySpy.mock.calls[0][0];
+ expect(payload.Body).toMatch(
+ /^\[Telegram Ops id:42 from Ada Lovelace \(@ada\) id:99 2025-01-09T00:00Z\]/,
+ );
});
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts
index 296f5b15c..650cae7b9 100644
--- a/src/telegram/bot.ts
+++ b/src/telegram/bot.ts
@@ -375,8 +375,8 @@ export function createTelegramBot(opts: TelegramBotOptions) {
const body = formatAgentEnvelope({
provider: "Telegram",
from: isGroup
- ? buildGroupLabel(msg, chatId)
- : buildSenderLabel(msg, chatId),
+ ? buildGroupFromLabel(msg, chatId, senderId)
+ : buildSenderLabel(msg, senderId || chatId),
timestamp: msg.date ? msg.date * 1000 : undefined,
body: `${bodyText}${replySuffix}`,
});
@@ -874,7 +874,10 @@ function buildSenderName(msg: TelegramMessage) {
return name || undefined;
}
-function buildSenderLabel(msg: TelegramMessage, chatId: number | string) {
+function buildSenderLabel(
+ msg: TelegramMessage,
+ senderId?: number | string,
+) {
const name = buildSenderName(msg);
const username = msg.from?.username ? `@${msg.from.username}` : undefined;
let label = name;
@@ -883,8 +886,17 @@ function buildSenderLabel(msg: TelegramMessage, chatId: number | string) {
} else if (!name && username) {
label = username;
}
- const idPart = `id:${chatId}`;
- return label ? `${label} ${idPart}` : idPart;
+ const normalizedSenderId =
+ senderId != null && `${senderId}`.trim()
+ ? `${senderId}`.trim()
+ : undefined;
+ const fallbackId =
+ normalizedSenderId ??
+ (msg.from?.id != null ? String(msg.from.id) : undefined);
+ const idPart = fallbackId ? `id:${fallbackId}` : undefined;
+ if (label && idPart) return `${label} ${idPart}`;
+ if (label) return label;
+ return idPart ?? "id:unknown";
}
function buildGroupLabel(msg: TelegramMessage, chatId: number | string) {
@@ -893,6 +905,16 @@ function buildGroupLabel(msg: TelegramMessage, chatId: number | string) {
return `group:${chatId}`;
}
+function buildGroupFromLabel(
+ msg: TelegramMessage,
+ chatId: number | string,
+ senderId?: number | string,
+) {
+ const groupLabel = buildGroupLabel(msg, chatId);
+ const senderLabel = buildSenderLabel(msg, senderId);
+ return `${groupLabel} from ${senderLabel}`;
+}
+
function hasBotMention(msg: TelegramMessage, botUsername: string) {
const text = (msg.text ?? msg.caption ?? "").toLowerCase();
if (text.includes(`@${botUsername}`)) return true;
From e6d6c822c57a80c365f49561741bd419ba121117 Mon Sep 17 00:00:00 2001
From: Dante Lex
Date: Tue, 6 Jan 2026 16:37:08 -0500
Subject: [PATCH 4/6] feat: add himalaya email CLI skill
Add skill for Himalaya (https://github.com/pimalaya/himalaya), a CLI
email client supporting IMAP, SMTP, Notmuch, and Sendmail backends.
Includes:
- SKILL.md with common operations (list, read, reply, forward, send)
- Configuration reference for Gmail, iCloud, and generic IMAP/SMTP
- MML (MIME Meta Language) composition guide for attachments
Tested with iCloud IMAP account - verified folder listing, email
reading, and sending work correctly.
---
skills/himalaya/SKILL.md | 225 ++++++++++++++++++
skills/himalaya/references/configuration.md | 175 ++++++++++++++
.../references/message-composition.md | 182 ++++++++++++++
3 files changed, 582 insertions(+)
create mode 100644 skills/himalaya/SKILL.md
create mode 100644 skills/himalaya/references/configuration.md
create mode 100644 skills/himalaya/references/message-composition.md
diff --git a/skills/himalaya/SKILL.md b/skills/himalaya/SKILL.md
new file mode 100644
index 000000000..452aadc8c
--- /dev/null
+++ b/skills/himalaya/SKILL.md
@@ -0,0 +1,225 @@
+---
+name: himalaya
+description: "CLI to manage emails via IMAP/SMTP. Use `himalaya` to list, read, write, reply, forward, search, and organize emails from the terminal. Supports multiple accounts and message composition with MML (MIME Meta Language)."
+homepage: https://github.com/pimalaya/himalaya
+metadata: {"clawdbot":{"emoji":"📧","requires":{"bins":["himalaya"]},"install":[{"id":"brew","kind":"brew","formula":"himalaya","bins":["himalaya"],"label":"Install Himalaya (brew)"},{"id":"cargo","kind":"shell","cmd":"cargo install himalaya","bins":["himalaya"],"label":"Install Himalaya (cargo)"}]}}
+---
+
+# Himalaya Email CLI
+
+Himalaya is a CLI email client that lets you manage emails from the terminal using IMAP, SMTP, Notmuch, or Sendmail backends.
+
+## References
+
+- `references/configuration.md` (config file setup + IMAP/SMTP authentication)
+- `references/message-composition.md` (MML syntax for composing emails)
+
+## Prerequisites
+
+1. Himalaya CLI installed (`himalaya --version` to verify)
+2. A configuration file at `~/.config/himalaya/config.toml`
+3. IMAP/SMTP credentials configured (password stored securely)
+
+## Configuration Setup
+
+Run the interactive wizard to set up an account:
+```bash
+himalaya account configure
+```
+
+Or create `~/.config/himalaya/config.toml` manually:
+```toml
+[accounts.personal]
+email = "you@example.com"
+display-name = "Your Name"
+default = true
+
+backend.type = "imap"
+backend.host = "imap.example.com"
+backend.port = 993
+backend.encryption.type = "tls"
+backend.login = "you@example.com"
+backend.auth.type = "password"
+backend.auth.cmd = "pass show email/imap" # or use keyring
+
+message.send.backend.type = "smtp"
+message.send.backend.host = "smtp.example.com"
+message.send.backend.port = 587
+message.send.backend.encryption.type = "start-tls"
+message.send.backend.login = "you@example.com"
+message.send.backend.auth.type = "password"
+message.send.backend.auth.cmd = "pass show email/smtp"
+```
+
+## Common Operations
+
+### List Folders
+
+```bash
+himalaya folder list
+```
+
+### List Emails
+
+List emails in INBOX (default):
+```bash
+himalaya envelope list
+```
+
+List emails in a specific folder:
+```bash
+himalaya envelope list --folder "Sent"
+```
+
+List with pagination:
+```bash
+himalaya envelope list --page 1 --page-size 20
+```
+
+### Search Emails
+
+```bash
+himalaya envelope list --query "from:john@example.com subject:meeting"
+```
+
+### Read an Email
+
+Read email by ID (shows plain text):
+```bash
+himalaya message read 42
+```
+
+Read as raw MIME:
+```bash
+himalaya message read 42 --raw
+```
+
+### Reply to an Email
+
+Interactive reply (opens $EDITOR):
+```bash
+himalaya message reply 42
+```
+
+Reply-all:
+```bash
+himalaya message reply 42 --all
+```
+
+### Forward an Email
+
+```bash
+himalaya message forward 42
+```
+
+### Write a New Email
+
+Interactive compose (opens $EDITOR):
+```bash
+himalaya message write
+```
+
+Send directly using template:
+```bash
+cat << 'EOF' | himalaya template send
+From: you@example.com
+To: recipient@example.com
+Subject: Test Message
+
+Hello from Himalaya!
+EOF
+```
+
+Or with headers flag:
+```bash
+himalaya message write -H "To:recipient@example.com" -H "Subject:Test" "Message body here"
+```
+
+### Move/Copy Emails
+
+Move to folder:
+```bash
+himalaya message move 42 "Archive"
+```
+
+Copy to folder:
+```bash
+himalaya message copy 42 "Important"
+```
+
+### Delete an Email
+
+```bash
+himalaya message delete 42
+```
+
+### Manage Flags
+
+Add flag:
+```bash
+himalaya flag add 42 --flag seen
+```
+
+Remove flag:
+```bash
+himalaya flag remove 42 --flag seen
+```
+
+## Multiple Accounts
+
+List accounts:
+```bash
+himalaya account list
+```
+
+Use a specific account:
+```bash
+himalaya --account work envelope list
+```
+
+## Attachments
+
+Save attachments from a message:
+```bash
+himalaya attachment download 42
+```
+
+Save to specific directory:
+```bash
+himalaya attachment download 42 --dir ~/Downloads
+```
+
+## Output Formats
+
+Most commands support `--output` for structured output:
+```bash
+himalaya envelope list --output json
+himalaya envelope list --output plain
+```
+
+## Sync Mode
+
+For faster access, enable local caching:
+```bash
+himalaya account sync personal
+```
+
+## Debugging
+
+Enable debug logging:
+```bash
+RUST_LOG=debug himalaya envelope list
+```
+
+Full trace with backtrace:
+```bash
+RUST_LOG=trace RUST_BACKTRACE=1 himalaya envelope list
+```
+
+## Tips
+
+- Use `himalaya --help` or `himalaya --help` for detailed usage.
+- Message IDs are relative to the current folder; re-list after folder changes.
+- For composing rich emails with attachments, use MML syntax (see `references/message-composition.md`).
+- Store passwords securely using `pass`, system keyring, or a command that outputs the password.
+
diff --git a/skills/himalaya/references/configuration.md b/skills/himalaya/references/configuration.md
new file mode 100644
index 000000000..42c252628
--- /dev/null
+++ b/skills/himalaya/references/configuration.md
@@ -0,0 +1,175 @@
+# Himalaya Configuration Reference
+
+Configuration file location: `~/.config/himalaya/config.toml`
+
+## Minimal IMAP + SMTP Setup
+
+```toml
+[accounts.default]
+email = "user@example.com"
+display-name = "Your Name"
+default = true
+
+# IMAP backend for reading emails
+backend.type = "imap"
+backend.host = "imap.example.com"
+backend.port = 993
+backend.encryption.type = "tls"
+backend.login = "user@example.com"
+backend.auth.type = "password"
+backend.auth.raw = "your-password"
+
+# SMTP backend for sending emails
+message.send.backend.type = "smtp"
+message.send.backend.host = "smtp.example.com"
+message.send.backend.port = 587
+message.send.backend.encryption.type = "start-tls"
+message.send.backend.login = "user@example.com"
+message.send.backend.auth.type = "password"
+message.send.backend.auth.raw = "your-password"
+```
+
+## Password Options
+
+### Raw password (testing only, not recommended)
+```toml
+backend.auth.raw = "your-password"
+```
+
+### Password from command (recommended)
+```toml
+backend.auth.cmd = "pass show email/imap"
+backend.auth.cmd = "security find-generic-password -a user@example.com -s imap -w"
+```
+
+### System keyring (requires keyring feature)
+```toml
+backend.auth.keyring = "imap-example"
+```
+Then run `himalaya configure -a ` to store the password.
+
+## Gmail Configuration
+
+```toml
+[accounts.gmail]
+email = "you@gmail.com"
+display-name = "Your Name"
+default = true
+
+backend.type = "imap"
+backend.host = "imap.gmail.com"
+backend.port = 993
+backend.encryption.type = "tls"
+backend.login = "you@gmail.com"
+backend.auth.type = "password"
+backend.auth.cmd = "pass show google/app-password"
+
+message.send.backend.type = "smtp"
+message.send.backend.host = "smtp.gmail.com"
+message.send.backend.port = 587
+message.send.backend.encryption.type = "start-tls"
+message.send.backend.login = "you@gmail.com"
+message.send.backend.auth.type = "password"
+message.send.backend.auth.cmd = "pass show google/app-password"
+```
+
+**Note:** Gmail requires an App Password if 2FA is enabled.
+
+## iCloud Configuration
+
+```toml
+[accounts.icloud]
+email = "you@icloud.com"
+display-name = "Your Name"
+
+backend.type = "imap"
+backend.host = "imap.mail.me.com"
+backend.port = 993
+backend.encryption.type = "tls"
+backend.login = "you@icloud.com"
+backend.auth.type = "password"
+backend.auth.cmd = "pass show icloud/app-password"
+
+message.send.backend.type = "smtp"
+message.send.backend.host = "smtp.mail.me.com"
+message.send.backend.port = 587
+message.send.backend.encryption.type = "start-tls"
+message.send.backend.login = "you@icloud.com"
+message.send.backend.auth.type = "password"
+message.send.backend.auth.cmd = "pass show icloud/app-password"
+```
+
+**Note:** Generate an app-specific password at appleid.apple.com
+
+## Folder Aliases
+
+Map custom folder names:
+```toml
+[accounts.default.folder.alias]
+inbox = "INBOX"
+sent = "Sent"
+drafts = "Drafts"
+trash = "Trash"
+```
+
+## Multiple Accounts
+
+```toml
+[accounts.personal]
+email = "personal@example.com"
+default = true
+# ... backend config ...
+
+[accounts.work]
+email = "work@company.com"
+# ... backend config ...
+```
+
+Switch accounts with `--account`:
+```bash
+himalaya --account work envelope list
+```
+
+## Notmuch Backend (local mail)
+
+```toml
+[accounts.local]
+email = "user@example.com"
+
+backend.type = "notmuch"
+backend.db-path = "~/.mail/.notmuch"
+```
+
+## OAuth2 Authentication (for providers that support it)
+
+```toml
+backend.auth.type = "oauth2"
+backend.auth.client-id = "your-client-id"
+backend.auth.client-secret.cmd = "pass show oauth/client-secret"
+backend.auth.access-token.cmd = "pass show oauth/access-token"
+backend.auth.refresh-token.cmd = "pass show oauth/refresh-token"
+backend.auth.auth-url = "https://provider.com/oauth/authorize"
+backend.auth.token-url = "https://provider.com/oauth/token"
+```
+
+## Additional Options
+
+### Signature
+```toml
+[accounts.default]
+signature = "Best regards,\nYour Name"
+signature-delim = "-- \n"
+```
+
+### Downloads directory
+```toml
+[accounts.default]
+downloads-dir = "~/Downloads/himalaya"
+```
+
+### Editor for composing
+Set via environment variable:
+```bash
+export EDITOR="vim"
+```
+
diff --git a/skills/himalaya/references/message-composition.md b/skills/himalaya/references/message-composition.md
new file mode 100644
index 000000000..9e22c1eec
--- /dev/null
+++ b/skills/himalaya/references/message-composition.md
@@ -0,0 +1,182 @@
+# Message Composition with MML (MIME Meta Language)
+
+Himalaya uses MML for composing emails. MML is a simple XML-based syntax that compiles to MIME messages.
+
+## Basic Message Structure
+
+An email message is a list of **headers** followed by a **body**, separated by a blank line:
+
+```
+From: sender@example.com
+To: recipient@example.com
+Subject: Hello World
+
+This is the message body.
+```
+
+## Headers
+
+Common headers:
+- `From`: Sender address
+- `To`: Primary recipient(s)
+- `Cc`: Carbon copy recipients
+- `Bcc`: Blind carbon copy recipients
+- `Subject`: Message subject
+- `Reply-To`: Address for replies (if different from From)
+- `In-Reply-To`: Message ID being replied to
+
+### Address Formats
+
+```
+To: user@example.com
+To: John Doe
+To: "John Doe"
+To: user1@example.com, user2@example.com, "Jane"
+```
+
+## Plain Text Body
+
+Simple plain text email:
+```
+From: alice@localhost
+To: bob@localhost
+Subject: Plain Text Example
+
+Hello, this is a plain text email.
+No special formatting needed.
+
+Best,
+Alice
+```
+
+## MML for Rich Emails
+
+### Multipart Messages
+
+Alternative text/html parts:
+```
+From: alice@localhost
+To: bob@localhost
+Subject: Multipart Example
+
+<#multipart type=alternative>
+This is the plain text version.
+<#part type=text/html>
+This is the HTML version
+<#/multipart>
+```
+
+### Attachments
+
+Attach a file:
+```
+From: alice@localhost
+To: bob@localhost
+Subject: With Attachment
+
+Here is the document you requested.
+
+<#part filename=/path/to/document.pdf><#/part>
+```
+
+Attachment with custom name:
+```
+<#part filename=/path/to/file.pdf name=report.pdf><#/part>
+```
+
+Multiple attachments:
+```
+<#part filename=/path/to/doc1.pdf><#/part>
+<#part filename=/path/to/doc2.pdf><#/part>
+```
+
+### Inline Images
+
+Embed an image inline:
+```
+From: alice@localhost
+To: bob@localhost
+Subject: Inline Image
+
+<#multipart type=related>
+<#part type=text/html>
+
+Check out this image:
+
+
+<#part disposition=inline id=image1 filename=/path/to/image.png><#/part>
+<#/multipart>
+```
+
+### Mixed Content (Text + Attachments)
+
+```
+From: alice@localhost
+To: bob@localhost
+Subject: Mixed Content
+
+<#multipart type=mixed>
+<#part type=text/plain>
+Please find the attached files.
+
+Best,
+Alice
+<#part filename=/path/to/file1.pdf><#/part>
+<#part filename=/path/to/file2.zip><#/part>
+<#/multipart>
+```
+
+## MML Tag Reference
+
+### `<#multipart>`
+Groups multiple parts together.
+- `type=alternative`: Different representations of same content
+- `type=mixed`: Independent parts (text + attachments)
+- `type=related`: Parts that reference each other (HTML + images)
+
+### `<#part>`
+Defines a message part.
+- `type=`: Content type (e.g., `text/html`, `application/pdf`)
+- `filename=`: File to attach
+- `name=`: Display name for attachment
+- `disposition=inline`: Display inline instead of as attachment
+- `id=`: Content ID for referencing in HTML
+
+## Composing from CLI
+
+### Interactive compose
+Opens your `$EDITOR`:
+```bash
+himalaya message write
+```
+
+### Reply (opens editor with quoted message)
+```bash
+himalaya message reply 42
+himalaya message reply 42 --all # reply-all
+```
+
+### Forward
+```bash
+himalaya message forward 42
+```
+
+### Send from stdin
+```bash
+cat message.txt | himalaya message write
+```
+
+### Send with headers from CLI
+```bash
+echo "Message body here" | himalaya message write \
+ --to "recipient@example.com" \
+ --subject "Quick Message"
+```
+
+## Tips
+
+- The editor opens with a template; fill in headers and body.
+- Save and exit the editor to send; exit without saving to cancel.
+- MML parts are compiled to proper MIME when sending.
+- Use `--raw` with `message read` to see the raw MIME structure of received emails.
+
From 16243b7edcc0d6ed4b28b7f9b13bb1d1c490f40a Mon Sep 17 00:00:00 2001
From: Dante Lex
Date: Tue, 6 Jan 2026 17:03:12 -0500
Subject: [PATCH 5/6] fix: simplify install to brew-only
Remove cargo install option to avoid confusing the model with
multiple installation methods.
---
skills/himalaya/SKILL.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/skills/himalaya/SKILL.md b/skills/himalaya/SKILL.md
index 452aadc8c..4dfc68ab4 100644
--- a/skills/himalaya/SKILL.md
+++ b/skills/himalaya/SKILL.md
@@ -2,7 +2,7 @@
name: himalaya
description: "CLI to manage emails via IMAP/SMTP. Use `himalaya` to list, read, write, reply, forward, search, and organize emails from the terminal. Supports multiple accounts and message composition with MML (MIME Meta Language)."
homepage: https://github.com/pimalaya/himalaya
-metadata: {"clawdbot":{"emoji":"📧","requires":{"bins":["himalaya"]},"install":[{"id":"brew","kind":"brew","formula":"himalaya","bins":["himalaya"],"label":"Install Himalaya (brew)"},{"id":"cargo","kind":"shell","cmd":"cargo install himalaya","bins":["himalaya"],"label":"Install Himalaya (cargo)"}]}}
+metadata: {"clawdbot":{"emoji":"📧","requires":{"bins":["himalaya"]},"install":[{"id":"brew","kind":"brew","formula":"himalaya","bins":["himalaya"],"label":"Install Himalaya (brew)"}]}}
---
# Himalaya Email CLI
From ea216994a1166f17b22037e8eb95607e1f1927c8 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Tue, 6 Jan 2026 23:44:06 +0100
Subject: [PATCH 6/6] docs: polish himalaya skill docs
---
CHANGELOG.md | 1 +
README.md | 2 +-
skills/himalaya/SKILL.md | 7 +++----
skills/himalaya/references/configuration.md | 5 ++---
skills/himalaya/references/message-composition.md | 14 +++++++-------
5 files changed, 14 insertions(+), 15 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5386ac4e2..5b3865e19 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -117,6 +117,7 @@
- Models: extend `clawdbot models` status output with a masked auth overview (profiles, env sources, and OAuth counts).
### Maintenance
+- Skills: add Himalaya email CLI skill. Thanks @dantelex for PR #335.
- Agent: add `skipBootstrap` config option. Thanks @onutc for PR #292.
- UI: add favicon.ico derived from the macOS app icon. Thanks @jeffersonwarrior for PR #305.
- Tooling: replace tsx with bun for TypeScript execution. Thanks @obviyus for PR #278.
diff --git a/README.md b/README.md
index 23d1827b9..b32dbac49 100644
--- a/README.md
+++ b/README.md
@@ -453,5 +453,5 @@ Thanks to all clawtributors:
-
+
diff --git a/skills/himalaya/SKILL.md b/skills/himalaya/SKILL.md
index 4dfc68ab4..5823e31b1 100644
--- a/skills/himalaya/SKILL.md
+++ b/skills/himalaya/SKILL.md
@@ -79,7 +79,7 @@ himalaya envelope list --page 1 --page-size 20
### Search Emails
```bash
-himalaya envelope list --query "from:john@example.com subject:meeting"
+himalaya envelope list --query "from john@example.com subject meeting"
```
### Read an Email
@@ -89,9 +89,9 @@ Read email by ID (shows plain text):
himalaya message read 42
```
-Read as raw MIME:
+Export raw MIME:
```bash
-himalaya message read 42 --raw
+himalaya message export 42 --full
```
### Reply to an Email
@@ -222,4 +222,3 @@ RUST_LOG=trace RUST_BACKTRACE=1 himalaya envelope list
- Message IDs are relative to the current folder; re-list after folder changes.
- For composing rich emails with attachments, use MML syntax (see `references/message-composition.md`).
- Store passwords securely using `pass`, system keyring, or a command that outputs the password.
-
diff --git a/skills/himalaya/references/configuration.md b/skills/himalaya/references/configuration.md
index 42c252628..015049203 100644
--- a/skills/himalaya/references/configuration.md
+++ b/skills/himalaya/references/configuration.md
@@ -39,14 +39,14 @@ backend.auth.raw = "your-password"
### Password from command (recommended)
```toml
backend.auth.cmd = "pass show email/imap"
-backend.auth.cmd = "security find-generic-password -a user@example.com -s imap -w"
+# backend.auth.cmd = "security find-generic-password -a user@example.com -s imap -w"
```
### System keyring (requires keyring feature)
```toml
backend.auth.keyring = "imap-example"
```
-Then run `himalaya configure -a ` to store the password.
+Then run `himalaya account configure ` to store the password.
## Gmail Configuration
@@ -172,4 +172,3 @@ Set via environment variable:
```bash
export EDITOR="vim"
```
-
diff --git a/skills/himalaya/references/message-composition.md b/skills/himalaya/references/message-composition.md
index 9e22c1eec..17e40ef37 100644
--- a/skills/himalaya/references/message-composition.md
+++ b/skills/himalaya/references/message-composition.md
@@ -163,14 +163,15 @@ himalaya message forward 42
### Send from stdin
```bash
-cat message.txt | himalaya message write
+cat message.txt | himalaya template send
```
-### Send with headers from CLI
+### Prefill headers from CLI
```bash
-echo "Message body here" | himalaya message write \
- --to "recipient@example.com" \
- --subject "Quick Message"
+himalaya message write \
+ -H "To:recipient@example.com" \
+ -H "Subject:Quick Message" \
+ "Message body here"
```
## Tips
@@ -178,5 +179,4 @@ echo "Message body here" | himalaya message write \
- The editor opens with a template; fill in headers and body.
- Save and exit the editor to send; exit without saving to cancel.
- MML parts are compiled to proper MIME when sending.
-- Use `--raw` with `message read` to see the raw MIME structure of received emails.
-
+- Use `himalaya message export --full` to inspect the raw MIME structure of received emails.