cd /news/ai-agents/a-zero-dependency-github-issue-polle… Β· home β€Ί topics β€Ί ai-agents β€Ί article
[ARTICLE Β· art-15184] src=gist.github.com pub= topic=ai-agents verified=true sentiment=Β· neutral

A zero-dependency GitHub Issue poller for multi-agent coding teams

A developer has created a zero-dependency GitHub Issue poller designed for multi-agent coding teams. The Node.js script, named gh-agent-watch.js, monitors a repository for issues labeled with specific agent rolesβ€”such as "agent:tester" or "agent:implementer"β€”and signals the appropriate AI agent when new work arrives or existing tickets are updated. The tool enforces a routing system where agents claim, reassign, and resolve issues through label-based queues, eliminating the need for manual polling.

read20 min publishedMay 27, 2026

Save atraining/d666e2d20e5abfdf92eb74a0d5f4918d to your computer and use it in GitHub Desktop.

Learn more about bidirectional Unicode characters

| #!/usr/bin/env node | | | /** | | | * gh-agent-watch.js β€” GitHub-Issue poller for multi-agent coding teams | | | * | | | * ============================================================================= | | | * WHAT THIS DOES (for AI agents reading this file) | | | * ============================================================================= | | | * | | | * This script is your "inbox watcher". It polls a GitHub repo for issues | | | * labelled with your agent name (e.g. "agent:tester") and wakes you whenever | | | * something new arrives or an existing issue is updated. | | | * | | | * You run it once at startup inside a persistent Monitor process. Every line it | | | * prints to stdout is a signal for your orchestrator to wake you and let you | | | * act. Between wakes, you sleep. You do NOT need to poll manually. | | | * | | | * ============================================================================= | | | * TEAM MODEL (generic, rename roles to fit your project) | | | * ============================================================================= | | | * | | | * Any number of agents can participate. Each has a unique role name. Common | | | * example set: | | | * | | | * implementer β€” writes and commits code (the only agent with write access | | | * to source files; all others review, advise, or document) | | | * tester β€” writes test cases, user journeys, and acceptance criteria; | | | * reports failures as new issues to implementer | | | * reviewer β€” code review, architecture, and technical quality gating | | | * docs β€” user-facing documentation; touches no source code | | | * legal β€” compliance, licensing, regulatory gating before release | | | * market β€” competitive analysis, domain model, product direction | | | * | | | * You can use any names you like. The only contract: every agent's name must | | | * match exactly the label suffix used in GitHub, e.g. agent:tester. | | | * | | | * ============================================================================= | | | * HOW ROUTING WORKS | | | * ============================================================================= | | | * | | | * Issues are routed by label. An issue labelled "agent:tester" sits in the | | | * tester's queue. Routing rules (for AI agents): | | | * | | | * - To send an issue TO another agent: | | | * gh issue edit <num> --add-label "agent:<target>" \ | | | * --remove-label "agent:<you>" | | | * Add a one-line comment explaining WHY you are re-routing. | | | * | | | * - To create a new issue for another agent: | | | * gh issue create --repo <owner/repo> \ | | | * --title "<Area>: <what> (<why>)" \ | | | * --label "agent:<target>" --label "<theme>" \ | | | * --body "..." | | | * | | | * - To respond to the current owner of an issue: comment on it. | | | * gh issue comment <num> --body "..." | | | * | | | * - To close a resolved issue: | | | * gh issue close <num> --comment "Done. <commit-sha or reason>" | | | * | | | * - FREI issues (no agent:* label) are visible to every agent's heartbeat. | | | * Any agent may claim them by adding their own label and a comment. | | | * | | | * ============================================================================= | | | * QUEUE DISCIPLINE (rules for AI agents, non-negotiable) | | | * ============================================================================= | | | * | | | * 1) idle != done. | | | * The heartbeat prints either "idle, queue empty" (genuinely done) or | | | * "idle, N open tickets" followed by PENDING lines (NOT done β€” those are | | | * your open assignments). When you see PENDING lines, your next action is | | | * to open the top ticket and work it, not report "idle". | | | * | | | * 2) Every ticket must be actively resolved. For each open ticket decide: | | | * (a) handle it yourself and close with a reason / commit SHA | | | * (b) re-route to the correct agent with a one-line comment | | | * (c) escalate to a human (label "agent:human") with a clear question | | | * Leaving a ticket untouched is not allowed. | | | * | | | * 3) Stuck? Cross-comment. If you are blocked on a ticket for >3 rounds | | | * without progress, look for thematically related tickets (same theme:* | | | * label) in other agents' queues and comment there. You may comment on any | | | * ticket. You may NOT take ownership (change the agent label) unless the | | | * ticket has had no activity for 7+ days β€” then claim it and explain why. | | | * | | | * ============================================================================= | | | * SELF-MUTE AFTER YOUR OWN POSTS (--ack flag) | | | * ============================================================================= | | | * | | | * Problem: this watcher cannot tell whether a new comment was written by YOU | | | * or by another agent, because all agents may share a single GitHub account | | | * (e.g. a bot account). So your own comment would trigger a spurious WAKE. | | | * | | | * Fix: after you post a comment or edit labels on an issue, run: | | | * | | | * node gh-agent-watch.js <your-name> --ack <issue-num> [<issue-num> ...] | | | * | | | * This re-baselines that issue's signature in the local state file so the next | | | * poll sees no diff. It exits immediately (no poll loop). | | | * | | | * ============================================================================= | | | * USAGE | | | * ============================================================================= | | | * | | | * # Start the watcher (run inside a persistent/Monitor process): | | | * node gh-agent-watch.js <agent-name> [--repo owner/repo] [--interval 60] | | | * | | | * # Acknowledge your own edits (self-mute, call after every gh post/edit): | | | * node gh-agent-watch.js <agent-name> --ack <num> [<num> ...] | | | * | | | * # Examples: | | | * node gh-agent-watch.js tester --repo myorg/myproject | | | * node gh-agent-watch.js implementer --repo myorg/myproject --interval 30 | | | * node gh-agent-watch.js tester --ack 42 43 | | | * | | | * Prerequisites: gh CLI installed and authenticated (gh auth login). | | | * | | | * ============================================================================= | | | * OUTPUT FORMAT (for orchestrators parsing stdout) | | | * ============================================================================= | | | * | | | * WAKE NEU #<n> <title> β€” new issue just appeared in your queue | | | * WAKE UPDATE #<n> <title> β€” existing issue was updated (new comment etc) | | | * PENDING #<n> <title> β€” heartbeat: open issue already in your queue | | | * FREI #<n> [<age>] <title> β€” unclaimed issue (no agent:* label) | | | * ROLLE: <agent> β€” <reminder> β€” heartbeat role reminder line | | | * [gh-agent-watch <agent>] ... β€” informational/error lines (not action signals) | | | * | | | * ============================================================================= | | | * SETUP IN 3 STEPS | | | * ============================================================================= | | | * | | | * Step 1 β€” Install gh CLI and authenticate: | | | * https://cli.github.com/ β†’ gh auth login | | | * | | | * Step 2 β€” Create agent labels in your GitHub repo (once, by a human or script): | | | * gh label create "agent:implementer" --repo owner/repo --color "0075ca" | | | * gh label create "agent:tester" --repo owner/repo --color "e4e669" | | | * gh label create "agent:reviewer" --repo owner/repo --color "d93f0b" | | | * gh label create "agent:docs" --repo owner/repo --color "0e8a16" | | | * gh label create "agent:human" --repo owner/repo --color "ffffff" | | | * # Add more as needed. | | | * | | | * Step 3 β€” Start each agent's watcher in a persistent process: | | | * node gh-agent-watch.js implementer --repo owner/repo | | | * node gh-agent-watch.js tester --repo owner/repo | | | * # etc. | | | * | | | * State is stored in ~/.gh-agent-watch/<agent-name>.json (cross-session). | | | * Delete the file to reset seen-state (causes all open issues to fire as NEU). | | | * | | | * ============================================================================= | | | / | | | const { execSync } = require('node:child_process'); | | | const fs = require('node:fs'); | | | const path = require('node:path'); | | | // --------------------------------------------------------------------------- | | | // Argument parsing | | | // --------------------------------------------------------------------------- | | | const args = process.argv.slice(2); | | | if (!args[0] || args[0].startsWith('-')) { | | | console.error('Usage: gh-agent-watch.js <agent-name> [--repo owner/repo] [--interval 60]'); | | | console.error(' gh-agent-watch.js <agent-name> --ack <num> [<num> ...]'); | | | console.error(''); | | | console.error('The agent-name must match the label suffix used in GitHub.'); | | | console.error('Example label: "agent:tester" β†’ agent-name: "tester"'); | | | process.exit(2); | | | } | | | const agent = args[0]; | | | // --repo (default: tries to read from gh repo view in cwd, falls back to error) | | | const repoFlagIdx = args.indexOf('--repo'); | | | const repoFlagEq = (args.find(a => a.startsWith('--repo=')) || '').split('=')[1]; | | | let repo = repoFlagEq | | | || (repoFlagIdx >= 0 ? args[repoFlagIdx + 1] : null); | | | if (!repo) { | | | // Auto-detect from current working directory | | | try { | | | repo = execSync('gh repo view --json nameWithOwner -q .nameWithOwner', { | | | encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] | | | }).trim(); | | | } catch { | | | console.error('[gh-agent-watch] Could not detect repo. Pass --repo owner/repo explicitly.'); | | | process.exit(2); | | | } | | | } | | | // --interval in seconds (default 60) | | | const intervalFlagEq = (args.find(a => a.startsWith('--interval=')) || '').split('=')[1]; | | | const intervalFlagIdx = args.indexOf('--interval'); | | | const interval = Number( | | | intervalFlagEq | | | || (intervalFlagIdx >= 0 ? args[intervalFlagIdx + 1] : 60) | | | ) || 60; | | | // --ack <num> [<num> ...] | | | const ackIdx = args.indexOf('--ack'); | | | const ackNums = ackIdx >= 0 | | | ? args.slice(ackIdx + 1).filter(a => /^\d+$/.test(a)).map(Number) | | | : []; | | | // --------------------------------------------------------------------------- | | | // State persistence (~/.gh-agent-watch/<agent>.json) | | | // --------------------------------------------------------------------------- | | | const stateDir = path.join(process.env.HOME || process.env.LOCALAPPDATA || '.', '.gh-agent-watch'); | | | fs.mkdirSync(stateDir, { recursive: true }); | | | const stateFile = path.join(stateDir, ${agent}.json); | | | function loadState() { | | | try { return JSON.parse(fs.readFileSync(stateFile, 'utf8')); } | | | catch { return { seen: {} }; } | | | } | | | function saveState(s) { | | | fs.writeFileSync(stateFile, JSON.stringify(s, null, 2)); | | | } | | | // --------------------------------------------------------------------------- | | | // Poll FREI: open issues with no agent: label (fall-through / unclaimed) | | | // These appear in every agent's heartbeat so nothing gets lost. | | | // --------------------------------------------------------------------------- | | | function pollFrei() { | | | try { | | | const raw = execSync( | | | gh issue list --repo ${repo} --state open --limit 200 --json number,title,labels,createdAt, | | | { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } | | | ); | | | return JSON.parse(raw).filter(i => | | | !i.labels.some(l => l.name.startsWith('agent:')) | | | ); | | | } catch { | | | return []; | | | } | | | } | | | // --------------------------------------------------------------------------- | | | // Main poll: check for new or updated issues in this agent's queue | | | // --------------------------------------------------------------------------- | | | function poll() { | | | let issues; | | | try { | | | const raw = execSync( | | | gh issue list --repo ${repo} --label "agent:${agent}" --state open --limit 200 | | | + --json number,title,updatedAt,comments,createdAt, | | | { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } | | | ); | | | issues = JSON.parse(raw); | | | } catch (e) { | | | console.error([gh-agent-watch ${agent}] gh failed: ${e.message.split('\n')[0]}); | | | return; | | | } | | | const state = loadState(); | | | const seen = state.seen || {}; | | | let changed = 0; | | | for (const i of issues) { | | | const key = String(i.number); | | | // Signature: updatedAt + comment count. Any change fires a WAKE. | | | const sig = ${i.updatedAt}|${(i.comments || []).length}; | | | if (seen[key] !== sig) { | | | const verb = seen[key] === undefined ? 'NEU' : 'UPDATE'; | | | console.log(WAKE ${verb} #${i.number} ${i.title}); | | | seen[key] = sig; | | | changed++; | | | } | | | } | | | state.seen = seen; | | | state.lastPoll = new Date().toISOString(); | | | saveState(state); | | | // Heartbeat β€” emitted every ~5 poll intervals when nothing changed. | | | // Tells the agent about its full open queue and any unclaimed issues. | | | if (changed === 0) { | | | const now = Date.now(); | | | const heartbeatDue = !state.heartbeat | | | || Date.now() - state.heartbeat > 5 * interval * 1000; | | | if (heartbeatDue) { | | | const lines = []; | | | if (issues.length === 0) { | | | lines.push([gh-agent-watch ${agent}] idle, queue empty); | | | } else { | | | lines.push([gh-agent-watch ${agent}] idle, ${issues.length} open ticket(s) in queue:); | | | // Cluster by age: NEU < 2h, MID 2-24h, ALT > 24h | | | const neu = [], mid = [], alt = []; | | | for (const i of issues) { | | | const ageH = (now - new Date(i.createdAt)) / 36e5; | | | if (ageH < 2) neu.push(i); | | | else if (ageH < 24) mid.push(i); | | | else alt.push(i); | | | } | | | const byUpdated = (a, b) => new Date(b.updatedAt) - new Date(a.updatedAt); | | | const fmt = (i) => PENDING #${i.number} ${i.title}; | | | if (neu.length > 0) { | | | lines.push('--- NEW (just assigned) ---'); | | | for (const i of neu.sort(byUpdated)) lines.push(fmt(i)); | | | } | | | for (const i of mid.sort(byUpdated)) lines.push(fmt(i)); | | | if (alt.length > 0) { | | | lines.push('--- STALE (> 24h in queue) ---'); | | | for (const i of alt.sort(byUpdated)) lines.push(fmt(i)); | | | } | | | } | | | // Unclaimed issues β€” visible across all agents' heartbeats | | | const frei = pollFrei(); | | | if (frei.length > 0) { | | | lines.push( UNCLAIMED (no agent label): ${frei.length} open); | | | for (const i of frei.slice(0, 5)) { | | | const ageH = Math.round((now - new Date(i.createdAt)) / 36e5); | | | const age = ageH < 2 ? 'new' : ageH < 24 ? ${ageH}h : ${Math.round(ageH / 24)}d; | | | lines.push( FREI #${i.number} [${age}] ${i.title}); | | | } | | | if (frei.length > 5) lines.push( ... and ${frei.length - 5} more); | | | } | | | for (const l of lines) console.log(l); | | | // Role reminder β€” nudges agent to act on PENDING tickets, not stay idle | | | console.log( | | | ROLLE: ${agent} β€” read your agent self-doc (agenten/${agent}.md or equivalent). | | | + idle != done: PENDING = your open assignments. | | | + Consult specialist agents before escalating to human. | | | + Check other agents' open tickets proactively for issues you can help with. | | | ); | | | state.heartbeat = now; | | | saveState(state); | | | } | | | } | | | } | | | // --------------------------------------------------------------------------- | | | // --ack mode: re-baseline specific issues to suppress self-triggered wakes. | | | // | | | // WHY: If all agents post under the same GitHub account (e.g. a shared bot), | | | // the watcher cannot use viewerDidAuthor to distinguish "I posted this" from | | | // "another agent posted this". Without --ack, every comment you write would | | | // immediately trigger a WAKE UPDATE on your own issue. | | | // | | | // HOW: After posting a comment or editing labels, call: | | | // node gh-agent-watch.js <agent> --ack <issue-num> [...] | | | // This fetches the issue's current state, writes its signature into seen, | | | // and exits. The next regular poll finds no diff and stays silent. | | | // --------------------------------------------------------------------------- | | | function ack(nums) { | | | const state = loadState(); | | | const seen = state.seen || {}; | | | for (const num of nums) { | | | let raw; | | | try { | | | raw = execSync( | | | gh issue view ${num} --repo ${repo} --json number,updatedAt,comments, | | | { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } | | | ); | | | } catch (e) { | | | console.error([gh-agent-watch ${agent}] --ack #${num}: gh failed: ${e.message.split('\n')[0]}); | | | continue; | | | } | | | const i = JSON.parse(raw); | | | const sig = ${i.updatedAt}|${(i.comments || []).length}; | | | seen[String(i.number)] = sig; | | | console.log([gh-agent-watch ${agent}] ack #${i.number} -> ${sig}); | | | } | | | state.seen = seen; | | | saveState(state); | | | } | | | // --------------------------------------------------------------------------- | | | // Entry point | | | // --------------------------------------------------------------------------- | | | if (ackNums.length > 0) { | | | ack(ackNums); | | | process.exit(0); | | | } | | | console.log([gh-agent-watch ${agent}] started β€” repo=${repo}, interval=${interval}s); | | | console.log([gh-agent-watch ${agent}] watching label "agent:${agent}" for new/updated issues); | | | console.log([gh-agent-watch ${agent}] state file: ${stateFile}); | | | poll(); | | | setInterval(poll, interval * 1000); |

A zero-dependency GitHub Issue poller for multi-agent coding teams.

Each AI agent runs one instance. It watches for issues labelled agent:<name>

and prints a WAKE

line to stdout whenever something new arrives. Your agent

orchestrator (Claude Code, a Monitor process, etc.) picks up that line and wakes

the agent to act.

Why this exists #

When multiple AI agents collaborate on a project, they need an inbox. GitHub

Issues are a natural fit: they are persistent, auditable, and accessible from

any terminal. This script gives each agent a continuous, low-noise feed of

exactly the issues that are routed to them β€” nothing more.

Prerequisites #

  • Node.js 18+ installed and authenticated (gh

CLIgh auth login

)- A GitHub repo with agent:*

labels (see setup below)

Quickstart #

curl -O https://raw.githubusercontent.com/yourorg/yourrepo/main/gh-agent-watch.js

REPO=yourorg/yourrepo
for role in implementer tester reviewer docs human; do
  gh label create "agent:$role" --repo $REPO --color "0075ca" 2>/dev/null || true
done

node gh-agent-watch.js implementer --repo yourorg/yourrepo
node gh-agent-watch.js tester      --repo yourorg/yourrepo

If you run from inside the repo directory, --repo

is auto-detected via

gh repo view

.

Agent roles (example team β€” rename freely) #

Agent What they do
implementer
Writes and commits code; the only agent touching source files
tester
Writes test cases, user journeys, reports failures as issues
reviewer
Code review, architecture, technical quality gating
docs
User-facing documentation; never modifies source code
legal
Compliance and regulatory gating before release
market
Competitive analysis, domain model, product direction
human
Escalation target for decisions only a human should make

Use any names you like. The only constraint: the name must match the GitHub

label suffix exactly. Label agent:tester

β†’ agent name tester

.

How routing works #

Issues move between agents by changing the agent:*

label.

gh issue edit 42 --add-label "agent:reviewer" --remove-label "agent:tester"
gh issue comment 42 --body "Routing to reviewer: needs architecture sign-off."

gh issue create --repo yourorg/yourrepo \
  --title "Auth: token refresh missing on 401 (breaks mobile flow)" \
  --label "agent:implementer" --label "theme:auth" \
  --body "**Observed:** ...\n**Why it matters:** ...\n**Suggestion:** ..."

gh issue close 42 --comment "Fixed in commit abc1234."

gh issue edit 42 --add-label "agent:human" --remove-label "agent:tester"
gh issue comment 42 --body "Needs product decision: which behaviour is correct?"

Unclaimed issues (no agent:*

label) appear in every agent's heartbeat as

FREI

lines. Any agent may claim them.

Self-mute after your own posts #

If all agents post under the same GitHub account (e.g. a shared bot), the

watcher cannot distinguish your own comment from another agent's. Your post

would otherwise trigger a spurious WAKE UPDATE

.

Fix: after every gh issue comment

or gh issue edit

, run:

node gh-agent-watch.js tester --ack 42 43

This re-baselines the issue's signature in local state and exits immediately.

Output format #

WAKE NEU #42 Auth: token refresh missing on 401
WAKE UPDATE #7 Docs: onboarding flow needs rewrite
PENDING #12 Tests: checkout flow user journey incomplete
FREI #99 [2h] Refactor: extract payment module
ROLLE: tester β€” read your agent self-doc ...
Prefix Meaning
WAKE NEU
New issue just appeared in your queue
WAKE UPDATE
Existing issue was updated (comment, label change, etc.)
PENDING
Heartbeat: this issue is already open in your queue
FREI
Unclaimed issue (no agent:* label) β€” anyone can take it
ROLLE
Heartbeat role reminder line

State file #

Seen-state is stored in ~/.gh-agent-watch/<agent-name>.json

. Delete it to

reset (all open issues will fire as NEU

on the next poll).

Options #

node gh-agent-watch.js <agent-name> [--repo owner/repo] [--interval 60]
node gh-agent-watch.js <agent-name> --ack <num> [<num> ...]
Flag Default Description
--repo
auto-detect via gh GitHub repo in owner/repo format
--interval
60 Poll interval in seconds
--ack
β€” Re-baseline issues after your own edits

Queue discipline for AI agents #

These are not suggestions. An agent that ignores them blocks the whole team.

idle != done. Heartbeat printsPENDING

lines for your open tickets.

When you see them, act on them β€” do not report "idle". - Every ticket gets resolved. For each open ticket: handle it, re-route

it (with a comment), or escalate toagent:human

. Leaving it untouched is

the one forbidden option. - Stuck? Cross-comment. After 3+ rounds without progress, look for

related tickets in other agents' queues (sametheme:*

label) and comment

there. You may comment anywhere. You may not change ownership unless the

ticket has had no activity for 7+ days.

── more in #ai-agents 4 stories Β· sorted by recency
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/a-zero-dependency-gi…] indexed:0 read:20min 2026-05-27 Β· β€”