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.
- 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 givesU…(CHAT_DEST); the self-DM URL's last path segment givesD…(CHAT_DEST_ALT). - Which channels to summarize — ids + names, e.g.
C123 (#team-dev). - 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.
- Optional sources:
LINEAR_TEAMS(one or more keys, comma-separated) andFORGE(gitlab/github, CLI must be authed). Leave empty to omit those sections entirely.FORGE_ORGSis only for teammates' changes — the user's own and their review requests are found without it. - 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:
--permission-mode manualon everyclaude -pcall. Required: a globaldefaultMode: "auto"classifier will otherwise approve tools that were never in--allowedTools.guard.sh(PreToolUse hook, matcher.*) blocks any send whose destination isn'tCHAT_DEST/CHAT_DEST_ALT, blocks every tool from the chat's MCP server except the three named inconfig.sh, and makesglabandghread-only (no-X/-f/-F/--method/--field, no write subcommands). Fails closed, and reads the tool names fromconfig.shso it stays correct when the chat platform changes.settings.jsondeny 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_channeltakeU…;slack_add_reactionrejects it and needs theD…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 barePATH(no mise/node shims). Set both explicitly at the top of any script. CLAUDE_CONFIG_DIR=$HOME/.claudeis NOT the default login. The default profile's config is~/.claude.json; pointingCLAUDE_CONFIG_DIRat~/.claudemakes Claude read~/.claude/.claude.json— a fresh, never logged-in profile, so the run dies withNot logged in · Please run /login. LeaveCONFIG_DIRempty for the default login; only set it to a genuine alt profile dir. Check withjq .oauthAccount.emailAddress <dir>/.claude.json. EmptyCONFIG_DIRmustunset 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.
ToolSearchselect:slack_send_messagefinds nothing;select:mcp__claude_ai_Slack__slack_send_messagefinds 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 whyconfig.shholds 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(omitUSER/LOGNAME/TMPDIRand you get a misleadingNot logged in). grep -q+set -o pipefailis a false negative.-qexits on first match, the writer dies on SIGPIPE, and pipefail propagates that as failure — solaunchctl list | grep -q Xreports "not loaded" for a loaded job. Usegrep -cand test the count.- zsh globbing: a no-match glob is a fatal error unless
setopt null_glob; and with null_glob,ls "$DIR"/*.mdsilently becomes a barelsof the cwd — count with an array (A=("$DIR"/*.md); echo ${#A}) instead of piping towc. - 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 whydigest-action.shlives 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 bootstrapregistration 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
jqcosts nothing) and spawn Claude only once a trigger is found.app-manifest.yamlhere 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.
