plugins/claude-code-hermit/skills/channel-responder/SKILL.md
Handles inbound messages from Claude Code Channels (Telegram, Discord, webhooks) with session context awareness.
npx skillsauth add gtapps/claude-code-hermit channel-responderInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
When a message arrives via a channel:
ALL responses to messages wrapped in <channel source="..." chat_id="..." ...>
MUST be delivered via the channel's reply tool, not the terminal/transcript.
Terminal output is invisible to the operator: they read Discord, Telegram, or
the configured channel, never the raw transcript.
For each channel plugin, the reply tool is the reply action exposed by the
channel's MCP server, named mcp__plugin_<plugin-name>_<server-name>__reply —
the two segments are exactly the plugin name and server name the harness puts in
the plugin-qualified source on the wire (source="plugin:<plugin-name>:<server-name>").
For the built-in channels the two coincide (e.g. plugin:discord:discord →
mcp__plugin_discord_discord__reply), but a custom channel plugin whose names
differ fills each slot from its own wire segment (e.g. plugin:acme-crm:crm →
mcp__plugin_acme-crm_crm__reply) — build the tool name from the raw source,
not by doubling one segment. Every config.channels key below instead uses the
normalized bare server name (discord, not the qualified string — see
lib/channel-envelope.ts's normalizeChannelSource). Pass the
inbound chat_id back. Optionally pass reply_to (the inbound message_id)
to thread under the operator's message.
Terminal output is acceptable as a SECONDARY surface (tool-call narration, status visible only to a maintainer at the box). The substantive response, the one the operator needs to see, must go through the channel.
Read .claude-code-hermit/sessions/SHELL.md for current task context.
Read state/runtime.json for lifecycle state (session_state is the source of truth — never parse SHELL.md Status: for decisions).
If runtime.json session_state is idle (no active task):
If runtime.json session_state is waiting (alive but blocked on input):
Read waiting_reason from runtime.json to understand why:
"unclean_shutdown" or "dead_process" → operator reply is an archive/resume choice:
(1) archive as partial and start fresh: pipe Status: partial\nBlockers: none\nClosed Via: operator\n on stdin to bun ${CLAUDE_PLUGIN_ROOT}/scripts/session-archive.ts archive --mode=close --state-dir=.claude-code-hermit. On ok === true, clear waiting_reason and last_error in runtime.json.(2) resume as-is: run bun ${CLAUDE_PLUGIN_ROOT}/scripts/session-archive.ts open --state-dir=.claude-code-hermit with an empty Task: payload (SHELL.md's existing Task is left untouched) to set session_state back to in_progress; then clear waiting_reason and last_error in runtime.json.ok === false, surface the reason to the operator rather than silently proceeding as if the transition completed."operator_input", "conservative_pickup", or null → treat as normal task resumption.
Status request → respond with current context, stay waiting
New instruction or answer to a question → update runtime.json session_state to in_progress, clear waiting_reason to null, resume work
Anything else → respond, stay waiting
If a shutdown is pending (shutdown_requested_at set, shutdown_completed_at null) and the shutdown-gate hook did not already intercept, reply that shutdown is in progress and start no new work.
Read config.json → channels.<channel>.allowed_users for the inbound channel
(<channel> is the normalized bare key per §0 — e.g. discord, not
plugin:discord:discord):
allowed_users list: ignore the message silently — do not respond, do not log. Applies to ALL message types including status requests.allowed_users is absent for this channel: accept all messages (backwards compatible)allowed_users is an empty array []: accept from no one (explicit lockdown)The allowlist is per-channel inside the channels object in config.json:
{
"channels": {
"discord": { "enabled": true, "allowed_users": ["user-id-1"] },
"telegram": { "enabled": true, "allowed_users": ["user-id-1"] }
}
}
After authorization passes, run:
bun ${CLAUDE_PLUGIN_ROOT}/scripts/record-operator-action.ts --force
This writes state/last-operator-action.json with the current timestamp, resetting the AUTO_CLOSE quiet window (used by both the 12h-inactivity trigger and the daily-midnight lull drain). It also opens state/operator-turn-open.json, which defers monitor-mode routines for the rest of this exchange (cleared at Stop). The UserPromptSubmit hook deliberately skips <channel prompts (it can't see the allowlist); this step is the authorized write site for both. Run it as early as authorization allows — a routine due before it lands can interject mid-exchange.
After authorization passes, store the inbound chat_id to config.json → channels.<channel>.dm_channel_id (e.g. channels.discord.dm_channel_id — the normalized bare key per §0, never the raw qualified source) if it differs from the currently stored value or hasn't been stored yet.
This is how the agent learns the DM channel ID for proactive outbound notifications. In Discord, the DM channel ID differs from the user ID and is only discoverable from an inbound message. Write back to config.json only when the value has changed to avoid unnecessary writes.
Before running any heavy sub-step — an archive traversal, a multi-file search, or a delegated execution step — apply the Context-hygiene & delegation rule: delegate when its criteria hold and keep only the verdict.
Harness command (exactly /compact, /clear, /model <arg>, or /effort <arg>)
user-prompt-pipeline.ts UserPromptSubmit hook's harness-command stage before this skill runs — the request is already recorded, and the Stop hook types it into the session when this turn ends. When /model or /effort opens Claude Code's cached-context warning, that same hook path confirms the already-authorized switch. There is nothing for you to do; acknowledge briefly via the channel if you like.hermit-start re-asserts config.model / config.effort. If Claude Code rejects the argument, that shows in the terminal, not in chat — so don't promise it took effect./model with no argument, a bare clear, or prose mentioning one) is not intercepted — classify it under the categories below instead.Slash command (message starts with /, e.g. /claude-code-hermit:simplify, /plugin:command)
Status request ("what are you working on?", "status", "progress")
session_state (runtime.json) is idle: respond with session summary — tasks completed, cumulative cost, "ready for what's next"session_state is in_progress: respond with a concise summary of SHELL.md: task, current step, blockersSpend request ("how much have I spent", "why is my bill high", "cost breakdown", "what's my spend", or any variant asking about spend/cost/billing, in any language)
config.operator_profile === 'non-technical': do not invoke cost-reflect or surface figures. Reply in the client chat with a localized plain deflection: en "Day-to-day costs are handled by your provider — anything else I can help with?" / pt-PT "Os custos do dia a dia são geridos pelo seu fornecedor — posso ajudar com mais alguma coisa?" (spend figures stay available maintainer-side: terminal, maintainer chat, weekly review)./claude-code-hermit:cost-reflect. Its own Step 0/1 already detect the channel-tagged turn and run the plain-language --plain mode — do not run the raw token-category breakdown here.Task assignment (only when session_state is idle: "work on X", "next task: Z", "start Y", or any message describing work to be done)
/claude-code-hermit:session-start to begin the new task (idle → in_progress)session_state; plan items are created as native TasksMicro-approval response ("yes", "no", "MP-… yes/no", "MP-… <number>", "MP-… <label>", a bare number, or a bare label while any pending micro-proposal exists)
state/micro-proposals.json → pending. Filter to status: "pending" entries.MP-YYYYMMDD-N yes / MP-YYYYMMDD-N 2 / MP-YYYYMMDD-N <label>): match that entry by id.options, if any) and ask the operator to specify (e.g. "MP-20260422-0 yes" or "MP-20260422-0 2"). Do not resolve yet.options (plain yes/no entry): the answer must be yes or no (case-insensitive). Anything else on this entry → ambiguous, ask for clarification once, do not resolve.options (2-4 labels): a bare number k within range (1 through the option count) selects options[k-1]; a number outside that range is ambiguous. Otherwise, case-insensitive prefix match the answer against the labels; a unique match resolves, no match or a multi-label prefix match is ambiguous. A bare yes/no against an options entry is ambiguous — reply with the numbered options and ask once, do not resolve.yes/no/later can't be cleanly resolved here (ambiguous against an options entry, or multiple pending entries), run bun ${CLAUDE_PLUGIN_ROOT}/scripts/proposal.ts index .claude-code-hermit (validates the index against disk — one bounded output line) and check the refreshed state/proposals-index.json. If it has a status: "proposed" proposal, append to the clarification reply: "…or reply 'YES #N' to act on an open suggestion instead." Precedence is unchanged — this only hands a bare reply meant for a Suggestion card a way out of the micro-proposal loop.state/micro-proposals.json (issue 649: a stray trailing comma from a hand-edit removal corrupted the file and every reader silently treated it as an empty queue).
on_resolve → resolve on disk FIRST, then invoke. Run:
bun ${CLAUDE_PLUGIN_ROOT}/scripts/proposal.ts micro .claude-code-hermit resolve <id> --action answered --answer "<selected label>"
This removes the entry from pending, writes the file, and appends the micro-resolved event ("action":"answered") in one atomic call — before invoking the command. The on_resolve command can run a long implementation (e.g. proposal-act … --answer "implement now" runs the falsification gate + full implementation); if the durable-queue removal were left until after that, a crash or compaction mid-implementation would leave the entry pending and heartbeat would keep re-nudging a question already acted on. Then substitute the selected label into the on_resolve {answer} placeholder — wrap it in double quotes so a multi-word label (e.g. session task) stays a single --answer argument — and invoke the resulting skill command. This is how a channel-bridged ask (e.g. a 3-option proposal-act entry) re-enters the asking skill at the right branch — the invoked command itself detects it's a re-entry and skips straight to acting on the answer. The answered event is audit-only (neither an approval nor a rejection, so it's outside the micro approval-rate metrics). See § Channel-safe ask bridge below.on_resolve, "yes" on tier 1 → execute the change at next idle, log outcome in SHELL.md, then:
bun ${CLAUDE_PLUGIN_ROOT}/scripts/proposal.ts micro .claude-code-hermit resolve <id> --action approved
on_resolve, "yes" on tier 2 → create PROP-NNN via /claude-code-hermit:proposal-create, queue for next idle, then run the same resolve <id> --action approved call.on_resolve, "no" → run:
bun ${CLAUDE_PLUGIN_ROOT}/scripts/proposal.ts micro .claude-code-hermit resolve <id> --action rejected
Proposal approval ("accept PROP-", "go ahead with PROP-", "approve PROP-", referencing proposal numbers, #N, or a bare/#N-qualified YES/LATER/NO reply to a Suggestion card — only when no pending micro-proposal claimed the reply first, per Micro-approval response above)
YES / "go ahead" / "accept" → accept; LATER / "hold" / "defer" → defer; NO / "drop" / "dismiss" → dismiss. accept PROP-/approve PROP- phrasing maps to accept directly; the operator can also spell the action out instead of YES/LATER/NO.bun ${CLAUDE_PLUGIN_ROOT}/scripts/proposal.ts index .claude-code-hermit to validate the index against disk (one bounded output line — catches out-of-band file renames/moves that never went through Write/Edit). Then, for an explicit #N or PROP-NNN reference — confirm it matches a proposal in the refreshed state/proposals-index.json; if it doesn't, reply in plain voice ("I don't see a Suggestion #N — reply with one of the open numbers") rather than routing to proposal-act (whose no-match error is terminal-voice and names a slash command). On a match, route through /claude-code-hermit:proposal-act <action> PROP-N (proposal-act zero-pads the integer itself). A bare YES/LATER/NO with no #N: read the refreshed state/proposals-index.json, filter to status: "proposed". Exactly one → apply to it. Zero or 2+ → reply listing the open Suggestion numbers and ask the operator to specify (e.g. "Reply 'YES #14'").#N derivation are canonical in proposal-list §4a) — confirm using the Suggestion number (see proposal-act's channel-tagged notify).New instruction ("work on X", "switch to Y", "prioritize Z")
session_state is idle: treat as Task assignment (above)Question ("why did you...", "what about...", "how does X work?")
Pause / resume / snooze (bare, exact-match "pause", "stop", "resume", or "snooze <duration>")
user-prompt-pipeline.ts UserPromptSubmit hook's pause stage (PROP-015) before this skill ever runs — state/pause.json is already set or cleared by the time you see the prompt. There is nothing left for you to do for the state change itself; if you want to acknowledge it, reply via the channel.pause-gate.ts) denies every tool call except the channel reply tool while paused — including a Bash call running hermit-pause.ts off — and returns the pause reason in the denial. Resume can only come from an exact "resume" message (the deterministic hook above) or the operator's own .claude-code-hermit/bin/hermit-pause off.Emergency ("abort", "revert", "rollback", or a "stop" that is not a bare exact match — see Pause/resume/snooze above for the exact-match case)
runtime.json session_state to waiting (waiting_reason: "operator_input") and note the halt reason in SHELL.md ## BlockersCLAUDE-APPEND.md § Operator Notification for the full rule.After sending the response, check whether this turn revealed a durable signal worth recording. Append at most one line to SHELL.md ## Findings when the turn matches one of these conditions:
Do not write a finding for: one-off questions, research turns with no preference signal, task assignments, status checks, or micro-approval responses. When in doubt, write nothing — the next scheduled reflect catches genuine recurrence via archived-session evidence.
Format (one line, appended under ## Findings):
[HH:MM] Channel pattern: <one-line description of the preference or recurrence>
If the sender's user ID (verified in §1c) is not the primary paired operator (i.e. not the first or only entry in allowed_users), append [origin: external] to the line:
[HH:MM] Channel pattern: <description> [origin: external]
Under the common single-operator config, allowed_users has exactly one entry and this marker never fires — all channel content stays own-work. The marker is only relevant on multi-user allowlists (e.g. a trusted third party added for task delegation).
Do not classify tier, tag Evidence Source, or decide memory-vs-proposal. Reflect reads this line as current-session evidence (Evidence Source: current-session, Sessions: current) and uses the [origin: external] marker (if present) to set Evidence Origin: external-content when passing to the judge.
Resolved corrections → observations ledger, not Findings. If the turn matched the "Correction or emergency implying a durable preference" condition above AND the correction clearly names a specific installed skill/component (e.g. "the brief is too verbose", "reflect keeps missing X" — an explicit skill or its behavior, not a vague "you"), append a ledger row instead of the ## Findings line above:
bun ${CLAUDE_PLUGIN_ROOT}/scripts/observations.ts observe .claude-code-hermit skill-correction --origin=<own-work|external-content> <<'HERMIT_OBSERVATION'
skill-correction:<canonical-name>
HERMIT_OBSERVATION
<canonical-name> = the corrected skill's bare name: frontmatter (strip any claude-code-hermit:/<plugin>: prefix, lowercase) — same resolution session-close uses. origin follows the same sender check as the [origin: external] marker above (external-content for a non-primary sender, else own-work). A rejected row answers ERROR|<reason> on stdout at exit 0, so it can never block the reply — no || true needed. (A mis-invocation exits 1 by design; fix the call and continue, never retry blind.) At most one row per turn, same as the Findings cap.
If the correction is a stated preference/recurrence with no clearly named skill, keep writing the ## Findings line as before — do not guess a <name> and do not ask the operator to disambiguate mid-reply.
Canonical protocol for proactively notifying the operator (referenced from CLAUDE-APPEND.md § Operator Notification). Main owns the outbound send and any AskUserQuestion; a delegated sub-step returns the message and main runs this protocol.
If no channel is enabled (channels block absent, channels === {}, or every channel-config entry has enabled === false — exclude the primary string pointer when iterating):
push_notifications === true in config.json, fire PushNotification(message="<condensed one line, per CLAUDE-APPEND.md § Operator Notification push format>", status="proactive"). Push is best-effort; do not retry on failure and do not log a channel-send-unavailable issue for this branch — the operator's empty-channels config is intentional.If at least one channel is enabled, compose the audience version(s) and deliver them in one call — do not resolve the channel yourself, the script owns routing:
bun ${CLAUDE_PLUGIN_ROOT}/scripts/channel-send.ts .claude-code-hermit --notice
with a JSON payload on stdin:
{ "client": "<text>" }{ "maintainer": "<text>" }{ "client": "<plain headline>", "maintainer": "<full detail incl. figures>" }. The
maintainer text must be the complete richer version of the same notice, not a fragment —
when both audiences resolve to the same chat the client leg is dropped, so the maintainer text
has to stand alone."sensitive": true for credential-bearing text (keeps it out of the searchable channel log).Compose each version in the operator's configured language.
The script prints { "delivered", "degraded", "no_channel", "result" }.
channel-send-unavailable issue.degraded: true, where maintainer detail reached only
SHELL.md Findings because a configured maintainer chat was unreachable). If
push_notifications === true, fire PushNotification(message="<condensed one line, per § Operator Notification push format>", status="proactive"), log the undelivered content to SHELL.md
Findings, and record a deduped channel-send-unavailable issue — you only reach this branch with a
channel enabled, so even no_channel: true means it is configured but unreachable (unpaired,
empty allowed_users, unreadable config), which is exactly the signal the operator needs.Never send a proactive notice through a channel reply tool, and never advise /<channel>:access
for a maintainer chat — the maintainer chat is reached by direct API POST and is outbound-only.
Canonical dual-delivery rule for any skill that hits a decision point on a channel-tagged turn (inbound prompt contains a <channel source="..."> tag) — referenced from proposal-act and hermit-settings (and any future skill that needs to ask a bounded question over a channel).
proposal.ts queue-micro (see reflect's § Micro-approval queuing) — options set to the labels (omit for plain yes/no), tier: 1, and on_resolve set to the skill invocation that should run once an answer is picked, with {answer} as the placeholder for the selected label. Free-form asks (no bounded set of answers) are reply-tool only — no entry is queued for those.pending, write the file, and append the same micro-resolved "action":"answered" event as § Micro-approval response above — so the entry doesn't dangle waiting for a reply that already happened. If the operator answers later (new turn, possibly a new session), the § Micro-approval response resolver above handles it via on_resolve.AskUserQuestion on a channel-tagged turn. It renders in the terminal/transcript, which is invisible to a remote operator — exactly the strand this bridge exists to prevent.This skill is a stub for the Channels research preview (Claude Code v2.1.110+). As Channels matures, extend this skill with:
tools
Composes and delivers the daily fitness brief — a forward-looking morning read (readiness + today's plan) or a backward-looking evening read (today's training, or an earned-rest note, + tomorrow's setup) — in the operator's configured voice. Invoke with /claude-code-fitness-hermit:fitness-brief --morning|--evening|--slot <name>. Becomes the plugin's two daily beats — the morning Strava connectivity check and the evening activity sync, RPE binding, and Run deep-dive.
development
Renew the hermit's long-lived Claude login token over the channel, before it expires. Relays a one-time sign-in link to the operator, takes the code back, installs the new token, and restarts. Activates on messages like 'relogin', 'renew my login', 'reauth', 'the login is expiring', or when doctor's credential-expiry check flags setup-token.
development
Synthesizes the past 7 days of archived briefs into a weekly digest — top stories, emerging vs faded themes, category activity, and per-source performance built from archive frontmatter. Delivers to the operator's configured channel and archives a weekly note. Designed as a weekly routine. Invoke with /feed-hermit:weekly-digest.
development
Manage developing story arcs tracked across briefs — add, resolve, and list active arcs in compiled/story-arcs-*.md. Arc Watch keywords drive the feed-brief arc-tagging enrichment. Invoke with /feed-hermit:story-arcs add|resolve|list.