Capability: webhook-triggers
GitHub events (comments, reviews, CI results) reach the right running harness session on the user's own machine, programmatically.
What it is
The local trigger path: an HMAC-verified webhook receiver plus event → session routing, so a PR comment or workflow result resumes the Claude Code / Cursor session working that item — the self-hosted equivalent of claude.ai/code PR watching.
Current behaviour
the-loop gh-webhook startSHALL run an HTTP receiver (default127.0.0.1:8787, path/gh-webhook) that verifiesX-Hub-Signature-256HMAC usingTHE_LOOP_GH_WEBHOOK_SECRET, exposesGET /health, and logs events.- WHEN
webhooks.ghWebhook.eventsis omitted or empty THEN the receiver SHALL accept the-loop's default event set —issues,issue_comment,pull_request,pull_request_review,pull_request_review_comment,workflow_run,check_run,check_suite,status, i.e. every event it can map to a work item. An explicit list narrows it, and WHEN that list omitsissuesorpull_requestTHEN the receiver SHALL warn at startup and on hot reload: a work item that ends would never be seen, so its session (and tmux session) would leak. A warning, not an error — narrowing is the operator's call. - WHEN routing is enabled (
webhooks.ghWebhook.routing.enabled) THEN a verified event SHALL be matched to a registered session (.the-loop/sessions/*.json, managed bythe-loop sessions) and the harness SHALL be resumed via its official CLI (claude -p --resume/cursor-agent -p --resume), serialized per session and parallel across sessions (maxConcurrentDispatches). - WHEN no session matches THEN the router SHALL spawn a new session per
spawnOnUnmatched(never | always | labeled, defaultlabeled— opt-in via thethe-loop: auto-executelabel) using the configured prompt templates. - An event on a PR resolves the PR's linked issue(s) first. WHEN an event concerns a pull request —
pull_request*, or anissues/issue_commentevent whoseissuecarries apull_requestkey (GitHub's shape for a PR conversation comment) — THEN the router SHALL emit the issue(s) the PR is linked to before the PR's own number. Linked issues come from three sources, most authoritative first: GitHub's ownclosingIssuesReferences(the Development panel), theissue-<n>head-branch convention, and closing keywords in the PR body in every form GitHub accepts (Closes #N,Fixes: #N,Closes OWNER/REPO#N,GH-N, a full issue URL); a qualified reference naming a different repository SHALL be ignored. Consequently a PR comment/review/CI result SHALL be delivered into the existing session for the linked issue (reusing its tmux session) rather than spawning a second one, and WHEN nothing matches and the spawn policy allows it THEN the session SHALL be spawned against the issue's ref, not the PR's (issue-93, decision-036). - The auto-execute label SHALL work on PRs directly: a labelled PR linked to no GitHub issue is routed as its own work item (
github:OWNER/REPO#<pr-number>), so PRs stay monitorable when the ticketing system is not GitHub (Jira, …) —work-onadds the label to the PR it opens and registers the session against the PR's ref. - WHEN
routing.runneristmuxTHEN spawned sessions SHALL be hosted as attachable interactive tmux sessions and events pasted into them — see interactive-sessions. - WHEN a work item ends — an
issuesevent with actionclosed, or apull_requestclosed(merged or not) — THEN its matched session(s) SHALL be auto-closed rather than resumed: registry entry closed, tmux session handled perrouting.tmux(see interactive-sessions), workspace cleaned,session.autoclosedrecording the reason (issue-closed|pr-merged|pr-closed). A close event SHALL never spawn a session, whateverspawnOnUnmatchedsays, and SHALL never be rendered into a harness prompt. Because it is a lifecycle signal that carries no free-form text and can only end the-loop's own session, it bypasses the authorized-actor guard (as PR-close always has) — narrowly: only theclosedaction. - One work item may be delivered by several PRs, and only the object that closed is ended. WHEN a
pull_requestclosedevent is dispatched THEN the system SHALL auto-close only the session registered against that PR's own ref, and SHALL leave a session registered against an issue the PR is merely linked to active (logged assession.kept_open) — a spec PR, a stacked series or a follow-up fix all deliver one work item, so one of them merging is not the item ending. Consequently the work item's checkout is not removed while it is open, and the item's session ends on its own close: theissuesclosedevent, or the poll path's closure reconciliation. The decision is made from the event payload alone (no API call, no credentials); a payload that names no closing number closes nothing. The operational consequence: a PR merged without closing its ticket leaves the session active until the ticket closes (the-loop sessions closeis the manual escape hatch) — issue-101, decision-039. - On the poll path the same closure is discovered by reconciliation, since a poll listing only ever carries open items: after each successful listing the poller checks every active session that source owns whose item is no longer listed, asks the provider whether it really ended (
poll.closure_detected), and closes it through the dispatcher's identical close path. It never closes on doubt — a failed listing skips reconciliation, and an unanswerable state query leaves the session running for a later cycle — and forgetting the item's poll state means a reopened work item is first-sight again and spawns afresh. The closure query lives behind the provider contract (owns/closure/closure_event), so a provider that does not implement it is simply never reconciled (issue-94). - Duplicate deliveries SHALL be dropped via a dedup cache (
dedupCacheSize). - On the poll (pull) path the-loop drives its own retries, bounded by
polling.maxRetries(default 3): WHEN a spawn or a comment forward does not succeed THEN it SHALL be retried on later cycles up to the budget (a failed event is no longer baselined as "processed" after one attempt), and WHEN the budget is exhausted THEN the poller SHALL log a terminal failure (poll.spawn_failed/poll.comment_failed,will_retry=false) and ignore that event on later polls until new activity re-arms it. A still-processing (in-flight) dispatch SHALL NOT be counted a failed attempt, and a new comment on a work item SHALL retrigger it with a fresh budget. The poller reads the async dispatch outcome via the dispatcher's durable delivery record (Dispatcher.delivery_status: done/inflight/unhandled) rather than assuming success at enqueue time. (The webhook path relies on GitHub redelivery, repaired for dead tmux sessions by the respawn above — see interactive-sessions.) - On the poll path the linked issues of a labelled PR SHALL be read from GitHub inside the PR listing the poller already performs (
gh pr list --json …,closingIssuesReferences— no extra API round-trip per cycle), and WHEN the installedghpredates that field THEN the poller SHALL warn once and fall back to the head-branch / closing-keyword conventions rather than failing the cycle. - WHEN
routing.reactions.enabledis on (default on — owner decision at PR #85 review;enabled: falseopts out of the daemon's one write surface to GitHub) THEN the dispatcher SHALL acknowledge each event it processes with emoji reactions on the triggering entity: thestartedreaction (default 👀eyes) when the event is dequeued for delivery/spawn, thencompleted(default 🎉hooray) orerror(default 😕confused) from the dispatch outcome — on the triggering comment when the event carries one, else on the issue/PR itself. Shared by the webhook receiver and the poller; best-effort via the operator's ownghCLI (a reaction failure never affects the dispatch; a missinggh, a non-GitHub provider, or an event with no reactable target is a silent no-op — so work-item platforms without reactions degrade cleanly). GitHub's palette is fixed (+1 -1 laugh confused heart hooray rocket eyes; ✅/⁉️ don't exist), and each state's emoji is configurable (""skips a state). Outcomes are logged asreaction.added/reaction.failed. - WHEN the dispatcher spawns (or respawns) a session THEN it SHALL first pre-seed the harness's own user config for that session's working directory (
routing.harnessTrust, default on), so an unattended session cannot stall on Claude Code's workspace-trust dialog or its bypass-permissions disclaimer — neither of which is a permission rule, hence neither is silenced by--dangerously-skip-permissions. Scoped byharnessTrust.scope— the workspace root by default (one entry covering every checkout under it) or the exact spawn directory — non-destructive (named keys only, merged, atomic, skipped when already correct, refused on an unparseable file), and permission-neutral: the bypass disclaimer is accepted only when this harness'sharnessArgsalready ask for bypass mode. Best-effort — a failure warns, emitsworkspace.trust_failedand still spawns. Audited asworkspace.trusted;harnessTrust.enabled: falseopts out. See interactive-sessions. - A comment/review the-loop itself posted (identified by an embedded marker, since it is posted under the operator's own credentials and is otherwise indistinguishable by author) SHALL be dropped before dispatch, so the-loop never resumes a session on its own reply (
the_loop.authz.is_self_authored; same check inthe-loop poll). - WHEN any producer writes a comment on the-loop's behalf — the spawned harness or the daemon itself (today: the interactive-session announcement) — THEN the body SHALL be stamped by
the_loop.authz.mark_self_authored, the producer-side counterpart of that check: a visible attribution line plus the marker, idempotent, and applied only to text the-loop composed (never to payload-derived text). An unmarked daemon comment is authorized — it is posted with the operator's own credentials — so the marker is the only thing preventing it from being delivered into the session it describes (issue-104). - All
webhooks.*keys above live in the CLI config (cli-config.yaml, resolved via--config/env/cwd/home — seecli/README.md), independent of any repo's.the-loop/harness-config.yaml(the plugin config) — the daemon is not tied to a single repo and never reads a repo's plugin config for anything (decision-032).routing.authorizedUsershas no fallback: it must be set explicitly in the CLI config or the receiver fails closed (acts on no human-authored events).
Design
docs/specs/issue-15/design.md · architecture § triggers
History
| Work item | What changed | Links |
|---|---|---|
| issue-104 | The loop-prevention marker gained a producer-side helper (mark_self_authored) applied to the daemon's own comments — the session announcement no longer re-enters the session it announces | spec, decision-031, issue |
| issue-101 | A work item may be delivered by several PRs: a pull_request closed event now ends only the session registered against that PR itself, leaving a linked issue's session (and its checkout) running until the issue's own close | spec, decision-039, issue |
| issue-94 | A finished work item now ends its session on both ingress paths: the poller reconciles active sessions against each successful listing and closes the ones whose item is closed/merged upstream; the receiver treats issues/closed like pull_request/closed instead of delivering it into the conversation | spec, issue |
| issue-93 | An event on a PR resolves the PR's linked issues first (closingIssuesReferences + branch/keyword conventions, incl. PR conversation comments delivered as issue_comment), so PR activity reuses the linked issue's session instead of spawning a second one | spec, decision-036, issue |
| issue-90 | Pre-seed the harness config before spawning (routing.harnessTrust) so spawned sessions stop stalling on the workspace-trust dialog / bypass-permissions disclaimer | spec, decision-037 |
| issue-84 | Dispatch-lifecycle emoji reactions (routing.reactions, opt-in): 👀 started / 🎉 completed / 😕 error on the triggering comment or issue/PR, best-effort via gh, no-op where unsupported | spec, issue |
| issue-63 | webhooks.* moved out of the per-repo plugin config into an independent, repo-agnostic CLI config | spec, decision-032 |
| issue-64 | Added the self-reply marker guard (drops the-loop's own comments/reviews before dispatch, on both trigger paths, so it never resumes a session on its own reply) | decision-031 |
| issue-80 | Bounded per-event retry policy on the poll path (polling.maxRetries, default 3): stop baselining failed spawns/comments as processed, retry each cycle, then log poll.spawn_failed/poll.comment_failed and ignore; a new comment retriggers | spec, issue |
| issue-32 | Added the tmux runner option for spawned sessions (dispatch via paste-injection; PR-close kills the tmux session) | spec, decision-021 |
| issue-15 | Added session registry, event→session routing and harness resume (receiver shipped in v0 gained --route) | spec, decision-016 |
| issue-1 | Shipped the HMAC-verified gh-webhook receiver (v0) | spec, decision-005 |