Appearance
The agent runner
A cron job on the VPS reads GitHub issues labelled agent:ready, asks Claude to implement them, asks a second Claude to review the result, and stops. It never merges. A human does.
This page explains how it works and how to operate it. The script itself is the source of truth — if this page and ops/agent-runner/agent-runner.sh disagree, the script wins and this page is a bug. The reasoning behind the tool allowlist lives next to the script, in ops/agent-runner/README.md.
Status as of 2026-07-30
The cron entry is installed and the PAUSE file is in place, which means every tick currently exits immediately without doing anything. The script has been shell-checked but has never run end to end against a live ticket. Treat the first run as a test, with a human reading the log as it goes.
The label flow
someone writes prose
│
▼
agent:spec ──── snag-spec ────▶ agent:ready ─────┐
│ │
└──▶ agent:blocked │ the runner picks it up
(questions for │
a human) ▼
agent:working
│
┌─────────────────────────────┴──────────────┐
▼ ▼
PR opened no PR opened
│ (the agent refused)
snag-review │
│ ▼
▼ agent:blocked
agent:reviewed
│
▼
a human mergesagent:working exists so that a run dying mid-ticket cannot make the next run pick up the same ticket and open a duplicate pull request.
A ticket that ends in agent:blocked is not a failure of the system — it is the system working. The agent found something it could not resolve and said so instead of guessing. Read its comment.
One cycle, step by step
- Guards. Exit if
PAUSEexists. Take an exclusiveflockso two runs can never overlap. Read.env. Verifyghcan authenticate, and fail loudly if it cannot. - Find work. For each repository in
AGENT_REPOS, list open issues labelledagent:ready, oldest first, capped atAGENT_MAX_TICKETS_PER_RUN. - Clean the checkout.
git reset --hard origin/mainthengit clean -fdbefore every ticket. The runner assumes nothing about the state a previous run left behind. - Deliver. Relabel
agent:ready→agent:working, then run Claude headless with thesnag-deliverskill on that issue. - Find the pull request. Search open pull requests for one whose body contains
Closes #<issue>. That string is how the runner knows the agent actually opened something, and it is whysnag-deliverrequires it. - Review. Run a second Claude with the
snag-reviewskill on that pull request. The reviewer is launched with noWriteand noEdit— it cannot quietly fix what it finds, only report it. - Label the outcome.
agent:reviewedif a pull request exists, otherwiseagent:blockedwith a comment carrying the run id.
Which skills the agent gets
The runner cds into the repository checkout before invoking Claude, so Claude discovers that repository's own .claude/ directory. The skills are per repository, not global, and the prompt names the one it wants rather than hoping:
Use the snag-deliver skill to implement GitHub issue #<n> in this repository
Use the snag-review skill to review pull request #<n>| Repository | .claude/skills/ |
|---|---|
poc-tcg-collector | snag-stack snag-spec snag-deliver snag-review |
tcg-collector-backend | the same four, different content |
documentation | none yet — see Known limits |
The two sets are not copies. The backend snag-deliver carries the mandatory step of wiring a new test file into npm test (the backend has no glob; an unwired test file fails tests/testWiring.test.mjs on purpose). The mobile one carries the palette contract and the forbidden zones, android/ and ios/.
The repository's CLAUDE.md and the operator's global ~/.claude/CLAUDE.md load as well, so the branch discipline — conventional commits, never a direct push to main — applies to the agent and not only to a human.
Where everything lives
| Script | /home/kael/skn-tcg/tcg-collector-backend/ops/agent-runner/agent-runner.sh |
| Configuration | .env next to it, mode 600, gitignored |
| Checkouts | /home/kael/skn-tcg/<repo> |
| Locks, kill switch, logs | /home/kael/.agent-runner/ |
| Per-run log | /home/kael/.agent-runner/logs/<run-id>.log |
| Full Claude transcript | …/logs/<run-id>-deliver-<repo>-<issue>.json |
| Cron output | /home/kael/.agent-runner/cron.log |
The state directory sits outside every repository on purpose: nothing the agent writes should ever be a candidate for git add.
The workspace is still called skn-tcg although the organisation is now snag-tcg. That is deliberate — ten bind mounts across five running containers resolve through that path, including the Postgres data volume. See the rename history.
The schedule
*/20 8-20 * * 1-5 /home/kael/skn-tcg/tcg-collector-backend/ops/agent-runner/agent-runner.sh >> /home/kael/.agent-runner/cron.log 2>&1Every twenty minutes, 08:00–20:00, Monday to Friday. Nothing at night, because a run that breaks at 03:00 just sits there being wrong until morning with nobody to notice.
All eight binaries the script needs — node, npm, git, gh, claude, timeout, flock, jq — resolve under cron's minimal PATH because all eight are in /usr/bin. That is the classic failure of a cron job that works by hand and not on a schedule; it will not happen here.
Starting and stopping
sh
touch /home/kael/.agent-runner/PAUSE # every future run exits immediately
rm /home/kael/.agent-runner/PAUSE # resumeIt takes effect on the next tick without editing the crontab, and it survives a reboot. If something looks wrong, reach for it first and diagnose second.
To watch a single cycle by hand before trusting the schedule:
sh
cd /home/kael/skn-tcg/tcg-collector-backend/ops/agent-runner
AGENT_MAX_TICKETS_PER_RUN=1 ./agent-runner.shOn one throwaway ticket, in one repository, with a human reading the log as it goes. The failure you learn about that way costs an hour. The one you learn about from twelve open pull requests on a Monday costs a day.
Authentication
The runner does not need a token of its own to work. Two paths:
GH_TOKENfilled in.env— a fine-grained personal access token owned by thesnag-tcgorganisation, limited to the three repositories, with Contents, Pull requests and Issues at read/write, plus Checks and Commit statuses at read. Those last two are what lets the runner see CI; without them the API answers 403 and a red build can read as "no checks pending".GH_TOKENempty — falls back to whateverghis already authenticated with on the box: an OAuth token created by a browser login, stored in~/.config/gh/hosts.ymlmode600, with broad scopes and no expiry.
The run log states which of the two is in play, on its first line. The fallback is acceptable for a supervised dry run and wrong for the cron, because that token is a person, with admin rights, and the runner inherits all of it.
git push over HTTPS goes through the same credential either way: the git credential helper on this host is !/usr/bin/gh auth git-credential, and it honours GH_TOKEN.
Never install the runner's token with gh auth login. A fine-grained token does not sit alongside an account's OAuth token, it replaces it — logging in with it would destroy the working interactive gh on the machine, and if the token then turned out to be missing a permission you would be debugging with no access at all.
What actually protects you
Be clear-eyed: the script protects you very little. The layers, strongest first.
| Layer | What it stops |
|---|---|
| A human merges | everything — and gh pr merge is in neither allowlist, so this is enforced by the CLI, not by a convention |
| The tool allowlist | force pushes, branch deletion, history rewrites, secret reads, repository settings, arbitrary shell |
The reviewer having no Write | a reviewer that silently patches what it was meant to report |
| A scoped GitHub token | damage outside the three repositories |
block-main-commit.sh | a commit landing on main — but it is a git hook, so never run the agent with --bare |
AGENT_MAX_TICKETS_PER_RUN | a runaway night. It bounds the mess, it does not prevent it |
The PAUSE file | everything, immediately |
| Blast-radius rules in the skills | android/, ios/, migrations, workflows, dependency bumps — soft: a prompt is guidance, not a sandbox |
Every allowlist entry is an enumerated subcommand rather than a wildcard, because the wildcards that look narrow are not: Bash(gh *) includes gh pr merge, gh secret set and gh repo delete. With that wildcard, "it never merges" would rest on a prompt asking nicely. Enumerated, it rests on the CLI refusing.
Known limits
- The script has never run end to end. Written and shell-checked, not exercised against a live ticket.
- Claude's quota is shared. The VPS is logged in on a Max 5x plan, which two tickets per run does not strain — but it is the same quota as an interactive session anywhere else on that account. An unexplained rate limit in the middle of the afternoon is a reason to read the crontab before blaming anything else.
- No cost ceiling.
claude --max-budget-usdexists only for API key users; on a subscription there is nothing to cap. The ticket cap and the cron window are the only bounds. documentationhas no skills. Harmless today because it is not inAGENT_REPOS, but adding it there without first writing its.claude/skills/would run an agent with no guardrails and fail on the first prompt.- The runner does not check that a ticket belongs to the repository it was filed in.
snag-specis supposed to route that. A mobile ticket on the backend will be refused, but only after burning a run. - The operator's personal skills are visible too.
~/.claude/skills/is on the path andSkillis in the allowlist, so the agent can invoketdd,prototypeand the rest. Low risk, since a skill only activates when named and the prompt names one — but worth watching on the first runs.