Shippy - An Autonomous GitHub Agent in 630 Lines of Bash

Shippy - An Autonomous GitHub Agent in 630 Lines of Bash

I wanted an agent that ships code while I sleep. Not a SaaS platform, not a Docker container, not a framework — just a script on a cheap VPS that picks up GitHub issues, writes the code, and opens a pull request.

Shippy is ~630 lines of bash. It runs on cron, uses git worktrees for isolation, and delegates the actual coding to Claude Code. The entire infrastructure cost is $10/month.

The Idea

The workflow is simple:

  1. Assign a GitHub issue to the agent’s account
  2. Within 5 minutes, Shippy picks it up
  3. Claude Code implements the changes in an isolated worktree
  4. A draft PR appears with the implementation
  5. Review the PR — @mention the agent for feedback, it responds automatically

No web UI, no queue service, no database. Just cron polling GitHub’s search API every 5 minutes.

Two Workers

Shippy has two scripts that run independently:

Issue Worker — Finds the oldest assigned issue across all repos, creates a feature branch in a git worktree, runs Claude Code with the task, and opens a draft PR. Times out after 90 minutes.

Feedback Worker — Polls for @mentions on PRs created by the issue worker. When a reviewer leaves feedback, it checks out the PR branch, runs Claude Code with the review context, pushes fixes, and comments back. Times out after 20 minutes.

Both workers share a lock file via flock, so they never run simultaneously. If one is working, the other exits cleanly and tries again next cron cycle.

Why Bash

The entire project is two bash scripts, two prompt files, and a deploy script. No runtime dependencies beyond bash, git, gh, jq, curl, and claude.

Bash gets a bad reputation, but for this kind of glue work it’s the right tool. Every operation is a CLI call — GitHub API via gh, git operations, file manipulation. A Python or Ruby wrapper would just be subprocess calls with extra syntax.

The scripts use set -euo pipefail throughout. Every arithmetic operation that could fail is checked. Lock files prevent race conditions. Worktrees provide isolation. It’s not elegant, but it’s reliable.

Architecture

~/shippy/                   # Runtime directory (on VPS, not in git)
├── .env                    # Agent credentials
├── .lock                   # Shared flock between workers
├── state.json              # Feedback worker state (last processed comment)
└── logs/
    ├── issue-worker.log
    └── feedback-worker.log

~/projects/                 # Auto-cloned repos
├── repo-a/
│   └── .worktrees/
│       └── shippy/         # Issue worker worktree
└── repo-b/
    └── .worktrees/
        └── feedback-42/    # Feedback worker worktree

The repo itself contains the source scripts in agents/ and a bin/deploy script that copies them to ~/shippy/. The runtime state (.env, logs, lock files) lives outside the repo and is never committed.

Issue Worker: Two Modes

Without arguments, the issue worker polls for assigned issues:

# Cron: every 5 minutes
*/5 * * * * ~/shippy/issue-worker.sh

It uses GitHub’s search API to find issues assigned to the agent account:

gh api search/issues \
  --method GET \
  -f q="assignee:${AGENT_GITHUB_USERNAME} is:open is:issue" \
  -f sort=created \
  -f order=asc \
  -f per_page=1

If nothing is assigned, it exits. Zero Claude invocations, zero cost. When idle, Shippy is just a no-op cron job.

With a repo argument, it switches to repo mode:

# Cron: once daily
1 6 * * * ~/shippy/issue-worker.sh owner/repo

Repo mode has its own task priority:

  1. Priorities file — First item from <project>/.shippy/priorities.md
  2. Self-directed — If no priorities exist, the agent analyzes the codebase and picks the highest-impact small improvement

Both modes cap open Shippy PRs at 2 per repo. If two are awaiting review, the worker skips and sends a Telegram notification: “Review them first.”

Worktree Isolation

Every run gets a fresh git worktree:

BRANCH="shippy/$(date +%Y-%m-%d-%H%M%S)"
git worktree add "$PROJECT_DIR/.worktrees/shippy" -b "$BRANCH"
cd "$PROJECT_DIR/.worktrees/shippy"

Worktrees are git’s built-in mechanism for multiple working directories from the same repository. The agent works in its own directory on its own branch while the main checkout stays untouched. No Docker containers, no virtual machines — just a filesystem-level checkout.

Stale worktrees (older than 2 days) are cleaned up automatically at the end of each run.

Feedback Loop

The feedback worker is the more interesting piece. It doesn’t just create PRs — it iterates on them based on human review.

It discovers which PRs to watch by searching for open PRs with the shippy label:

gh api search/issues \
  --method GET \
  -f q="is:pr is:open label:shippy author:${AGENT_GITHUB_USERNAME}"

For each PR, it fetches both issue comments (top-level) and review comments (inline on diff), filters for @mentions from authorized users, and processes them in order.

The authorization check ensures only listed team members can trigger the agent. Random users commenting on the PR won’t cause Claude invocations.

For inline review comments, the worker passes file path and line number context to Claude, so the feedback is targeted:

REVIEW COMMENT LOCATION:
File: app/models/user.rb
Line: 42

Safety Rails

Several guardrails prevent the agent from doing damage:

Forbidden files — After every run, the worker checks if any restricted files were modified:

FORBIDDEN=$(git diff --name-only "origin/$DEFAULT_BRANCH" |
  grep -E '(config/credentials|config/deploy|\.env)' || true)

If credentials, deploy configs, or environment files were touched, the PR is not created and a Telegram alert fires.

Memory check — Before invoking Claude, both workers verify at least 8GB of available RAM:

AVAILABLE_MB=$(awk '/MemAvailable/ {print int($2/1024)}' /proc/meminfo)
if [ "$AVAILABLE_MB" -lt 8000 ]; then
  log "Insufficient memory"
  exit 1
fi

Daily limit — The feedback worker caps at 10 Claude invocations per day, resetting at midnight.

Timeouts — The issue worker times out at 90 minutes, the feedback worker at 20. Timeout produces a comment on the PR explaining the failure.

Mutual exclusion — Both workers share a lock file. Only one Claude instance runs at a time. flock handles this with zero configuration:

exec 200>"$LOCK_FILE"
if ! flock -n 200; then
  log "Another agent run in progress, exiting"
  exit 0
fi

The lock is attached to a file descriptor, so it auto-releases if the process crashes. No stale lock files to clean up manually.

Prompt Engineering

The prompts are minimal. The issue worker prompt fits in a few paragraphs:

The feedback worker prompt adds scope checks:

That last point is a prompt injection safeguard. The feedback body comes from external users. The prompt explicitly instructs Claude to treat it as code review feedback, not as instructions to execute.

Notifications

Both workers send Telegram messages for key events: PR created, feedback addressed, errors, timeouts, forbidden file violations. The notification system is entirely optional — if TELEGRAM_URL is unset, everything works silently.

Deployment

The deploy script copies the agent files from the repo to ~/shippy/:

bin/deploy

Then add two cron entries:

*/5 * * * * ~/shippy/issue-worker.sh
*/5 * * * * ~/shippy/feedback-worker.sh

That’s it. No systemd services, no process managers, no orchestration. Cron runs the scripts, flock prevents overlap, and git worktrees provide isolation.

What I Learned

Bash is underrated for glue work. When every operation is a CLI call, adding a language runtime in between just adds complexity. The scripts are long but every line does something visible — there’s no framework magic hiding behavior.

Cron + flock is a production-grade scheduler. It sounds primitive, but cron has been running scheduled tasks reliably for decades. flock handles mutual exclusion with automatic cleanup on crash. Together they replace a task queue, a worker process, and a supervisor.

Git worktrees are perfect for agent isolation. Each run gets a clean checkout without cloning the entire repo. The main working tree stays untouched. Multiple feature branches can coexist. And cleanup is a single git worktree remove.

The feedback loop is the key feature. Creating PRs is useful. But responding to review comments and iterating — that’s what makes the agent feel like a team member. You review, leave feedback, and within 5 minutes the changes are pushed.

Constraints make the agent reliable. The forbidden files check, memory guard, daily limit, timeouts, and mutual exclusion aren’t features — they’re what make the difference between a demo and something you trust to run unattended on a VPS.

The code is on GitHub: Shippy

Jose

Written by Jose

Full-stack developer with 20 years of Rails experience, currently building on Solana.

Follow me on X
Back to blog