Writing a Claude Code skill that reviews pull requests
Engwire ships no reviewer, so review policy is a Markdown file you own. Here is the contract it must keep, and the ways a skill silently does not run.
Engwire decides which pull request is reviewed, at which revision, exactly once. It decides nothing about the review itself. What gets read, what gets said, whether anything is posted at all — that is a Markdown file in your skills directory, and the binary ships without one on purpose.
So editing it is not an advanced move. It is the normal one. engwire-review is a starting point rather than a default, and the first thing to do with it is copy it under a name of your own — updating the published skill reinstalls it over your edits.
What a skill is
A directory with a SKILL.md in it:
~/.claude/skills/my-review/SKILL.md
Or under $CLAUDE_CONFIG_DIR/skills/ where you have set one. The front matter names it and declares what it may use:
---
name: my-review
description: When to invoke this skill, in the words Claude will match against.
allowed-tools: Bash, Write, WebFetch, WebSearch
---
Everything after that is prose. It is a document telling an agent how to work, not a program — which is why changing what a review looks for is an edit rather than a release.
The contract Engwire holds you to
What the runner promises, and what it needs back. skillPreflightProblem in src/claude/skills.ts is where these are actually decided, and is worth reading before trusting the list.
The name is the directory. skill = "my-review" in your config.toml is joined straight onto your skills directory to find SKILL.md, so the two have to agree. Engwire accepts [A-Za-z0-9][A-Za-z0-9:_-]* and reads nothing else about the name — the name in your front matter is Claude’s business, not Engwire’s.
You are invoked as a slash command, with the repository, the number and the revision:
/my-review acme/api#42 at 8f3a1c2…
Your working directory is the checkout — a detached worktree at that exact revision, backed by Engwire’s own bare clone rather than sitting inside it. Read the code from disk.
The trap is gh pr diff, which answers about whatever GitHub considers current — possibly a push ahead of the revision you were handed. If you do need GitHub for the diff, because the clone is blobless and a blob will not lazily fetch, gh api "repos/<repo>/compare/<base>...<sha>" is pinned where gh pr diff is not. It also caps the files it lists, so a skill taking that path knows its coverage is partial and should say so.
gh already points at the right repository. GH_REPO and GH_HOST are set, so a plain gh pr review 42 posts where the checkout came from. A skill that names a repository itself still wins; this only fixes the default.
Your Claude configuration root has to be absolute. CLAUDE_CONFIG_DIR, or the HOME-derived fallback, is refused when it is relative — because Claude would resolve it from the directory it runs in, which is the pull request.
There is no stdin. Claude is spawned with its standard input ignored, so a skill that asks a question waits forever and is then killed by the timeout below.
You have a budget, and it is enforced. run_timeout — twenty minutes by default — kills the run and the process group it started. That is why the published skill reserves time to post rather than reading until it is stopped: an unposted review helps nobody, and being killed mid-thought is indistinguishable from having found nothing.
The ways a skill silently does not run
Each of these ends the same way if nothing looks for it first: Claude exits 0, a caller reads that as “the agent ran”, and a review request that cannot be re-sent is spent on nothing. Five of the seven are caught before a run is claimed. Knowing which two are not is the point of the list.
The name does not match. A rule naming a skill that is not there produces an unknown slash command. Claude prints Unknown command: /my-review and exits 0. An unknown command is not an error.
The directory is called synced. This one was measured rather than reasoned about: Claude returns success without invoking a valid skill at that path. Engwire reserves the name outright.
SKILL.md is there but cannot be read. A permission problem is reported as itself rather than as a missing file, deliberately — telling somebody there is no SKILL.md sends them to reinstall a file that is already on disk.
The front matter declares user-invocable in a spelling nobody measured. Engwire accepts true, 1, yes or on, refuses a key quoted in a way Claude was not measured to honour, and refuses the same declaration twice. This is deliberately stricter than Claude: a quoted "yes" was measured to run, and Engwire still refuses it, because a request spent on a guess costs more than a refusal.
The configuration root is relative. The same case seen from the other side: preflight refuses the skill rather than letting Claude look for it inside a contributor’s checkout.
Claude’s own settings disable it. A skillOverrides: "off" in your Claude Code configuration can stop a skill that passes every check above. Engwire deliberately does not read that file — interpreting another product’s configuration model is a worse failure than not interpreting it — so a clean preflight means none of the problems above, and never that Claude will run the thing.
The skill runs but posts nothing. Engwire can see that the agent exited. It cannot see whether a review appeared on GitHub. If your skill decides not to post, say so in what you write back, because the runner has no other way to tell that apart from a review that worked.
engwire doctor and the runner’s own preflight check the first five before a run is ever claimed, and leave those reviews queued rather than spending them. The last two are yours: one lives in a file Engwire will not read, and the other is what your skill decides to do.
Testing it without spending a request
You do not need a real review request to try a skill. Engwire’s invocation is reproducible by hand.
One thing to be clear about first: there is no dry run. The line below invokes the real skill, and the published one posts a review at the end of it. The safety is in the target you choose, so point it at a pull request you own and are willing to see a review appear on.
Check out the revision you want to review, then:
GH_REPO=acme/api GH_HOST=github.com \
claude --setting-sources user -p "/my-review acme/api#42 at 8f3a1c2"
Those are the arguments the runner builds — same flag, same prompt shape. The environment is not identical: the runner also disables gh’s prompts, sanitises PATH to absolute directories, and clears ambient Git variables, none of which the line above reproduces. --setting-sources user matters even here: without it Claude reads CLAUDE.md, .claude/settings.json, project skills and .mcp.json from the directory you are standing in — which, if you are testing against somebody’s branch, is a directory they control.
If you want to see the review without publishing it, that is a change to the skill rather than a flag on the command — comment out its posting step, or have it write the payload to a file and read that instead.
What to keep, and what to change
The published skill’s shape is worth keeping even if none of its opinions are:
- Look, prove, say. Gather candidates, then verify each against the code at that revision, then write. The verification step is the one under time pressure and the one that separates a review from a wall of plausible noise.
- Drop what you cannot prove, however plausible it sounded.
- Cap the comment count. The published skill stops at roughly eight, on the view that a longer review gets skimmed rather than read, and folds one problem in six places into one comment saying it recurs. The number is a judgement; having one is the part worth copying.
- One review, one post. Never a stream of individual comments.
The opinions are the part to change. Whether it approves at all, what counts as blocking, how much of the diff is in scope, what it does about tests, whether it will comment on style — every one of those is a line in a file you own, and the reason Engwire does not ship a default is that none of those answers is the same for two teams.