TL;DR Claude Code sessions on the web can subscribe to their PRs and get GitHub activity pushed in. I wanted the same in my local sessions. I’ve ended up with a Claude Code channel plugin that forwards each watched repo’s webhooks to my machine with gh webhook forward and pushes reviews, comments, CI results, merge conflicts and force pushes into the session that subscribed. I went through three designs. The one I run now is a small local daemon with a thin shim per session. It keeps each session’s subscriptions across restarts and catches up from the GitHub API after the laptop sleeps.
What I wanted Link to heading
- My local sessions get what web sessions have: Claude subscribes to a PR and GitHub activity on it arrives in the session.
- I opt in per PR, so events from PRs I haven’t subscribed to stay out.
- Comments, reviews and approvals, Actions results for the PR, merge conflicts, force pushes and draft status.
- I want repo-level webhooks only.
- It fits the PR-watch hooks I already had: subscribe first, and fall back to polling.
- No confirmation prompt every session, and no wrapper around
claude. Myccalias stays plain. - Several sessions on the same repo, which is how I often work.
- Events that land while my laptop sleeps reach the session when it wakes.
- A PR closed without merging stays watched for a while, in case someone reopens it.
- Review comments that arrive after a merge reach the session. Bot reviewers can post a few minutes late.
- macOS and Linux for now, Windows later.
- Light on memory, because each session carries its own copy.
- No third-party relay service.
What I had before Link to heading
A hook told Claude to start a polling watcher after each gh pr create, and a Stop hook blocked the session from ending while a PR it opened had no live watcher. It worked, but polling every 30 seconds across a handful of PRs is wasteful, and anything that happened between polls arrived late.
A channel looked like the fix. It is an MCP server that declares claude/channel in its capabilities and sends notifications/claude/channel events, which land in the session as a <channel> tag Claude reads on its next turn.
Getting events from GitHub to localhost Link to heading
GitHub needs somewhere to POST webhooks, and I didn’t want to expose a port or pay for a relay. The gh-webhook extension does this: GitHub relays the deliveries over a websocket to your machine and removes the temporary webhook when you stop it.
gh extension install cli/gh-webhook
gh webhook forward --repo=owner/repo \
--events=pull_request,pull_request_review,issue_comment,workflow_run,push \
--url=http://127.0.0.1:PORT/ --secret=$SECRET
Two things caught me out:
- Repo webhooks are enough. I started with an org-level forward, which wants the
admin:org_hookscope and returned a 404 without it. A forward per repo needs only admin on that repo, and it stays opt-in. - SIGINT has to reach the extension binary. I spawned
gh webhook forwardand sent it SIGINT on shutdown.ghdidn’t pass the signal on, the extension kept running, and the webhook stayed on the repo. Spawning the extension binary (under~/.local/share/gh/extensions/gh-webhook/) fixed it.
Opt in per PR Link to heading
The plugin exposes two tools, subscribe_pr and unsubscribe_pr. Subscribing to the first PR in a repo starts its forwarder, unsubscribing from the last one stops it, and events for PRs nobody subscribed to get dropped. My post-gh pr create hook now tells Claude to call subscribe_pr, and falls back to the old polling watcher if the tool isn’t there.
Events GitHub doesn’t send Link to heading
Most of the list mapped onto webhook events: reviews, review threads resolved, comments, ready for review, converted to draft, labels, auto-merge, merge queue, and workflow_run completions for CI. Two things needed more work.
Merge conflicts. GitHub sends no webhook when a PR becomes unmergeable. I subscribe to push as well, and after a push to a watched PR’s base or head branch I ask GET /repos/{o}/{r}/pulls/{n} for mergeable. That field is null while GitHub works it out, so I retry a few times, three seconds apart, and only report a change.
Force pushes. A force push arrives as an ordinary synchronize action. To tell them apart I compare the old head with the new one:
gh api repos/owner/repo/compare/OLD...NEW --jq .status
ahead is a normal push. diverged or behind means someone rewrote history, and the event text says so.
The confirmation prompt Link to heading
Custom channels aren’t on Anthropic’s allowlist during the research preview, so they need --dangerously-load-development-channels, which shows a full-screen “I am using this for local development” prompt every session. Without the flag the tools still work, but Claude Code drops the events without an error, so I couldn’t tell when I’d forgotten it.
The docs describe allowedChannelPlugins as a managed setting for Team and Enterprise admins. I’m on a personal Max plan, so I tried it locally:
sudo mkdir -p "/Library/Application Support/ClaudeCode"
sudo tee "/Library/Application Support/ClaudeCode/managed-settings.json" <<'EOF'
{
"allowedChannelPlugins": [
{ "marketplace": "agentic-utils", "plugin": "agentic-utils-github" }
]
}
EOF
After that, claude --channels plugin:agentic-utils-github@agentic-utils started without the prompt. This is undocumented for personal accounts, so I wouldn’t count on it lasting past the preview.
To pass the flag without wrapping claude, I added a CCS_CLAUDE_ARGS environment variable to ccs, my session picker, and my claude shell function reuses it. One line in my profile covers both. I ruled out a claude shim on PATH: it would have changed what plain claude means everywhere.
export CCS_CLAUDE_ARGS="--channels plugin:agentic-utils-github@agentic-utils"
How the design changed Link to heading
One process per session (1.0.0). Each session’s channel ran its own forwarder. GitHub allows one forward per repo, so once one session watched a repo, my other sessions couldn’t. That broke my requirement for several sessions on one repo.
A shared broker (1.1.0). The first session started a background broker on a unix socket, and every session’s channel connected to it. The broker ran one forwarder per repo and fanned events out. I had Claude run three rounds of adversarial review on it with subagents (SRE, QA, security and maintainability lenses), and most of what they found were races. Two Bun details surprised me:
socket.writesends what fits in the kernel buffer and returns the byte count. It doesn’t queue the rest, so a session that fell behind lost events until the broker kept the remainder and resent it ondrain.Bun.listenon a unix socket path replaces whatever is there, even a socket another live process is listening on. I can’t use the socket as a lock, so the broker takes a separate lock file before it listens.
Each time I patched a race where I found it, another one showed up. After I moved the broker’s state changes into one queue that doesn’t wait on a process, I found no new races. The broker still had two gaps: it forgot every subscription when a session restarted, and it queued nothing for a session that wasn’t connected.
A daemon with a thin shim (2.0). A small localhost daemon keeps subscriptions per Claude session id and queues events for sessions that aren’t connected. Claude Code gives each MCP server its session’s id in CLAUDE_CODE_SESSION_ID, and --resume keeps it, so a resumed session gets its subscriptions and missed events back. Each session runs a shim that speaks a minimal MCP over stdio and forwards requests to the daemon.
Catching up after sleep (3.0). The daemon couldn’t queue events while it was suspended, so a review that landed while my laptop was shut never reached Claude. I hoped to replay GitHub’s webhook delivery log on wake, but gh webhook forward doesn’t survive a suspend. It hit a websocket close, exited and never reconnected. GitHub deleted its temporary webhook, delivery log included, about 20 seconds in. So the daemon now restarts the forwarder and asks the PR API what changed during the gap: comments, reviews, pushes, CI results and state changes. The daemon marks them as recovered and keeps track of what it has delivered, so nothing turns up twice. I renamed the plugin to agentic-utils-github in the same release, with the server called pr-events.
Late comments and shared repos (3.1, 3.2). The daemon keeps watching a merged PR for an hour, because bot reviewers can post a few minutes after the merge. The daemon keeps watching a PR closed without merging for a day, in case someone reopens it. If a colleague or another of my machines holds the repo’s webhook, or I’m not an admin there, GitHub refuses the forward and the daemon polls that PR every 60 seconds. Polling uses conditional requests, so a poll that finds nothing new doesn’t count against the rate limit. The daemon returns to live delivery when the repo’s webhook is free again.
I measured the old per-session channel at 44 MB. An empty Bun process is about 21 MB, and a bare MCP server built on the SDK came in at 85 MB unbundled. So I left the SDK out of the shim, and the daemon does the work once for all sessions.
Other approaches I ruled out Link to heading
- A launchd daemon. It runs from login and is simple, but works on macOS only.
- The daemon as each session’s MCP server over HTTP, with no shim. I tested it, and an HTTP MCP server can be a channel. It can’t tell which session a connection belongs to, though:
${CLAUDE_CODE_SESSION_ID}in the server’s headers expands from Claude Code’s own environment, not the session’s id. - The MCP SDK’s streamable HTTP transport behind a proxy. It drops a notification when the client’s event stream isn’t open, which is the case queueing exists for.
- A Go shim. Smaller again, but it needs a build for each OS and architecture.
Where it falls short of the web sessions Link to heading
These are the gaps I know about:
- Recovery after a sleep uses the PR API, which is coarser than the webhooks. It doesn’t report resolved review threads, and it can’t return CI results from other apps on a commit that someone replaced during the gap.
- Live delivery needs admin on the repo and a free forward, because forwarding uses the repo’s one temporary webhook. Without either, the daemon polls, so events arrive up to a minute late.
- GitHub doesn’t link workflow runs from forks to the PR, so CI results on fork PRs don’t arrive.
- Comment and review bodies go straight into the session. I set the channel up to tell Claude to treat them as information only, but anyone who can comment on the PR can put text in front of it.
- Each event wakes Claude, so on a busy PR I spend more tokens and the session can get interrupted mid-task.
- The forwarder takes the webhook secret on its command line, so other users on a shared machine could read it with
ps. That rules out shared machines for now.
None of the three versions loaded as a plugin until 2.0.1. Claude Code reads a plugin’s manifest from .claude-plugin/plugin.json alone, and I had put plugin.json at the plugin root, so Claude Code never saw the mcpServers entry and never started the server. My tests ran server.js directly, so they passed throughout. In claude mcp list, other plugins’ servers appeared and mine didn’t. Running claude plugin validate reveals the misplaced manifest, as does loading the plugin with --plugin-dir and checking that the server connects.
The plugin lives in agentic-utils/skills. It needs Bun and gh with the gh-webhook extension. Live delivery also needs admin on the repos you watch; without it, the daemon polls.
What would close the remaining gaps Link to heading
A hosted relay would remove the limits that come from forwarding to one laptop. It would work like this:
- A permanent webhook on each repo, or one on the org, points at a small service, such as a Cloudflare Worker.
- Each person’s daemon connects to that service with their own GitHub token, and the service checks that they can see the repo before it sends them anything.
- The service holds events while a laptop sleeps and hands them over on reconnect.
With it, everyone on a shared repo would get live delivery without per-person admin, and events would survive an overnight sleep. The cost is a service to build, secure and run. Event content passes through it, and someone with org admin has to set up the webhooks. I haven’t built it. Polling covers my own needs for now.