IssueCommand is a Bun application that coordinates GitHub issue claims across multiple coding agents. It runs two transports in one process:
- MCP stdio server for local agent tooling.
- HTTP JSON + SSE server for remote agents.
- Claim exclusivity with per-issue locking.
- Idempotent re-claim for the current owner.
- Stale detection and optional auto-release.
- PR follow-up queue from GitHub webhook events (
changes_requested, PR comments, review comments). - GitHub sync for external closures and metadata updates.
- Crash recovery through durable SQLite persistence (relational row-level writes).
- Real-time claim/sync events via SSE.
- Bun 1.3+
- GitHub PAT with scopes:
reporead:orgread:issuewrite:issue
- GitHub CLI (
gh) is optional; IssueCommand uses it when available and falls back to REST API automatically.
- Install dependencies:
bun install- Create env config:
cp .env.example .env-
Update
.envvalues. -
Start IssueCommand:
bun run startHTTP_PORT defaults to 3100.
State is persisted in a local SQLite DB file (SQLITE_PATH) and survives restarts/redeployments.
| Variable | Required | Default | Description |
|---|---|---|---|
GITHUB_TOKEN |
yes | - | GitHub PAT used for API/CLI auth |
API_KEY |
yes | - | Required bearer token for all HTTP endpoints and SSE |
HTTP_PORT |
no | 3100 |
HTTP/SSE port |
TRUST_PROXY |
no | false |
Trust x-forwarded-for / x-real-ip for client IP derivation (set true only behind a trusted proxy) |
SQLITE_PATH |
no | ./issuecommand.db |
SQLite database file path for durable local persistence |
SQLITE_BUSY_TIMEOUT_MS |
no | 5000 |
SQLite busy timeout for concurrent access retries |
SQLITE_JOURNAL_MODE |
no | WAL |
SQLite journal mode (recommended: WAL) |
WEBHOOK_DEDUPE_MAX_ENTRIES |
no | 20000 |
Max persisted GitHub webhook delivery IDs retained for duplicate protection |
WEBHOOK_ENABLED |
no | true |
Enable GitHub webhook ingestion endpoint |
WEBHOOK_PATH |
no | /api/webhooks/github |
Path used for GitHub webhook ingestion |
GITHUB_WEBHOOK_SECRET |
no | "" |
HMAC secret for GitHub webhook signatures (X-Hub-Signature-256) |
CLAIM_TIMEOUT_MINUTES |
no | 120 |
Minutes since last update before claim is marked stale |
STALE_AUTO_RELEASE_MINUTES |
no | 0 |
Minutes after stale before auto-release (0 disables) |
AUTO_CLOSE_GITHUB_ISSUE |
no | false |
If true, close GitHub issue when claim status becomes closed |
HISTORY_MAX_ENTRIES |
no | 1000 |
Maximum number of completed/released claims retained in memory/state |
FOLLOWUP_STALE_MINUTES |
no | 1440 |
Minutes since last update before an active follow-up is marked stale |
FOLLOWUP_MAX_ENTRIES |
no | 2000 |
Maximum number of completed/dismissed follow-ups retained in memory/state |
SYNC_INTERVAL_MINUTES |
no | 15 |
GitHub reconciliation interval |
ALLOWED_REPOS |
no | "" |
Comma-separated owner/repo whitelist |
LOG_FILE |
no | "" |
Optional JSON log output file |
RATE_LIMIT_ENABLED |
no | true |
Enable in-memory HTTP throttling |
RATE_LIMIT_IP_PER_MINUTE |
no | 120 |
Per-IP request refill rate for /api/* |
RATE_LIMIT_IP_BURST |
no | 40 |
Per-IP burst capacity for /api/* |
RATE_LIMIT_AGENT_MUTATIONS_PER_MINUTE |
no | 40 |
Mutation refill rate applied to both authenticated principal and agent_id |
RATE_LIMIT_AGENT_MUTATIONS_BURST |
no | 20 |
Mutation burst capacity applied to both authenticated principal and agent_id |
RATE_LIMIT_SSE_CONNECT_PER_MINUTE |
no | 10 |
Per-IP SSE connection-attempt refill rate |
RATE_LIMIT_SSE_CONNECT_BURST |
no | 10 |
Per-IP SSE connection-attempt burst |
list_reposlist_open_issuesget_issue_detailsnext_issuenext_workclaim_issuerelease_issueupdate_claim_statusget_followupsupdate_followup_statusget_my_claimsget_my_workget_all_claimsget_claim_historysystem_health
release_issuerequiresclaim_idandagent_id(reasonoptional).update_claim_statusrequiresclaim_id,agent_id, andstatus(note/pr_urloptional).
All HTTP endpoints except webhook ingestion require:
Authorization: Bearer <API_KEY>Webhook ingestion (WEBHOOK_PATH, default /api/webhooks/github) accepts either:
Authorization: Bearer <API_KEY>- GitHub
X-Hub-Signature-256validated withGITHUB_WEBHOOK_SECRET
GET /sseGET /api/reposGET /api/repos/:owner/:repo/issuesGET /api/issues/:owner/:repo/:numberPOST /api/nextPOST /api/next-workPOST /api/claimsDELETE /api/claims/:claim_idPATCH /api/claims/:claim_idGET /api/followupsPATCH /api/followups/:work_item_idGET /api/my-workPOST /api/webhooks/github(or configuredWEBHOOK_PATH, auth via API key or GitHub signature)GET /api/claimsGET /api/health
Get repos:
curl -H "Authorization: Bearer $API_KEY" \
http://localhost:3100/api/reposClaim issue:
curl -X POST http://localhost:3100/api/claims \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"agent_id":"claude-code-macmini-1","repo":"myorg/api","issue_number":42}'Claim next issue in one call:
curl -X POST http://localhost:3100/api/next \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"agent_id":"claude-code-macmini-1","repo":"myorg/api"}'Claim next work item (PR follow-up first, issue fallback):
curl -X POST http://localhost:3100/api/next-work \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"agent_id":"claude-code-macmini-1","repo":"myorg/api"}'Update status:
curl -X PATCH http://localhost:3100/api/claims/<claim_id> \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"agent_id":"claude-code-macmini-1","status":"in_progress"}'Release claim:
curl -X DELETE http://localhost:3100/api/claims/<claim_id> \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"agent_id":"claude-code-macmini-1","reason":"handing off"}'Subscribe to events:
curl -N -H "Authorization: Bearer $API_KEY" http://localhost:3100/sseSend a signed GitHub webhook (example):
BODY='{"action":"submitted","repository":{"full_name":"myorg/api"}}'
SIG="sha256=$(printf "%s" "$BODY" | openssl dgst -sha256 -hmac "$GITHUB_WEBHOOK_SECRET" -hex | sed 's/^.* //')"
curl -X POST http://localhost:3100/api/webhooks/github \
-H "Content-Type: application/json" \
-H "X-GitHub-Event: pull_request_review" \
-H "X-GitHub-Delivery: demo-delivery-id" \
-H "X-Hub-Signature-256: $SIG" \
-d "$BODY"- Agent requests next work:
{"agent_id":"claude-code-1","repo":"myorg/api"}- IssueCommand returns either:
- PR follow-up work item (
kind: "pr_followup") when review feedback is queued, or - issue claim + issue details (
kind: "issue") when no follow-up is queued.
- PR follow-up work item (
- For issue work, agent updates claim status through
in_progress->pr_submitted->pr_merged->closed. - For follow-up work, agent updates follow-up status (typically
in_progressthendone). - GitHub webhook events can enqueue new follow-up work or auto-resolve stale follow-ups on PR
synchronize.
IssueCommand includes a hardened container profile with:
restart: unless-stopped- read-only root filesystem
- dropped Linux capabilities
no-new-privileges- authenticated healthcheck against
/api/health - persistent claim state volume (
issuecommand_data)
Run:
bun run docker:upStop:
bun run docker:downThe container persists state at /app/data/issuecommand.db via the named volume.
ecosystem.config.cjs is included for host deployments without containers.
Important: run one instance only (instances: 1) because v1 uses in-memory locks/state authority.
Start:
export $(grep -v '^#' .env | xargs)
bun run pm2:startRestart with updated env:
bun run pm2:restartStop:
bun run pm2:stopclaude_desktop_config.json:
{
"mcpServers": {
"issuecommand": {
"command": "bun",
"args": ["run", "/absolute/path/to/issuecommand/src/index.ts"],
"env": {
"GITHUB_TOKEN": "ghp_your_token",
"API_KEY": "shared-secret",
"HTTP_PORT": "3100",
"TRUST_PROXY": "false",
"SQLITE_PATH": "/absolute/path/to/issuecommand/issuecommand.db",
"SQLITE_BUSY_TIMEOUT_MS": "5000",
"SQLITE_JOURNAL_MODE": "WAL",
"WEBHOOK_DEDUPE_MAX_ENTRIES": "20000",
"WEBHOOK_ENABLED": "true",
"WEBHOOK_PATH": "/api/webhooks/github",
"GITHUB_WEBHOOK_SECRET": "",
"CLAIM_TIMEOUT_MINUTES": "120",
"STALE_AUTO_RELEASE_MINUTES": "0",
"HISTORY_MAX_ENTRIES": "1000",
"FOLLOWUP_STALE_MINUTES": "1440",
"FOLLOWUP_MAX_ENTRIES": "2000",
"SYNC_INTERVAL_MINUTES": "15",
"ALLOWED_REPOS": "",
"LOG_FILE": "",
"AUTO_CLOSE_GITHUB_ISSUE": "false",
"RATE_LIMIT_ENABLED": "true",
"RATE_LIMIT_IP_PER_MINUTE": "120",
"RATE_LIMIT_IP_BURST": "40",
"RATE_LIMIT_AGENT_MUTATIONS_PER_MINUTE": "40",
"RATE_LIMIT_AGENT_MUTATIONS_BURST": "20",
"RATE_LIMIT_SSE_CONNECT_PER_MINUTE": "10",
"RATE_LIMIT_SSE_CONNECT_BURST": "10"
}
}
}
}bun run typecheck
bun test- Logs are emitted on stderr to avoid interfering with MCP stdio protocol output.
- By default, state is persisted to SQLite (
SQLITE_PATH) and loaded on startup. - SQLite persistence uses relational tables with incremental row updates (active claims/followups, history, and runtime metadata), not full-state blob rewrites.
- Completed claim history is bounded by
HISTORY_MAX_ENTRIES; oldest records are trimmed first. - Follow-up history is bounded by
FOLLOWUP_MAX_ENTRIES; oldest records are trimmed first. - Webhook delivery dedupe keys are also persisted in SQLite for duplicate protection across restarts.
- Startup logs include
ghCLI availability (github.gh.availableorgithub.gh.unavailable). - HTTP rate limiting returns
429with aRetry-Afterheader and JSON body{ error: \"rate_limited\", scope, retry_after_seconds }. - Mutation routes enforce rate limits by both authenticated principal and provided
agent_id. TRUST_PROXY=falseis the safe default; enable it only when a trusted reverse proxy sanitizes forwarding headers.- Webhook ingestion accepts either
Authorization: Bearer <API_KEY>or GitHubX-Hub-Signature-256whenGITHUB_WEBHOOK_SECRETis set.