Setup Trigger Service
Set up a Bun-based HTTP trigger server on a VM and configure a GitHub Actions workflow to trigger it on a cron schedule, GitHub events, or manual dispatch.
The user wants to set up a trigger service for: $ARGUMENTS
CRITICAL: Repository Path — Ask the User
NEVER guess the repository path. NEVER invent home directories (e.g., /home/claude-runner). ASK the user where the repo lives.
There are two common environments:
| Environment | Home dir | Typical repo path |
|---|---|---|
| Sprite VM (Fly.io managed) | /home/sprite/ |
/home/sprite/spawn |
| Normal VM (bare metal, cloud) | /root/ |
/root/spawn |
How to determine the path
- Run
pwdand check the current working directory - If unclear, ask the user where the spawn repo is checked out
- Use that path consistently for ALL configuration: systemd services, wrapper scripts, PATH variables
Rules
- NEVER create new user accounts or home directories for the service
- NEVER assume a path like
/home/claude-runner/— that doesn't exist - All systemd services, wrapper scripts, and PATH variables MUST use the same base path as the repo checkout
- The wrapper scripts (e.g.,
start-security.sh) MUST live inside the repo at{REPO_ROOT}/.claude/skills/setup-agent-team/
Examples (for a repo at /root/spawn)
- ✅
WorkingDirectory=/root/spawn/.claude/skills/setup-agent-team - ✅
ExecStart=/bin/bash /root/spawn/.claude/skills/setup-agent-team/start-security.sh - ✅
Environment="PATH=/root/.bun/bin:/root/.local/bin:/usr/local/bin:/usr/bin:/bin" - ❌
WorkingDirectory=/home/claude-runner/spawn/...(invented path) - ❌
Environment="PATH=/home/claude-runner/.bun/bin:..."(invented path)
Overview
This skill sets up a trigger server that GitHub Actions can call to run a script:
GitHub Actions (cron / events / manual)
-> curl POST $SERVICE_URL/trigger (with Bearer token)
-> trigger-server.ts validates Bearer token
-> target script runs (single cycle, then exits)
How it works:
- The trigger server listens on port 8080
- A
TRIGGER_SECRETbearer token protects the/triggerendpoint from unauthorized access - The service URL + trigger secret are stored as GitHub Actions secrets
Prerequisites
bunis installedghCLI is installed and authenticated- Repository has write access for setting secrets
Step 1: Verify trigger-server.ts
The trigger server lives at:
$REPO_ROOT/.claude/skills/setup-agent-team/trigger-server.ts
It reads env vars:
TRIGGER_SECRET(required) — Bearer token for authenticating requestsTARGET_SCRIPT(required) — Absolute path to the script to run on triggerREPO_ROOT(optional) — Working directory for the script (defaults to script's parent dir)MAX_CONCURRENT(optional) — Max parallel runs (default:1)RUN_TIMEOUT_MS(optional) — Kill runs older than this in milliseconds (default:14400000= 4 hours)
Stale run detection:
Before accepting a trigger, the server checks if tracked processes are still alive (kill -0). Dead processes are reaped automatically. Runs exceeding RUN_TIMEOUT_MS are force-killed to free the slot.
Fire-and-forget:
The /trigger endpoint spawns the script and returns a JSON response immediately with the run ID. Script stdout/stderr go to the server console (captured by journalctl). The real state lives on the VM (log files at .docs/). GitHub Actions is just a dumb trigger — it makes the POST and exits.
Endpoints:
GET /health→{"status":"ok","running":N,"max":N,"timeoutSec":N,"runs":[...]}(no auth, shows per-run pid/age)POST /trigger→ validatesAuthorization: Bearer <secret>, reaps stale runs, spawns script, returns immediately
Responses:
200—{"ok":true,"runId":N,"reason":"...","concurrent":N,"max":N}(script spawned)400—{"error":"issue must be a positive integer"}if issue param is invalid401—{"error":"unauthorized"}if bearer token is wrong409—{"error":"run for this issue already in progress"}if duplicate issue trigger429—{"error":"max concurrent runs reached","oldestAgeSec":N}if at limit503—{"error":"server is shutting down"}during graceful shutdown
Step 2: Generate a trigger secret
openssl rand -hex 32
Save this — you'll use it in Steps 3 and 5.
Step 3: Create the wrapper script
Create a gitignored wrapper script that sets env vars and launches the server.
Create start-<service-name>.sh in {REPO_ROOT}/.claude/skills/setup-agent-team/:
#!/bin/bash
# Wrapper script — sets env vars and launches the trigger server.
# CRITICAL: SCRIPT_DIR must match the actual repo path on this machine.
# On a Sprite VM this is /home/sprite/spawn, on a normal VM it's /root/spawn.
SCRIPT_DIR="<REPO_ROOT>/.claude/skills/setup-agent-team"
export TRIGGER_SECRET="<secret-from-step-2>"
export TARGET_SCRIPT="${SCRIPT_DIR}/<target-script>.sh"
export REPO_ROOT="<REPO_ROOT>"
export CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
export MAX_CONCURRENT=5
export RUN_TIMEOUT_MS=7200000
exec bun run "${SCRIPT_DIR}/trigger-server.ts"
Replace <REPO_ROOT> with the actual path (e.g., /root/spawn or /home/sprite/spawn).
Make it executable:
chmod +x .claude/skills/setup-agent-team/start-<service-name>.sh
IMPORTANT: Verify that .gitignore includes wrapper scripts:
.claude/skills/setup-agent-team/start-*.sh
Wrapper scripts contain secrets and MUST NOT be committed.
Step 4: Create the service
Choose the service management approach based on your environment:
Option A: systemd (recommended)
Create a systemd unit file at /etc/systemd/system/<service-name>-trigger.service.
CRITICAL: Replace <REPO_ROOT> and <HOME> with the actual paths. Ask the user if unsure.
| Environment | <REPO_ROOT> |
<HOME> |
User/Group |
|---|---|---|---|
| Sprite VM | /home/sprite/spawn |
/home/sprite |
sprite / sprite |
| Normal VM | /root/spawn |
/root |
root / root |
[Unit]
Description=<Service Name> Trigger Server
After=network.target
[Service]
Type=simple
User=<user>
Group=<group>
WorkingDirectory=<REPO_ROOT>/.claude/skills/setup-agent-team
ExecStart=/bin/bash <REPO_ROOT>/.claude/skills/setup-agent-team/start-<service-name>.sh
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
# Environment
Environment="IS_SANDBOX=1"
Environment="PATH=<HOME>/.bun/bin:<HOME>/.local/bin:<HOME>/.claude/local/bin:/usr/local/bin:/usr/bin:/bin"
Environment="HOME=<HOME>"
[Install]
WantedBy=multi-user.target
Note: The wrapper script (start-<service-name>.sh) sets the actual env vars (TRIGGER_SECRET, TARGET_SCRIPT, etc.). The systemd service just executes the wrapper.
Enable and start:
systemctl daemon-reload
systemctl enable spawn-<service-name>
systemctl start spawn-<service-name>
Service management:
systemctl status spawn-<service-name> # Check status
journalctl -u spawn-<service-name> -f # Tail logs
systemctl restart spawn-<service-name> # Restart
systemctl stop spawn-<service-name> # Stop
Verify the service
# Test health endpoint
curl -sf http://localhost:8080/health
# Expected: {"status":"ok"}
# Test auth rejection
curl -sf -o /dev/null -w "%{http_code}" -X POST http://localhost:8080/trigger
# Expected: 401
# Test valid trigger
curl -sf -X POST "http://localhost:8080/trigger?reason=test" \
-H "Authorization: Bearer <secret-from-step-2>"
# Expected: {"ok":true,"runId":1,...}
Step 5: Create the GitHub Actions workflow
Create .github/workflows/<service-name>.yml:
name: Trigger <Service Name>
on:
schedule:
- cron: '*/30 * * * *' # Every 30 minutes (adjust as needed)
issues:
types: [opened, reopened]
workflow_dispatch: # Always include for manual testing
concurrency:
group: <service-name>-trigger
cancel-in-progress: false
jobs:
trigger:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Trigger <service-name> cycle
env:
SPRITE_URL: ${{ secrets.<SERVICE_NAME>_SPRITE_URL }}
TRIGGER_SECRET: ${{ secrets.<SERVICE_NAME>_TRIGGER_SECRET }}
run: |
curl -sS --fail-with-body -X POST \
"${SPRITE_URL}/trigger?reason=${{ github.event_name }}" \
-H "Authorization: Bearer ${TRIGGER_SECRET}"
The trigger is fire-and-forget — the workflow just makes the POST and exits. The script runs on the VM independently. Use --fail-with-body so HTTP errors (429/409/401) still print the JSON response body for debugging.
Step 5.5: Determine the Service URL
Before setting GitHub secrets, you need to know the public URL where this service can be accessed. The approach depends on your VM type:
Option A: Sprite/Fly.io VM
Sprite VMs have a public URL assigned automatically. Get it with:
flyctl status --json | jq -r '.Hostname'
# Example output: my-sprite-abc1.sprites.app
The service URL will be: https://my-sprite-abc1.sprites.app
Option B: Hetzner or other cloud VM (with public IP)
For VMs with a static public IP, use the IP directly:
curl -s https://api.ipify.org
# Example output: 203.0.113.45
The service URL will be: http://YOUR_IP:8080
Important: Ensure port 8080 is open in your firewall/security group settings.
Option C: Custom domain or reverse proxy
If you've set up a custom domain or reverse proxy (e.g., nginx with SSL):
The service URL will be: https://your-custom-domain.com
Make sure the domain/proxy forwards requests to localhost:8080.
Step 6: Set GitHub Actions secrets
Cron examples:
'*/30 * * * *'— every 30 minutes'0 */2 * * * *'— every 2 hours'0 */6 * * *'— every 6 hours'0 0 * * *'— daily at midnight
Set two secrets per service. Use namespaced secret names to avoid collisions:
# Set the service's public URL (from Step 5.5)
printf '<service-url>' | gh secret set <SERVICE_NAME>_SPRITE_URL --repo <owner>/<repo>
# Examples:
# Sprite VM: printf 'https://my-sprite-abc1.sprites.app' | gh secret set DISCOVERY_SPRITE_URL --repo OpenRouterTeam/spawn
# Hetzner/IP: printf 'http://YOUR_IP:8080' | gh secret set SECURITY_SPRITE_URL --repo OpenRouterTeam/spawn
# Set the trigger secret (from Step 2)
printf '<secret-from-step-2>' | gh secret set <SERVICE_NAME>_TRIGGER_SECRET --repo <owner>/<repo>
# Example: printf '61e6...' | gh secret set DISCOVERY_TRIGGER_SECRET --repo OpenRouterTeam/spawn
Secret naming convention:
| Secret | Example | Purpose |
|---|---|---|
<SERVICE>_SPRITE_URL |
DISCOVERY_SPRITE_URL |
Public URL of the service |
<SERVICE>_TRIGGER_SECRET |
DISCOVERY_TRIGGER_SECRET |
Bearer token for the trigger server |
Step 7: Tune RUN_TIMEOUT_MS
RUN_TIMEOUT_MS controls how long a run can execute before the trigger server force-kills it and frees the slot. Start high, then tune down based on real data.
Recommended approach
Start with a high timeout (6-12 hours). You don't know how long cycles take yet. A too-short timeout kills legitimate runs mid-work, leaving orphaned branches, half-merged PRs, and dirty worktrees.
Run several cycles and collect data. Check the trigger server logs for actual run durations:
# Look for "finished" lines with duration
grep 'finished' /var/log/spawn-<service-name>.log
Set the timeout to 2x your longest observed cycle. For example, if cycles take 30-90 minutes, set
RUN_TIMEOUT_MSto10800000(3 hours). This gives headroom for slow cycles without letting truly hung processes block the slot forever.Re-evaluate after changes. Adding more agents to a team, increasing the scope of work, or hitting API rate limits can all increase cycle time. Check logs periodically.
Current values (based on observed data)
| Service | Observed cycle time | RUN_TIMEOUT_MS | Rationale |
|---|---|---|---|
| Discovery (discovery.sh) | 15 min (gaps), 1-2h+ (discovery) | 14400000 (4h) |
Discovery cycles are open-ended; gap fills are fast |
| Refactor (refactor.sh) | TBD | 14400000 (4h) |
Start high, tune after data |
To override, add to the wrapper script:
export RUN_TIMEOUT_MS=14400000 # 4 hours
Or set it to a very high value initially:
export RUN_TIMEOUT_MS=43200000 # 12 hours (safe starting point)
Step 8: Ensure the target script is single-cycle
The target script (e.g., refactor.sh, discovery.sh, security.sh, qa.sh) MUST:
- Run a single cycle and exit — no
while trueloops - Sync with origin before work (MANDATORY) — Update to latest main before every cycle:
This ensures the service always runs the latest code. Without this, the service will run stale code indefinitely.git fetch --prune origin git reset --hard origin/main # OR: git pull --rebase origin main - Exit cleanly — so the trigger server marks it as "not running" and accepts the next trigger
If converting from a looping script, remove the while true / sleep and keep only the body of one iteration.
Included scripts in this skill directory:
discovery.sh— Discovery team service (usesgit pull --rebase)refactor.sh— Refactoring team service (usesgit reset --hard)security.sh— Security team service (usesgit pull --rebase)qa.sh— QA team service (quality mode usesgit pull --rebase)
Agent Teams (ref: https://code.claude.com/docs/en/agent-teams)
Agent teams are experimental and disabled by default. Every service script and wrapper MUST export:
export CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
This can also be set in settings.json:
{
"env": {
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
}
}
.spawnrc persistence
On spawn VMs, ~/.spawnrc is sourced by every agent launch command. Service scripts automatically inject the flag into .spawnrc if it exists, ensuring all Claude sessions on the VM inherit it:
if [[ -f "${HOME}/.spawnrc" ]]; then
grep -q 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS' "${HOME}/.spawnrc" 2>/dev/null || \
printf '\nexport CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1\n' >> "${HOME}/.spawnrc"
fi
This is idempotent — it only appends once. All four service scripts (discovery.sh, refactor.sh, security.sh, qa.sh) include this check.
All service scripts use agent teams, not subagents. Key differences:
| Subagents | Agent Teams | |
|---|---|---|
| Communication | Results return to caller only | Teammates message each other directly |
| Context | Shares caller's context | Independent context window |
| Coordination | Caller manages all work | Shared task list with self-coordination |
Team coordination pattern for claude -p mode
In claude -p (print) mode, the session ends when no tool call is made. The lead must stay alive by always including a tool call. The correct monitoring loop is:
1. Call TaskList to check task status
2. Process any teammate messages (they arrive automatically as user turns)
3. If tasks still pending, call Bash("sleep 15") to yield, then go back to step 1
4. Once all tasks complete, shutdown teammates and exit
EVERY iteration MUST call TaskList. Looping on sleep alone blocks message delivery without checking progress. This is the #1 cause of stuck cycles.
Spawning teammates correctly
When spawning teammates via the Task tool, always pass team_name and name so they join the team:
Task(subagent_type='general-purpose', team_name='my-team', name='reviewer-1', prompt='...')
Without team_name, agents spawn as subagents that can't use team messaging.
Prompt completeness
Each teammate gets its own context window and cannot see other teammates' prompts. Always include the COMPLETE instructions in every teammate's prompt. Never abbreviate with "follow the same protocol as agent X".
Git Conventions for Agent Team Scripts
All agent team scripts (discovery.sh, refactor.sh, and any future scripts) MUST instruct their agents to follow these conventions:
1. Always pull main before creating worktrees
Agents MUST fetch and pull the latest main before starting any branch work:
git fetch origin main
git pull origin main
2. Use git worktrees for ALL work (mandatory)
Every agent MUST work in a git worktree — NEVER operate directly in the main repo checkout. This applies to all work: creating branches, reviewing PRs, running tests, reading code for audits, etc.
When multiple agents work in parallel, they MUST use worktrees instead of git checkout -b to avoid clobbering each other's uncommitted changes:
# Fetch latest main first
git fetch origin main
# Create worktree from latest origin/main
git worktree add /tmp/spawn-worktrees/BRANCH-NAME -b BRANCH-NAME origin/main
# Work inside the worktree
cd /tmp/spawn-worktrees/BRANCH-NAME
# ... make changes, run tests, etc. ...
# Commit, push, create PR
git push -u origin BRANCH-NAME
gh pr create --title "..." --body "...
-- TEAM-NAME/AGENT-NAME"
# Clean up
git worktree remove /tmp/spawn-worktrees/BRANCH-NAME
For PR review/testing (read-only worktree):
git worktree add /tmp/spawn-worktrees/pr-NUMBER -b review-pr-NUMBER origin/main
cd /tmp/spawn-worktrees/pr-NUMBER
gh pr checkout NUMBER
# ... run bash -n, bun test, read files ...
cd /path/to/repo
git worktree remove /tmp/spawn-worktrees/pr-NUMBER --force
Why: The main checkout must stay clean so concurrent agents don't conflict. Worktrees provide isolated working directories for each agent.
3. Include Agent markers in commits
Every agent commit MUST include an Agent: trailer identifying which agent authored it:
feat: Add RunPod cloud provider
Agent: cloud-scout
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
4. Clean up worktrees at end of cycle
The team lead or cleanup function must prune stale worktrees:
git worktree prune
rm -rf /tmp/spawn-worktrees
5. Comment sign-off for dedup
Every comment posted by an agent on issues or PRs MUST end with a sign-off line in this format:
-- team/agent-name
Format: -- <team-name>/<agent-name> using double-hyphen (--), not emdash.
Examples:
-- security/triage
-- security/pr-reviewer
-- security/issue-checker
-- security/scan
-- refactor/community-coordinator
-- refactor/pr-maintainer
-- discovery/issue-responder
-- discovery/cloud-scout
-- qa/test-runner
-- qa/dedup-scanner
-- qa/code-quality
-- qa/fixture-collector
-- qa/issue-fixer
Why: Agents run on schedules (every 15-30 min). Without sign-offs, the same issue gets re-triaged and re-commented every cycle. The sign-off lets each agent grep for its own prior comments and skip duplicates:
# Check if this agent already commented on this issue
gh issue view NUMBER --json comments --jq '.comments[].body' | grep -q '-- security/triage'
Rules:
- Use
--(double hyphen), never—(emdash) — emdash causes encoding issues in shell strings - The team name matches the script:
security.sh→security,refactor.sh→refactor,discovery.sh→discovery,qa.sh→qa - The agent name matches the teammate name defined in the prompt (e.g.,
pr-reviewer,community-coordinator,issue-responder) - Sign-off goes on its own line at the very end of the comment body
- For PR review bodies, wrap in italics:
*-- security/pr-reviewer*
These conventions are already embedded in the prompts of discovery.sh, refactor.sh, security.sh, and qa.sh. When adding new service scripts, copy the same patterns.
Step 10: Commit and push
Commit the workflow file and .gitignore changes (but NOT the wrapper script):
git add .github/workflows/<service-name>.yml .gitignore
git commit -m "feat: Add GitHub Actions trigger for <service-name>"
git push origin main
Step 11: Test end-to-end
# Trigger manually via GitHub Actions
gh workflow run <service-name>.yml --repo <owner>/<repo>
# Watch the run
gh run list --repo <owner>/<repo> --workflow <service-name>.yml --limit 1
# Check run logs
gh run view <run-id> --repo <owner>/<repo> --log
Verify the trigger server accepts the request and the target script runs.
Multiple Services on Different VMs
Each VM gets its own:
start-<service-name>.shwrapper with its ownTRIGGER_SECRETandTARGET_SCRIPT- GitHub Actions workflow file
- Pair of GitHub secrets (
<SERVICE>_SPRITE_URL+<SERVICE>_TRIGGER_SECRET)
The trigger-server.ts file is shared — same code runs on every VM, configured only by env vars.
Adding New Service Scripts
To add a new automation script (beyond discovery.sh and refactor.sh):
- Create the script in
$REPO_ROOT/.claude/skills/setup-agent-team/<script-name>.sh - Make it executable:
chmod +x <script-name>.sh - Ensure it follows the single-cycle pattern (sync with origin, run once, exit)
- Create a corresponding
start-<script-name>.shwrapper with the appropriate env vars - Follow the setup steps above to register the service and create the GitHub Actions workflow
Troubleshooting
| Problem | Fix |
|---|---|
| Service won't start | Check if another service is using port 8080 |
| 401 on trigger | Verify TRIGGER_SECRET matches between wrapper script and GitHub secret |
| curl exits with code 22 | HTTP error — --fail-with-body prints the JSON body (429/409/401) |
| Script runs but nothing happens | Check the target script works standalone: bash /path/to/script.sh |
| VM doesn't respond | Verify <SERVICE>_SPRITE_URL secret matches the service's public URL |
{"error":"max concurrent runs reached"} |
Max concurrent limit reached (default 1) — wait for runs to finish or increase MAX_CONCURRENT env var in wrapper script |
| env vars not passed | Use the wrapper script pattern (not --env flag with commas in values) |
| GitHub Actions secret is empty | Check gh secret list --repo <owner>/<repo> and re-set with printf (not echo, to avoid trailing newline) |
| systemd service won't start | Check journalctl -u spawn-<name> -n 50 — common issues: port in use (EADDRINUSE), wrong PATH (bun/claude not found), permission denied |
| systemd service keeps restarting | Check exit code in systemctl status — if exit 1, check journal logs. If EADDRINUSE, run fuser -k 8080/tcp first |
| Run status unknown | Use GET /health to check active runs, or check VM logs via journalctl -u spawn-<name> |
Current Deployed Services
| Workflow | Host | Service Type | Service Name | Secrets |
|---|---|---|---|---|
discovery.yml (Trigger Discovery) |
VM | systemd | discovery-trigger |
DISCOVERY_SPRITE_URL, DISCOVERY_TRIGGER_SECRET |
refactor.yml (Trigger Refactor) |
VM | systemd | refactor |
REFACTOR_SPRITE_URL, REFACTOR_TRIGGER_SECRET |
security.yml (Trigger Security) |
VM | systemd | spawn-security |
SECURITY_SPRITE_URL, SECURITY_TRIGGER_SECRET |
Source: openrouterteam/spawn — distributed by TomeVault.