Chapter 11 of 12 · Dispatch and telemetry

Bellows keeps the fire going: time, work, learning

Bellows is boring on purpose. It is a state machine on a timer. It reads Linear, runs the agent the state calls for, asks Priya at the gates and writes one line of telemetry for everything it does. The dashboard is those lines added up.

Agent BellowsRuns as claude -p and codex exec on a timerYou need Chapter 7 (gates) and Chapter 8 (Linear) done
The idea

Boring on purpose

The one line

Every other agent thinks. Bellows does not. It reads a state, looks up who runs next, runs them, reads one line back and moves the state. That is the whole job, and the company depends on it being dull.

The loop from chapter 2 only works if something turns it. In a human team that is a project manager nagging in Slack. Here it is a script that wakes every ten minutes. It never has an opinion about the work. It has three rules it will not break: it never merges, it never deploys without Priya's yes, and it never argues with a gate.

Bellows is also the one place where every agent's time is measured. Each run starts with a line in telemetry/events.jsonl and ends with another. Add those up and you know what the crew cost you this week, what it shipped, and how often it changed its own instructions. That is the dashboard.

Danny's real version of this is Development HQ: a Windows scheduled task that runs one review at a time, capped per day, with the evidence written to a Notion database. Same shape. A timer, a plan, a cap, a status line, a write-back.

The script

One file, read from the top

This is all of Bellows. It is long for a kit file because it is the only one that does anything on its own. Read it once here, then the walkthrough below.

#!/usr/bin/env node
// kit/scripts/bellows.mjs — Bellows, the dispatcher.
//
// Runs on a timer (Windows Task Scheduler, cron, or a GitHub Action every 10
// minutes). Each tick: read the Linear issues that changed state, decide which
// agent the state calls for, check the gates, start the agent with the right
// kickoff prompt, parse the last line of what it wrote, move the issue, and
// record time and work in telemetry/events.jsonl. It never merges, never
// deploys without Priya's yes, and never argues with a gate.
//
//   node kit/scripts/bellows.mjs --once          one tick
//   node kit/scripts/bellows.mjs --watch         every 10 minutes
//   node kit/scripts/bellows.mjs --issue ALD-42  force one issue
//
// Env: LINEAR_API_KEY, LINEAR_TEAM (key, e.g. ALD), PREVIEW_URL (optional),
//      GATE_WEBHOOK (Slack incoming webhook or similar), AGENT_TIMEOUT_MIN.

import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';

const ROOT = process.cwd();
const GATES = JSON.parse(readFileSync(join(ROOT, '.claude', 'gates.json'), 'utf8'));
const STATE_FILE = join(ROOT, 'telemetry', 'bellows-state.json');
const state = existsSync(STATE_FILE) ? JSON.parse(readFileSync(STATE_FILE, 'utf8')) : { issues: {} };
const args = process.argv.slice(2);
const TEAM = process.env.LINEAR_TEAM || 'ALD';

// ---- which agent a state calls for --------------------------------------
// Sub-steps inside a state are tracked in state.issues[id].phase so one Linear
// state can run several agents in order (build → cross-review → test → security).
const PLAN = {
  'Idea':            [{ agent: 'anvil',  runner: 'claude', prompt: 'scope' }],
  'Scoped':          [{ agent: 'warden', runner: 'claude', prompt: 'threat', onlyIfFlagged: true }, { agent: 'anvil', runner: 'claude', prompt: 'details' }],
  'Detailed':        [{ agent: 'builder', runner: 'auto',  prompt: 'build' }],
  'In Progress':     [{ agent: 'reviewer', runner: 'auto', prompt: 'review' }],
  'In Test':         [{ agent: 'gauge',  runner: 'codex',  prompt: 'qa' }, { agent: 'warden', runner: 'claude', prompt: 'security' }, { agent: 'beacon', runner: 'claude', prompt: 'journey' }],
  'Ready to Deploy': [{ agent: 'bellows', runner: 'self', prompt: 'deploy' }],
  'Deployed':        [{ agent: 'quill',  runner: 'claude', prompt: 'writeback' }, { agent: 'beacon', runner: 'claude', prompt: 'release-note' }]
};

// ---- Linear -------------------------------------------------------------
async function linear(query, variables) {
  const r = await fetch('https://api.linear.app/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: process.env.LINEAR_API_KEY }, body: JSON.stringify({ query, variables }) });
  const j = await r.json(); if (j.errors) throw new Error(JSON.stringify(j.errors)); return j.data;
}
async function activeIssues() {
  const d = await linear(`query($team:String!){ issues(filter:{ team:{ key:{ eq:$team } }, state:{ name:{ nin:["Deployed","Canceled","Duplicate"] } } }, first:50){ nodes{ id identifier title description estimate labels{ nodes{ name } } state{ name } updatedAt comments{ nodes{ body createdAt } } } } }`, { team: TEAM });
  return d.issues.nodes;
}
async function moveTo(issue, stateName) {
  const d = await linear(`query($team:String!){ workflowStates(filter:{ team:{ key:{ eq:$team } } }){ nodes{ id name } } }`, { team: TEAM });
  const s = d.workflowStates.nodes.find(x => x.name === stateName); if (!s) throw new Error('no state ' + stateName);
  await linear(`mutation($id:String!,$s:String!){ issueUpdate(id:$id, input:{ stateId:$s }){ success } }`, { id: issue.id, s: s.id });
  log({ event: 'state', agent: 'bellows', issue: issue.identifier, to: stateName });
}
async function comment(issue, body) { await linear(`mutation($id:String!,$b:String!){ commentCreate(input:{ issueId:$id, body:$b }){ success } }`, { id: issue.id, b: body }); }

// ---- telemetry ----------------------------------------------------------
function log(ev) { mkdirSync(join(ROOT, 'telemetry'), { recursive: true }); appendFileSync(join(ROOT, 'telemetry', 'events.jsonl'), JSON.stringify({ ts: new Date().toISOString(), ...ev }) + '\n'); }
function save() { mkdirSync(join(ROOT, 'telemetry'), { recursive: true }); writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); }

// ---- gates --------------------------------------------------------------
async function askPriya(issue, escalationId, detail) {
  const rule = GATES.escalations.find(e => e.id === escalationId) || { reason: escalationId };
  const text = `GATE · ${issue.identifier} · ${rule.reason}\n${detail}\nReply in Linear with "approve ${escalationId}" or "reject ${escalationId}".`;
  log({ event: 'gate', agent: 'bellows', issue: issue.identifier, gate: escalationId, decision: 'ask', detail });
  if (process.env.GATE_WEBHOOK) await fetch(process.env.GATE_WEBHOOK, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }) });
  await comment(issue, '🛎️ **Bellows** ' + text);
}
function approved(issue, escalationId) { return issue.comments.nodes.some(c => new RegExp(`^approve ${escalationId}`, 'im').test(c.body)); }
function rejected(issue, escalationId) { return issue.comments.nodes.some(c => new RegExp(`^reject ${escalationId}`, 'im').test(c.body)); }

// ---- running an agent ---------------------------------------------------
function pickBuilder(issue) {
  // Ember for UI-heavy, multi-file slices; Flint for well-specified back-end slices; Anvil's recommendation wins.
  const rec = /builder:\s*(ember|flint)/i.exec(issue.description || '');
  if (rec) return rec[1].toLowerCase();
  const labels = issue.labels.nodes.map(l => l.name.toLowerCase());
  return labels.includes('backend') || labels.includes('api') ? 'flint' : 'ember';
}
function kickoff(promptName, issue, extra) {
  const tpl = readFileSync(join(ROOT, 'kit', 'prompts', promptName + '.md'), 'utf8');
  return tpl.replace(/\{\{ISSUE_ID\}\}/g, issue.identifier).replace(/\{\{ISSUE_TITLE\}\}/g, issue.title).replace(/\{\{PREVIEW_URL\}\}/g, process.env.PREVIEW_URL || '(no preview yet)').replace(/\{\{EXTRA\}\}/g, extra || '');
}
function run(agent, runner, prompt, issue) {
  const started = Date.now();
  const env = { ...process.env, AGENT_NAME: agent, ISSUE_ID: issue.identifier };
  const timeout = (Number(process.env.AGENT_TIMEOUT_MIN) || GATES.budgets.maxMinutesPerRun) * 60 * 1000;
  log({ event: 'start', agent, issue: issue.identifier, runner, prompt });
  let res;
  if (runner === 'codex') {
    // Codex CLI, non-interactive. Check `codex exec --help` in your version for the sandbox flag you want.
    res = spawnSync('codex', ['exec', '--full-auto', prompt], { cwd: ROOT, env, encoding: 'utf8', timeout, shell: process.platform === 'win32' });
  } else {
    // Claude Code headless. The agent file is selected by asking for it in the prompt; hooks and permissions come from .claude/settings.json.
    res = spawnSync('claude', ['-p', prompt, '--output-format', 'text', '--permission-mode', 'acceptEdits'], { cwd: ROOT, env, encoding: 'utf8', timeout, shell: process.platform === 'win32' });
  }
  const out = (res.stdout || '') + (res.stderr || '');
  const minutes = Math.round((Date.now() - started) / 6000) / 10;
  const last = out.trim().split('\n').filter(Boolean).pop() || '';
  log({ event: 'end', agent, issue: issue.identifier, minutes, status: res.status, last });
  return { out, last, minutes };
}

// ---- one tick -----------------------------------------------------------
async function tick(only) {
  const issues = await activeIssues();
  for (const issue of issues) {
    if (only && issue.identifier !== only) continue;
    const st = state.issues[issue.identifier] || (state.issues[issue.identifier] = { phase: 0, state: null, runsToday: 0, day: today(), reviewRounds: 0, qaFails: 0 });
    if (st.day !== today()) { st.day = today(); st.runsToday = 0; }
    if (st.state !== issue.state.name) { st.state = issue.state.name; st.phase = 0; }
    if (st.runsToday >= GATES.budgets.maxRunsPerIssuePerDay) { await askPriya(issue, 'budget', `${st.runsToday} runs today.`); continue; }

    const plan = PLAN[issue.state.name]; if (!plan || st.phase >= plan.length) continue;
    const step = plan[st.phase];
    const flagged = /flags:\s*(?!none)/i.test(issue.description || '');
    if (step.onlyIfFlagged && !flagged) { st.phase++; save(); continue; }

    // The gates that live in the plan itself
    if (step.prompt === 'build' && flagged && !approved(issue, 'customer-data')) { if (!rejected(issue, 'customer-data')) await askPriya(issue, 'customer-data', 'Anvil flagged this slice.'); continue; }
    if (step.prompt === 'build' && (issue.estimate || 0) > 5 && !approved(issue, 'scope-size')) { await askPriya(issue, 'scope-size', `${issue.estimate} points.`); continue; }
    if (step.prompt === 'deploy') {
      if (!approved(issue, 'prod-deploy')) { if (!rejected(issue, 'prod-deploy')) await askPriya(issue, 'prod-deploy', 'Tests green, Warden clear, Beacon reviewed.'); continue; }
      const r = spawnSync('vercel', ['--prod', '--yes'], { cwd: ROOT, encoding: 'utf8', shell: process.platform === 'win32' });
      log({ event: 'deploy', agent: 'bellows', issue: issue.identifier, status: r.status });
      if (r.status === 0) {
        await moveTo(issue, 'Deployed');
        // Deployed issues drop out of activeIssues() on the next tick, so the
        // write-back steps (Quill, then Beacon's release note) run right now.
        for (const s of PLAN['Deployed']) {
          const done = run(s.agent, s.runner, kickoff(s.prompt, issue), issue);
          await comment(issue, `⚙️ **Bellows** ran **${cap(s.agent)}** (${s.runner}, ${done.minutes} min). Result: \`${done.last.slice(0, 160)}\``);
        }
      } else { await askPriya(issue, 'budget', 'Deploy failed:\n' + (r.stderr || '').slice(-800)); }
      continue;
    }

    // Who runs
    let agent = step.agent, runner = step.runner;
    if (agent === 'builder') { agent = st.builder || (st.builder = pickBuilder(issue)); runner = agent === 'flint' ? 'codex' : 'claude'; }
    if (agent === 'reviewer') { agent = st.builder === 'flint' ? 'ember' : 'flint'; runner = agent === 'flint' ? 'codex' : 'claude'; }

    const { last, minutes } = run(agent, runner, kickoff(step.prompt, issue), issue);
    st.runsToday++;

    // Parse the last line and move the state machine
    if (/^ANVIL: Scoped/.test(last)) await moveTo(issue, st.phase === 0 && issue.state.name === 'Idea' ? 'Scoped' : 'Detailed');
    else if (/REQUEST CHANGES/.test(last)) { st.reviewRounds++; if (st.reviewRounds >= GATES.budgets.maxReviewRounds) await askPriya(issue, 'review-deadlock', last); else { st.phase = 0; } }
    else if (/^ESCALATE:/.test(last)) await askPriya(issue, 'review-deadlock', last);
    else if (/^APPROVE$/m.test(last)) await moveTo(issue, 'In Test');
    else if (/QA RED/.test(last)) { st.qaFails++; if (st.qaFails >= GATES.budgets.maxQaFailsPerIssue) await askPriya(issue, 'qa-third-fail', last); else await moveTo(issue, 'In Progress'); }
    else if (/^FLAKY:/.test(last)) await askPriya(issue, 'flaky', last);
    else if (/^COVERAGE:/.test(last)) await askPriya(issue, 'qa-third-fail', 'Not every criterion has a test. ' + last);
    else if (/WARDEN BLOCK|SECRET FOUND/.test(last)) await askPriya(issue, 'security-high', last);
    else st.phase++;
    if (st.phase >= plan.length && issue.state.name === 'In Test') await moveTo(issue, 'Ready to Deploy');
    save();
    await comment(issue, `⚙️ **Bellows** ran **${cap(agent)}** (${runner}, ${minutes} min). Result: \`${last.slice(0, 160)}\``);
  }
}
function today() { return new Date().toISOString().slice(0, 10); }
function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }

const only = args.includes('--issue') ? args[args.indexOf('--issue') + 1] : null;
if (args.includes('--watch')) { const loop = async () => { try { await tick(only); } catch (e) { log({ event: 'error', agent: 'bellows', error: String(e) }); } setTimeout(loop, 10 * 60 * 1000); }; loop(); }
else tick(only).catch(e => { console.error(e); process.exit(1); });

Walkthrough

  1. The PLAN table: state → agents

    Each Linear state maps to an ordered list of agents. In Test runs three in a row: Gauge, then Warden, then Beacon. The phase field in the state file remembers how far down the list an issue is, so one Linear state can carry several runs. builder and reviewer are placeholders filled at run time. onlyIfFlagged skips Warden's threat note when Anvil wrote flags: none.

    Linear stateAgents, in orderWhat ends the phase
    IdeaAnvil (scope)ANVIL: Scoped → Scoped
    ScopedWarden (threat, only if flagged) → Anvil (details)ANVIL: Scoped → Detailed
    Detailedbuilder: Ember or Flint (build)PR open; the builder moves the issue to In Progress
    In Progressreviewer: whoever did not build (review)APPROVE → In Test · REQUEST CHANGES → round two
    In TestGauge (qa) → Warden (security) → Beacon (journey)All three pass → Ready to Deploy · QA RED → In Progress
    Ready to DeployBellows itself (deploy)approve prod-deployvercel --prod → Deployed
    DeployedQuill (writeback) → Beacon (release-note)QUILL DONE · RELEASE NOTE READY
  2. Linear over GraphQL

    Three helpers and no SDK. activeIssues pulls every open issue on the team with its state, estimate, labels, description and comments in one query. moveTo looks the state up by name and updates the issue. comment posts. Each is one fetch to api.linear.app/graphql with LINEAR_API_KEY from the environment. State names are what Linear shows, which is why chapter 8 told you the names matter.

  3. pickBuilder

    Anvil's recommendation wins: a line builder: ember or builder: flint in the issue body. Without one, the labels backend or api send the slice to Flint and everything else to Ember. The choice is saved on the issue's state, so the reviewer is always the other one for the life of the issue.

  4. kickoff templating

    kickoff reads kit/prompts/NAME.md and fills four braces: ISSUE_ID, ISSUE_TITLE, PREVIEW_URL and EXTRA. That is the entire templating engine. Prompts stay as readable files you can open and edit. When an agent keeps missing something, you change the prompt file and the next tick uses it.

  5. run: claude -p or codex exec

    One function, two runners. Codex gets codex exec --full-auto PROMPT. Claude Code gets claude -p PROMPT --output-format text --permission-mode acceptEdits. Both run in the repo with AGENT_NAME and ISSUE_ID in the environment, which is how the hooks tag their telemetry lines with the right agent. A timeout from gates.json budgets kills a run that wanders. Check both flag sets against the current docs at code.claude.com and developers.openai.com/codex. The pattern is the spawn, the environment and the timeout.

  6. The status-line parser

    Every agent file ends with one line Bellows can read: ANVIL: Scoped, APPROVE, REQUEST CHANGES, QA GREEN, QA RED, WARDEN CLEAR, WARDEN BLOCK, JOURNEY REVIEWED. The parser is a chain of regular expressions over the last non-empty line of output. A match moves the state or asks Priya. No match means the phase is done and the next agent in the list is up. That is why the agent files are so strict about their last line.

  7. The gates and askPriya

    Three gates live in the plan itself: customer-data before a build on a flagged issue, scope-size above five points, and prod-deploy before vercel --prod. The budgets in gates.json cap runs per issue per day, review rounds and QA fails. askPriya posts a GATE comment on the issue and a message to GATE_WEBHOOK. Approval is a comment on the issue that starts approve prod-deploy. Bellows never argues. It asks, skips to the next issue, and checks again next tick.

  8. Telemetry

    log() appends one JSON line to telemetry/events.jsonl: start, end with minutes and the last line, state, gate, deploy, error. save() writes the state file. The hooks in chapter 7 append tool, gate and edit lines to the same file. That one file is everything the dashboard needs.

Worked run

Four ticks on ALD-42

Monday 14 September, five in the afternoon. ALD-42 is back in In Test after Ember's fix for Gauge's AC-3 defect. Bellows says nothing on stdout when a tick goes well. Its trail is the telemetry file and the comments it leaves on the issue. Here is the trail for the next twenty-five minutes.

node kit/scripts/bellows.mjs --watch &
tail -f telemetry/events.jsonl
# times are UTC. 00:00Z on the 15th is 5:00 pm on the 14th in Portland.
{"ts":"2026-09-15T00:00:03.118Z","event":"start","agent":"gauge","issue":"ALD-42","runner":"codex","prompt":"qa"}
{"ts":"2026-09-15T00:06:15.402Z","event":"end","agent":"gauge","issue":"ALD-42","minutes":6.2,"status":0,"last":"QA GREEN"}
{"ts":"2026-09-15T00:10:04.077Z","event":"start","agent":"warden","issue":"ALD-42","runner":"claude","prompt":"security"}
{"ts":"2026-09-15T00:13:10.655Z","event":"end","agent":"warden","issue":"ALD-42","minutes":3.1,"status":0,"last":"WARDEN CLEAR"}
{"ts":"2026-09-15T00:20:02.910Z","event":"start","agent":"beacon","issue":"ALD-42","runner":"claude","prompt":"journey"}
{"ts":"2026-09-15T00:22:27.301Z","event":"end","agent":"beacon","issue":"ALD-42","minutes":2.4,"status":0,"last":"JOURNEY REVIEWED"}
{"ts":"2026-09-15T00:25:01.044Z","event":"state","agent":"bellows","issue":"ALD-42","to":"Ready to Deploy"}
{"ts":"2026-09-15T00:25:02.529Z","event":"gate","agent":"bellows","issue":"ALD-42","gate":"prod-deploy","decision":"ask","detail":"Tests green, Warden clear, Beacon reviewed."}

Read it as four ticks. 5:00: Gauge on Codex, 6.2 minutes, QA GREEN, phase one done. 5:10: Warden on Claude, 3.1 minutes, WARDEN CLEAR. 5:20: Beacon, 2.4 minutes, JOURNEY REVIEWED, the last phase of In Test. 5:25: the issue moves to Ready to Deploy and the deploy step finds no approve prod-deploy comment yet and asks. What Priya sees on her phone is the Linear side of the same twenty-five minutes, and then the day it took her to answer.

linear.app/alderline/issue/ALD-42 · activity
Ready to DeployALD-42 · Route notes on an account3 pts · customer-data · builder: ember · PR #57
Bellows · Mon 14 Sep, 5:06 pm

ran Gauge (codex, 6.2 min). Result: QA GREEN

Bellows · 5:13 pm

ran Warden (claude, 3.1 min). Result: WARDEN CLEAR

Bellows · 5:22 pm

ran Beacon (claude, 2.4 min). Result: JOURNEY REVIEWED

Bellows changed status from In Test to Ready to Deploy · 5:25 pm
Bellows · 5:25 pm gate

GATE · ALD-42 · Production deploy. Always a human.
Tests green, Warden clear, Beacon reviewed.
Reply in Linear with "approve prod-deploy" or "reject prod-deploy".

P
Priya Raman · Tue 15 Sep, 7:42 pm · from phone

approve prod-deploy

Bellows changed status from Ready to Deploy to Deployed · Tue 15 Sep, 7:52 pm vercel --prod · ok
Schedule it

A timer that runs one tick

Bellows does not need to be a service. It needs to be started every ten minutes with --once. Pick the scheduler you already have. Or ask Claude Code to set it up for you.

Read kit/scripts/bellows.mjs and kit/.claude/gates.json. Set up a scheduled task on this machine that runs

  node kit/scripts/bellows.mjs --once

every 10 minutes, started from the repo folder C:\PATH\TO\REPO so the script finds .claude/gates.json. It needs LINEAR_API_KEY, LINEAR_TEAM=ALD and GATE_WEBHOOK from my user environment. Do not write them into any file.

Show me the exact scheduler command before you run it and tell me which flags you checked against the current docs. Then run one tick by hand and show me the last three lines of telemetry/events.jsonl.

One command creates the task. The cmd /c cd /d wrapper matters: Bellows reads .claude/gates.json from the folder it starts in, and a task has no start-in folder unless you set one.

schtasks /Create /SC MINUTE /MO 10 /TN "Alderline Bellows" /TR "cmd /c cd /d C:\PATH\TO\REPO && node kit\scripts\bellows.mjs --once" /F

schtasks /Run /TN "Alderline Bellows"
schtasks /Query /TN "Alderline Bellows" /V /FO LIST

Set LINEAR_API_KEY, LINEAR_TEAM and GATE_WEBHOOK as user environment variables (Settings → System → Environment variables). A task that runs as your user inherits them. Never write them into a file in the repo. The secrets gate blocks that anyway. The claude and codex CLIs must be signed in as the same user.

One crontab line. The cd is there for the same reason as above. Redirect the output so a crash leaves a trace.

PATH=/usr/local/bin:/usr/bin:/bin:/Users/YOU/.npm-global/bin
LINEAR_TEAM=ALD

*/10 * * * * cd /PATH/TO/REPO && node kit/scripts/bellows.mjs --once >> telemetry/cron.log 2>&1

Put LINEAR_API_KEY and GATE_WEBHOOK in a file outside the repo (for example ~/.alderline.env) and source it in the command, or set them in the crontab if you are the only user on the machine. cron has a short PATH; claude, codex and node must be on it. A laptop that sleeps does not tick. That is fine.

A workflow on a schedule. concurrency stops two ticks overlapping. The catch is sign-in: the Claude Code and Codex CLIs need to be signed in on the runner, and a hosted runner starts clean every time.

name: bellows
on:
  schedule:
    - cron: '*/10 * * * *'
  workflow_dispatch:
concurrency:
  group: bellows
  cancel-in-progress: false
jobs:
  tick:
    runs-on: self-hosted        # a machine where claude and codex are installed and signed in
    timeout-minutes: 50
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: node kit/scripts/bellows.mjs --once
        env:
          LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
          LINEAR_TEAM: ALD
          GATE_WEBHOOK: ${{ secrets.GATE_WEBHOOK }}
      - run: git add telemetry && git -c user.name=bellows -c user.email=bellows@ALDERLINE.EXAMPLE commit -m "telemetry" && git push
        if: always()

Two ways through the sign-in problem. Use a self-hosted runner that stays signed in, as above. Or sign in as part of the job with whatever non-interactive credential your Claude and Codex plans support; check the current docs for both, because this changes. Telemetry has to persist between runs too, which is what the last step does. GitHub's schedule has a five-minute floor and can run late at busy hours.

Check the flags

schtasks /SC MINUTE /MO 10, the crontab syntax and the Actions schedule block are shown as they are documented today. Check each against the current docs before you rely on it. The pattern is what matters: a timer that starts one tick, from the repo folder, with the keys in the environment and the CLIs signed in.

The real one

Development HQ is Danny's version of Bellows for his own review work. A Windows scheduled task fires on a timer. A lock file stops two runs overlapping. It runs one review at a time, capped per day, with QA on Codex and product and marketing on Claude. Each run writes its evidence to a Notion Review Runs database instead of a JSONL file. Same shape. If you want to see what this looks like after a year, that is the place.

Telemetry

One line per event, then add them up

Bellows and the three hooks append to the same file. This script folds it into the numbers the dashboard shows.

#!/usr/bin/env node
// kit/scripts/telemetry.mjs — turn telemetry/events.jsonl into dashboard/data.json
//
// The hooks and Bellows append one JSON line per event: start, end, tool,
// edit, gate, state, deploy, learn, error. This script folds them into the
// numbers the dashboard shows: minutes worked per agent per day, issues
// touched, PRs, defects, gates fired and how they were decided, and the
// learning count (kit files changed + learning-log entries). Run it after
// each Bellows tick or on a schedule, and serve dashboard/ as static files.
//
//   node kit/scripts/telemetry.mjs telemetry/events.jsonl dashboard/data.json

import { readFileSync, writeFileSync } from 'node:fs';

const [,, src = 'telemetry/events.jsonl', out = 'dashboard/data.json'] = process.argv;
const lines = readFileSync(src, 'utf8').split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);

const agents = {};
const A = id => agents[id] || (agents[id] = { id, minutes: 0, runs: 0, issues: new Set(), tools: 0, edits: 0, gates: { ask: 0, deny: 0 }, learned: 0, days: {} });
const day = ts => ts.slice(0, 10);
const gatesById = {};
const states = [];
let learningEntries = 0;

for (const e of lines) {
  if (!e.agent) continue;
  const a = A(e.agent);
  if (e.issue) a.issues.add(e.issue);
  if (e.event === 'end') { a.minutes += Number(e.minutes || 0); a.runs++; a.days[day(e.ts)] = (a.days[day(e.ts)] || 0) + Number(e.minutes || 0); if (Array.isArray(e.learned) && e.learned.length) { a.learned += e.learned.length; learningEntries += e.learned.length; } }
  if (e.event === 'tool') a.tools++;
  if (e.event === 'edit') a.edits++;
  if (e.event === 'gate') { a.gates[e.decision] = (a.gates[e.decision] || 0) + 1; gatesById[e.gate] = (gatesById[e.gate] || 0) + 1; }
  if (e.event === 'state') states.push({ ts: e.ts, issue: e.issue, to: e.to });
  if (e.event === 'learn') { a.learned++; learningEntries++; }
}

const result = {
  generatedAt: new Date().toISOString(),
  totals: { minutes: Object.values(agents).reduce((s, a) => s + a.minutes, 0), runs: Object.values(agents).reduce((s, a) => s + a.runs, 0), gates: Object.values(gatesById).reduce((s, n) => s + n, 0), learning: learningEntries },
  agents: Object.values(agents).map(a => ({ ...a, issues: [...a.issues] })).sort((x, y) => y.minutes - x.minutes),
  gates: gatesById,
  states
};
writeFileSync(out, JSON.stringify(result, null, 2));
console.log(`telemetry → ${out}: ${result.agents.length} agents, ${result.totals.runs} runs, ${Math.round(result.totals.minutes)} min, ${result.totals.gates} gates, ${result.totals.learning} learning events`);

What the lines look like

Six lines from Alderline's file, from Ember's build of ALD-42 on 14 September and Quill's write-back after the deploy. Bellows writes start and end. The guard hook writes tool and gate. The after-edit hook writes edit. Quill writes learn when it creates a Learning log entry, and the Stop hook adds a learned list to its end line when a kit file changed during the run.

{"ts":"2026-09-14T20:30:05.204Z","event":"start","agent":"ember","issue":"ALD-42","runner":"claude","prompt":"build"}
{"ts":"2026-09-14T20:30:48.977Z","agent":"ember","issue":"ALD-42","event":"tool","tool":"Bash","gate":null}
{"ts":"2026-09-14T20:35:12.410Z","agent":"ember","issue":"ALD-42","event":"gate","tool":"Write","gate":"migrations","decision":"ask","detail":"drizzle/0007_route_notes.sql"}
{"ts":"2026-09-14T20:41:30.086Z","agent":"ember","issue":"ALD-42","event":"edit","file":"/src/copy/accounts.ts","secret":false}
{"ts":"2026-09-14T21:31:29.512Z","event":"end","agent":"ember","issue":"ALD-42","minutes":61.4,"status":0,"last":"PR #57 opened · ALD-42-route-notes · In Progress"}
{"ts":"2026-09-16T03:01:44.930Z","agent":"quill","issue":"ALD-42","event":"learn","title":"A new text field names its reader in the placeholder","file":".claude/agents/ember.md"}
node kit/scripts/telemetry.mjs telemetry/events.jsonl dashboard/data.json
telemetry → dashboard/data.json: 9 agents, 318 runs, 5760 min, 38 gates, 19 learning events

Run it after each tick, or once an hour, and serve dashboard/ as static files. 5,760 minutes is the 96 hours in the next section.

The dashboard

Time working, work done, learning

Three panels. The numbers below are Alderline's for the six weeks from 3 August to 21 September, and they are illustrative. Open the live dashboard to click through them.

dashboard · Alderline crew · 3 Aug to 21 Sep 2026
Time working
96 hover six weeks · 318 runs
Ember31h
Flint22h
Gauge18h
Warden8h
Anvil7h
Beacon4h
Ledger3h
Quill2h
Bellows1h
Work done
Issues deployed47
Tests written212
Defects caught before deploy31
Gates asked38
  approved34
  rejected4
Production deploys19
Learning
19learning-log entries
Kit files changed11 times
Most changedprompts/review.md

Latest entries

  • A new text field names its reader in the placeholder
  • Test the limit from both sides: 280 needs a 281 test
  • Quality means roast or freshness, not delivery

How learning is measured

Learning is not a feeling here. It is a count. A learning-log entry is written when an agent file, a hook, a kickoff prompt or a test approach changed because something went wrong. Quill writes the entry with the lesson as the title, the issue it came from and the file that changed. The Stop hook reminds the agent to call Quill whenever a run touched .claude/, AGENTS.md, CLAUDE.md or kit/.

The dashboard counts those entries and shows their titles. Nineteen in six weeks means the crew changed how it works nineteen times because of something it got wrong. That is the number Priya reads first each month. A crew that ships and never learns is a crew you will be correcting by hand for years.

Three of Alderline's nineteen

  • Test the limit from both sides. Gauge's AC-3 failure on ALD-42. Added to kit/prompts/qa.md.
  • Quality means roast or freshness, not delivery. Two misfiled tickets in week five. Added to ledger.md.
  • A new text field names its reader. Beacon's journey review on ALD-42. Added to ember.md.

Founder Q&A

What if Bellows crashes?

Nothing is lost. The state file telemetry/bellows-state.json holds each issue's phase and counts. A tick is idempotent: it reads Linear fresh and only acts on what the state calls for. If a run died halfway, the next tick sees the same state and runs the same agent again. The only cost is the minutes of the run that died. In --watch mode an error is logged as an error event and the loop continues.

Can I run it on my laptop?

Yes, while it is awake. Bellows runs wherever node, claude and codex are installed and signed in. A laptop that sleeps at night means no ticks at night, which is fine for most companies. If you want the crew working while you sleep, a small always-on machine in the office or a self-hosted Action runner is better. Alderline runs it on a mini PC next to the roaster.

How much does a tick cost?

A tick where nothing changed runs one Linear query and no agent. Call it a few cents of machine time and nothing in model spend. A tick that runs an agent costs that agent's run, which the end line records in minutes. The budgets in gates.json cap runs per issue per day and daily spend, and the budget gate asks Priya when a cap is hit. Watch the minutes panel for two weeks, then set the caps from what you saw.

Can two Bellows run at once?

No. Two dispatchers would run the same agent on the same issue twice and post two gates for one decision. Run one. If your scheduler can overlap a slow tick with the next timer, add a lock file: write telemetry/runner.lock at the start of a tick and exit if it exists and is younger than the timeout. Development HQ's runner does exactly this with runner.lock. The GitHub Actions tab above does it with concurrency.

Chapter 11

What you have now

The loop turns by itself. A script you have read line by line runs every ten minutes, reads Linear, wakes the right agent with the right prompt, reads one line back and moves the issue. It stops at every gate and never argues. Every run leaves two lines in a file, and a second script turns that file into three panels: how much time the crew spent, what it shipped, and how many times it changed its own instructions. You know what the company cost this week without asking anyone.

Last chapter: the whole CRM build, start to finish, with every name you now know.