platform/morning-digest/SKILL.md

name: morning-digest description: Set up, operate, or modify a "morning digest" — a launchd job that runs headless Claude to summarize a team chat (Slack, Discord, any chat with an MCP server) plus optional Linear and a code forge (GitLab or GitHub), and DM the summary to the user themselves, with a SwiftBar menu bar UI. Use when asked to install/scaffold a daily digest or standup summary, turn one on/off, run one now, change its schedule or content, debug a missed or failed digest, or edit its menu bar icon/menu. Also covers the security guard that confines a headless agent to messaging only its owner.

morning-digest

A launchd job runs claude -p headless; it reads a chat (+ optional Linear and a code forge), sends one DM to the user themselves, and archives the text locally. A SwiftBar plugin shows status, the archive by day, and run/on/off actions.

Nothing in the scripts names a platform. The chat is three tool names in config.sh; the forge is FORGE=gitlab|github. See Swapping the chat or the forge.

Code lives in this skill; state lives in ~/.digest/. install.sh symlinks ~/.digest/{digest,guard,digest-action}.sh back here, so scripts keep their $HOME/.digest/... paths and the plist never needs editing. Never commit a user's real config.sh — it holds their chat ids and lives only in ~/.digest.

Installing for a new user

Ask these before touching anything. Only the first three are required; everything else has a working default.

  1. Where the digest gets delivered (CHAT_DEST). On Slack that is the member id and the self-DM channel id, which differ and are not interchangeable: profile → Copy member ID gives U… (CHAT_DEST); the self-DM URL's last path segment gives D… (CHAT_DEST_ALT).
  2. Which channels to summarize — ids + names, e.g. C123 (#team-dev).
  3. How they want to be described in the digest (name, email, git handle) — the model uses it to spot mentions and things blocked on them.
  4. Optional sources: LINEAR_TEAMS (one or more keys, comma-separated) and FORGE (gitlab/github, CLI must be authed). Leave empty to omit those sections entirely. FORGE_ORGS is only for teammates' changes — the user's own and their review requests are found without it.
  5. Schedule (default 08:30) and which Claude login runs it (default ~/.claude).

Then: install.sh (creates ~/.digest/config.sh from the template on first run, so run it, fill in answers, run again). install.sh --status reports what is and isn't wired; install.sh --uninstall removes the job and symlinks but keeps config, archive, and logs.

Prerequisites worth checking before promising it works: the Claude login used by launchd must have the chat connector connected on claude.ai (a login without it produces a digest that can't send), and SwiftBar is optional (brew install --cask swiftbar) — the digest works headless without it.

Operating it

~/.digest/digest.sh --preview       # print the prompt, send nothing
~/.digest/digest-action.sh run      # run now, with notifications
launchctl list | grep digest        # the truth for on/off

It's a calendar job: a Mac asleep at the scheduled time runs the digest on wake, not never. Script edits take effect on the next run; plist edits need launchctl bootout + bootstrap (just re-run install.sh).

Reading the digest from another agent

The archive is the read API. No model, no network — an interactive agent can pull the morning's summary and reason over it with the user:

~/.digest/digest-action.sh latest         # newest digest, with a "Nd old" header
~/.digest/digest-action.sh latest <date>  # a specific YYYY-MM-DD
~/.digest/digest-action.sh list           # what's archived, newest first

Exit 1 with a message on stderr when nothing is archived yet (fresh install, or the first run hasn't happened) — treat that as "no data", not an error to debug. latest skips failed runs when a good one exists; list shows both, with failures suffixed .failed.

Pull, don't push. Keep the scheduled job one-way: it reads chat and DMs the user, and knows nothing about agents or panes. When a lead/orchestrator agent wants the outside-world picture (e.g. combining it with a herdr census to suggest next steps), it fetches the digest in an interactive session where the user can steer. Do not give the headless job access to the herdr CLI — those commands mutate other people's agent sessions, and a launchd-spawned agent has no business holding them.

The security boundary — do not weaken

A headless agent reading chat is processing untrusted text, so confinement is enforced at the tool layer, not by prompting. Three layers; keep all three:

  1. --permission-mode manual on every claude -p call. Required: a global defaultMode: "auto" classifier will otherwise approve tools that were never in --allowedTools.
  2. guard.sh (PreToolUse hook, matcher .*) blocks any send whose destination isn't CHAT_DEST/CHAT_DEST_ALT, blocks every tool from the chat's MCP server except the three named in config.sh, and makes glab and gh read-only (no -X/-f/-F/--method/--field, no write subcommands). Fails closed, and reads the tool names from config.sh so it stays correct when the chat platform changes.
  3. settings.json deny list covers other outbound surfaces (Gmail, Drive, Calendar, Atlassian, Figma, WebFetch/WebSearch, Linear writes).

Test it — exit 2 means blocked:

echo '{"tool_name":"mcp__claude_ai_Slack__slack_send_message","tool_input":{"channel_id":"C_SOMEONE_ELSE"}}' | ~/.digest/guard.sh; echo $?

Gotchas that cost real time

  • (Slack) Two id forms, not interchangeable. slack_send_message / slack_read_channel take U…; slack_add_reaction rejects it and needs the D… DM channel id. Reactions fail silently-ish if you pass the wrong one.
  • launchd gets a clean environment. No CLAUDE_CONFIG_DIR (so headless runs use the default login, which may not be the interactive one) and a bare PATH (no mise/node shims). Set both explicitly at the top of any script.
  • CLAUDE_CONFIG_DIR=$HOME/.claude is NOT the default login. The default profile's config is ~/.claude.json; pointing CLAUDE_CONFIG_DIR at ~/.claude makes Claude read ~/.claude/.claude.json — a fresh, never logged-in profile, so the run dies with Not logged in · Please run /login. Leave CONFIG_DIR empty for the default login; only set it to a genuine alt profile dir. Check with jq .oauthAccount.emailAddress <dir>/.claude.json. Empty CONFIG_DIR must unset CLAUDE_CONFIG_DIR, not just skip the export — a run launched from an interactive Claude Code session inherits that session's profile, and a personal profile has no work Slack connector.
  • In headless runs MCP tools are deferred — the prompt must name them in full. ToolSearch select:slack_send_message finds nothing; select:mcp__claude_ai_Slack__slack_send_message finds it. Told the bare name, the agent concludes the chat is unavailable, writes a digest it cannot send, and exits 0 — so the archive gets a digest that was never DM'd. This is why config.sh holds full tool names and the prompt interpolates them.
  • A locally-authorized MCP server's OAuth token expires per Claude profile, and headless cannot re-auth. The job then reports "needs authentication" for a server that looks ✔ Connected in your terminal, because the terminal is a different profile. The section goes quiet, and the digest reports it as unread rather than empty only if the prompt says so. Prefer an account-level claude.ai connector over a locally-added server for anything the scheduled job needs: same tools, no local token to expire. Reproduce the job's view with env -i HOME=… USER=… LOGNAME=… TMPDIR=… PATH=… claude mcp list (omit USER/LOGNAME/TMPDIR and you get a misleading Not logged in).
  • grep -q + set -o pipefail is a false negative. -q exits on first match, the writer dies on SIGPIPE, and pipefail propagates that as failure — so launchctl list | grep -q X reports "not loaded" for a loaded job. Use grep -c and test the count.
  • zsh globbing: a no-match glob is a fatal error unless setopt null_glob; and with null_glob, ls "$DIR"/*.md silently becomes a bare ls of the cwd — count with an array (A=("$DIR"/*.md); echo ${#A}) instead of piping to wc.
  • zsh negative slices: ${A[-14,-1]} on a shorter array yields empty, not a clamped slice. Reverse (${(Oa)A}) then take [1,14].
  • SwiftBar treats every file in its plugin dir as a plugin. A helper script there renders as a broken ? menu bar item — keep the plugin folder to plugins only (that's why digest-action.sh lives one level up).
  • Validate an SF Symbol before shipping it — a bad sfimage= name renders as a blank menu bar item, not an error: swift -e 'import AppKit; print(NSImage(systemSymbolName:"sun.horizon",accessibilityDescription:nil) != nil)'
  • launchctl bootstrap registration can lag a beat; a status check immediately after can read stale. Re-check before concluding it failed.

Swapping the chat or the forge

Both are config, not code. The chat needs three tools — read a channel, read a thread, send a message — and a destination id:

CHAT_NAME="Discord"
CHAT_READ_CHANNEL="mcp__discord__read_messages"
CHAT_READ_THREAD="mcp__discord__read_thread"
CHAT_SEND="mcp__discord__send_message"
CHAT_DEST="<your user id>"; CHAT_DEST_ALT=""

guard.sh derives the server prefix from CHAT_SEND (${CHAT_SEND%__*}__), so the confinement follows automatically — no second place to edit.

The forge is FORGE="gitlab" or "github"; each has one function in digest.sh and both answer the same three questions from the authenticated user, so neither needs a repo list. They differ where the APIs do: GitLab MRs carry target_branch, so pipelines are queried on the branch each MR targets; GitHub's PR search has no base-branch field, so github_facts asks each repo for its default_branch (a quarter of a typical personal account is still master — don't hardcode main).

Cost — the thing that kills these designs

Polling with an LLM is the expensive part. A headless claude -p call costs roughly $0.03–0.04 even for a trivial prompt, because each invocation reloads the system prompt, project instructions, and MCP schemas (~69k tokens, mostly cache reads). A 30s poll is thousands of calls a day; a once-daily digest is fine.

So: never use a model to detect whether there's work. If a trigger-watching variant is ever wanted, poll with the platform's own API (conversations.history

  • jq costs nothing) and spawn Claude only once a trigger is found. app-manifest.yaml here is a minimal read-only Slack app (im:history) for exactly that.

A two-way variant is a separate decision from a cost one: letting an agent take instructions from a DM is a different product than a summary that arrives each morning, and some users deliberately want the digest to stay one-way. Treat "should this talk back?" as the user's call, recorded per-machine — not as an obvious upgrade to offer.

Before assuming a connector can't be driven from a script, probe it: claude.ai's Slack connector has no local URL or credentials, but mcp.slack.com is a real MCP endpoint advertising OAuth metadata at /.well-known/oauth-protected-resource — it just lacks dynamic client registration, so you need your own Slack app either way.