One mention, one merged PR: Claude Code as a GitHub App
I have been driving Claude Code from my laptop for months. This morning I ran /install-github-app and moved it into the repository itself. The timings below all come from the same ten minutes, and they are the least interesting part of the story — what sits underneath is better.
Ten minutes, from a chore to a merged PR
- 05:39 — I open issue #142, Update README.md. The README still described a
frontend/+backend/repo and had drifted past the blog, BuildCV and a new agent module. - 05:43 —
/install-github-appopens PR #143 with a single workflow file. I merge it eleven seconds later. - 05:45 — I comment
@claude check this out.on the issue. - 05:46 — a commit lands on
claude/issue-142-20260727-0545. The job took 1m 46s end to end. - 05:49 — PR #144,
+72 −9onREADME.md, merged.
The issue predated the workflow by four minutes, and opening it triggered nothing: the workflow did not exist yet, and its body never said @claude anyway. The comment was the trigger.
The workflow file is mostly a mention guard
The generated claude.yml is about fifty lines, and most of it is a condition:
on:
issue_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review_comment:
types: [created]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'issues' && contains(github.event.issue.body, '@claude'))Four event types, every one gated on the literal string @claude. Nothing fires on a push, nothing fires on a schedule, nothing fires on an issue that did not ask for it. For a repo where I am the only committer that is the right default — the agent is opt-in per thread, not a standing daemon.
It writes without write permission
Here is the part I did not expect. The permissions block the installer generated is read-only:
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: readYet a branch appeared, a commit landed on it, and the bot posted a comment. None of that is possible with a GITHUB_TOKEN scoped like the above — so that token is not the one doing the writing. The action's security notes are explicit: “The GitHub app receives only a short-lived token scoped specifically to the repository.” The id-token: write line is the tell; that is the OIDC identity the runner exchanges for the app token.
I like this split more the longer I look at it. The workflow's own token — the one every other step in the job can reach — stays read-only. The write capability belongs to an installed app I can audit and revoke from repo settings, independently of the workflow file. A malicious step landing in the same job does not inherit push rights just by sitting next to Claude.
The commit is authored by a ghost
The trailer is inverted from what I am used to. Normally I am the author and Claude is the co-author. Here it is the other way round:
$ git show -s --format='%an <%ae>' 901f38b
claude[bot] <41898282+claude[bot]@users.noreply.github.com>
# ...and in the message body:
Co-authored-by: Zak <54307711+zakariahere@users.noreply.github.com>Then GitHub disagrees with the commit about who wrote it:
$ gh api repos/zakariahere/cvnext/commits/901f38b \
--jq '{author: .author.login, verified: .commit.verification.verified}'
{"author":"github-actions[bot]","verified":false}That is not a claim about which token pushed. GitHub resolves commit authorship by matching the author email to an account, and 41898282 is github-actions[bot]'s numeric id. The display name says claude[bot], the id inside the noreply address says something else, and the id wins. If you are auditing who changed what, read the email, not the name.
verified: false is by design too — commits are unsigned unless you opt into signing. Worth knowing before you write a branch protection rule requiring signatures, because it would reject every one of these.
The same CLAUDE.md travelled with it
This is the bit that actually changed how I think about the setup. The first item on the bot's own task list was “Gather context (read issue, CLAUDE.md, current README.md)”. My CLAUDE.md carries a blunt rule near the top:
Do not commit directly to
master— it is the protected/published branch. For any new change, create a feature branch offmasterfirst, commit there, and open a PR to merge back.
It branched. Nobody told it to in the comment, because the instruction was already in the repo. The context file I had been maintaining for laptop sessions turned out to be the contract for a machine I never configured — same rules, different runner. That is a far better argument for keeping CLAUDE.md honest than “it makes the autocomplete better”.
What is actually on the runner
The job log shows a normal Claude Code install, not a special build: version 2.1.220 dropped into ~/.local/bin/claude, a fresh /home/runner/.claude/settings.json, and the prompt handed over in a file rather than an argument. Tools are explicitly allow-listed — the visible entries include Glob, Grep, LS and Read, with GitHub access arriving as MCP servers such as mcp__github_comment__update_claude_comment and mcp__github_ci__get_ci_status.
Two lines in that log earn their place: Using multi-block message with user request (content hidden) and Running Claude Code via SDK (full output hidden for security). Actions logs are public on a public repo. Not streaming the model's full working into them is the correct default, even though it means debugging a run costs you a re-run in debug mode.
The last mile is still a human
It did not open the pull request. It pushed a branch and left a comment with a pre-filled Create PR link; I clicked it, added the assignee, the documentation label and the project, and merged. Then the notification arrived in my inbox exactly as it would for any other collaborator — a slightly funny thing to receive from something running inside your own CI.
That division feels right. The work it did well was a chore with an obvious spec: read the repo, notice the README describes a shape that no longer exists, fix it. Seventy-two lines added on one file, no judgement calls I would have argued with. I would not hand it a design decision this way — an issue comment is a poor place to negotiate architecture, and the mention guard means it only ever sees the thread, never the back-and-forth I would have had with it locally.
So: documentation drift, dependency chores, why is CI red on this PR triage. The install cost was one merged workflow file and roughly ninety seconds of runner time per invocation. It stays on.