Skip to content

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 merges

agent: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 ​

  1. Guards. Exit if PAUSE exists. Take an exclusive flock so two runs can never overlap. Read .env. Verify gh can authenticate, and fail loudly if it cannot.
  2. Find work. For each repository in AGENT_REPOS, list open issues labelled agent:ready, oldest first, capped at AGENT_MAX_TICKETS_PER_RUN.
  3. Clean the checkout. git reset --hard origin/main then git clean -fd before every ticket. The runner assumes nothing about the state a previous run left behind.
  4. Deliver. Relabel agent:ready → agent:working, then run Claude headless with the snag-deliver skill on that issue.
  5. 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 why snag-deliver requires it.
  6. Review. Run a second Claude with the snag-review skill on that pull request. The reviewer is launched with no Write and no Edit — it cannot quietly fix what it finds, only report it.
  7. Label the outcome. agent:reviewed if a pull request exists, otherwise agent:blocked with 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-collectorsnag-stack snag-spec snag-deliver snag-review
tcg-collector-backendthe same four, different content
documentationnone 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>&1

Every 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     # resume

It 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.sh

On 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_TOKEN filled in .env — a fine-grained personal access token owned by the snag-tcg organisation, 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_TOKEN empty — falls back to whatever gh is already authenticated with on the box: an OAuth token created by a browser login, stored in ~/.config/gh/hosts.yml mode 600, 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.

LayerWhat it stops
A human mergeseverything — and gh pr merge is in neither allowlist, so this is enforced by the CLI, not by a convention
The tool allowlistforce pushes, branch deletion, history rewrites, secret reads, repository settings, arbitrary shell
The reviewer having no Writea reviewer that silently patches what it was meant to report
A scoped GitHub tokendamage outside the three repositories
block-main-commit.sha commit landing on main — but it is a git hook, so never run the agent with --bare
AGENT_MAX_TICKETS_PER_RUNa runaway night. It bounds the mess, it does not prevent it
The PAUSE fileeverything, immediately
Blast-radius rules in the skillsandroid/, 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-usd exists only for API key users; on a subscription there is nothing to cap. The ticket cap and the cron window are the only bounds.
  • documentation has no skills. Harmless today because it is not in AGENT_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-spec is 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 and Skill is in the allowlist, so the agent can invoke tdd, prototype and the rest. Low risk, since a skill only activates when named and the prompt names one — but worth watching on the first runs.