Make your coding agent end every turn with a report you can read in five seconds

Run many agents and every one ends with a wall of prose. Here is the report format all of mine end with, the Stop hook that enforces it in Claude Code and Codex, and the exact instructions text.

Claude Code with an arrow to a report with DONE, DISPATCHED, QUEUED, PENDING and DECISION rows
Every turn ends the same way: a report sorted by who owes the next action.

When you run twenty agents, you can’t read each one’s last message and move on. Every one ends with a wall of prose, and you can’t tell which is finished, which is stuck and which is waiting on you.

So I made every agent end every turn the same way with the same format. Here’s how to set up the same thing in Claude Code or Codex:

🤖
Want an agent to just build this for you? Copy the prompt below and paste it into your AI agent. It tells the agent what to build and sends it back here for the actual instructions.
Prompt for your coding agent▾
I want every final message from my coding agent to follow a fixed status-report format, enforced by a Claude Code Stop hook that rejects any report that breaks the shape.

The key rule: every report must stand alone, so I never have to scroll up or look anything up to act on it. Items are sorted by who owes the next action.

The format, the instructions text and the full hook source are here:

https://strangenewworld.net/make-your-coding-agent-end-every-turn-with-a-report-you-can-read-in-five-seconds/

Read that page first, then build it.
⚙️ Requirements
  • 💻 An agent CLI with an end-of-turn hook. Claude Code and OpenAI Codex both have one (a Stop hook).
  • 🧠 An instructions file the agent reads (CLAUDE.md or an output style in Claude Code, AGENTS.md in Codex). The hook only enforces the format; the agent has to be told it first.

Every report has to stand alone

With many agents talking to you, you can’t keep any of them in your head. A report should carry every fact you need to act on it right now, so you never have to scroll up or look anything up.

That rules out most of what agents write by default:

  • “Fixed the SC-4437 thing.” What thing? An ID or a file name means nothing to someone who didn’t watch the work.
  • “Approve the 13 accounts I found.” The 13 names have to be in the report, not in the paragraph above it.
  • “See the output above.” You won’t scroll up. You have nineteen other agents.

The test for any line: if I’d have to scroll up or look something up to act on it, it’s written wrong. Say what the thing is and why it matters, put IDs after the description in parentheses, and explain jargon the first time it appears.

The seven sections

The agent sorts everything it did into these headers, in this order, and leaves out the empty ones:

  • 🔴 BROKEN: something is broken and needs attention
  • ▶️ IN PROGRESS: the agent owns the next step and is still going
  • ✅ DONE: finished, nothing left to do
  • 🛰️ DISPATCHED: handed to something that will run on its own, like a scheduled job
  • 📋 QUEUED: written down, but nothing is going to pick it up yet
  • ⏳ PENDING: waiting on me to do something
  • ❓ DECISION: waiting on me to choose

PENDING and DECISION are always last and next to each other. Everything above them is information; everything below is my to-do list.

When everything is finished, the report ends with ## 🏁 ALL FINISHED. It only appears when nothing is broken, queued, pending or undecided and nothing is still running, so I never have to ask “are we done?”

Sort by who owes the next action

Items aren’t sorted by how far along they are, but by who has to act next:

  • Can the agent do it without me? IN PROGRESS, and the agent should go do it instead of ending the turn.
  • Do I have to do something? PENDING, with the action and every fact I need.
  • Do I have to choose? DECISION, always a real picker, with the deciding facts in the question.
  • Will something run on its own, guaranteed? DISPATCHED.
  • Is it written down but nobody is doing it? QUEUED.

Each item is three weights on one left edge: a heading, a bold title on its own line, and one plain sentence under it. If it needs two sentences, it’s two items.

✅ DONE
To-do added: why approval went to Telegram
On your "To do" list.
⏳ PENDING
Approve the six drip templates
you: look at the pane and tell me which to approve (e.g. "all", or "all but B4"). It opens when this tab is on screen. Each shows its subject, animation, words, button and footer.
A1asked for a link, never signed in (+1 h)
A2same people (+2 days)
B2signed in, never connected (+1 day)
B4same people (+7 days, last email)
C2every call failed (+2 days)
D2worked once, quiet 12 days
Approving B2 and B4 also backfills the 10 people stuck at signed-in-never-connected.
A real PENDING item: the action first, then the six templates and who gets each, so I can answer without opening anything else.

Tell the agent the format

The hook only enforces. The agent has to be told the format first. I keep it in an output style plus a few lines in CLAUDE.md. The core of it:

End every report with sections:
BROKEN, IN PROGRESS, DONE,
DISPATCHED, QUEUED, PENDING,
DECISION. Omit empty ones.
Sort each item by who owes
the next action.

Write it as a standalone
abstract: assume the reader
saw ONLY this block. No bare
session-local names. Gloss
jargon. An ID trails its
title, in parens.

One sentence per item body.
No preamble before the first
header. No text after the
last item. No "want me to..."

The Stop hook

Claude Code and Codex both run a Stop hook when the agent finishes a turn, and both hand it the path to the session transcript. Mine reads the transcript and does this:

  1. Finds the final message and the tool calls made this turn.
  2. If the turn only read things or chatted, it stays silent.
  3. If the turn changed something and the message has no sections, it asks for a re-send in the format.
  4. If it has sections but broke the shape, it lists what’s wrong: sections out of order, more than three items in a section, an item with no bold title, text before the first header or after the last item, a soft ask like “want me to…”.
  5. If the agent posed a decision in prose instead of calling the question tool, it asks for a real picker.

It doesn’t reject the turn. It answers with decision: block and the problem as the reason, and the agent re-sends the message fixed, so I never see the bad version.

Four guards keep it from looping: it ignores re-sends it triggered itself, remembers each message it already complained about, fires at most twice per session, and CLAUDE_DISABLE_STATUS_FORMAT=1 turns it off.

Save the hook below as report.js in your Claude Code hooks folder, make it executable, and register it in your settings file:

"hooks": {
  "Stop": [{
    "hooks": [{
      "type": "command",
      "command":
        "~/.claude/hooks/report.js"
    }]
  }]
}
The full hook: report.js▾
#!/usr/bin/env node

/**
 * Status-report Stop hook for Claude Code.
 *
 * When a turn CHANGED something, the final message must be a status report:
 * sections in this order, empty ones omitted --
 *   🔴 BROKEN  ▶️ IN PROGRESS  ✅ DONE  🛰️ DISPATCHED  📋 QUEUED  ⏳ PENDING  ❓ DECISION
 * Each item is a **bold title** on its own line, a blank line, then a plain
 * description. Sections are sorted by who owes the next action. Questions,
 * lookups and read-only turns are exempt.
 *
 * Two passes: PRESENCE (a turn that changed state has no report at all) and
 * STRUCTURE (a report that breaks the shape rules). The hook never fails the
 * turn: it returns a note and the agent re-sends the message fixed.
 *
 * Works as a Stop hook in Claude Code and in OpenAI Codex (same stdin fields, same
 * {decision:"block", reason} output).
 *
 * Loop safety: ignores re-sends it triggered (stop_hook_active), remembers each
 * message it already complained about, fires at most STATUS_FORMAT_MAX_FIRES
 * (default 2) times per session, and CLAUDE_DISABLE_STATUS_FORMAT=1 turns it off.
 */

const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');

function readStdin() { try { return fs.readFileSync(0, 'utf8'); } catch { return ''; } }
function scratchDir() {
  return process.env.AGENT_SCRATCH || path.join(os.homedir(), '.local', 'state', 'claude-code', 'scratch');
}

// A "mutation" is an edit/write tool, or a Bash command that changes state.
const MUT_TOOL = /^(Edit|Write|NotebookEdit|MultiEdit)$/;
const MUT_BASH = /\b(git\s+(commit|push|merge|rebase|reset|cherry-pick|revert|tag|apply|stash)|npm\s+(run\s+\S+|test|ci|publish)|pnpm\s+\S+|yarn\s+\S+|pytest|jest|vitest|go\s+(test|build|run)|cargo\s+(test|build|run)|\bmake\b|docker\s+(build|run|compose|push)|pm2\s+(start|restart|reload|delete)|chmod|chown|sed\s+-i|\bmv\s|\bcp\s|\brm\s)/;

const SECTIONS = ['BROKEN', 'IN PROGRESS', 'DONE', 'DISPATCHED', 'QUEUED', 'PENDING', 'DECISION'];
const EMOJI = { BROKEN: '🔴', 'IN PROGRESS': '▶️', DONE: '✅', DISPATCHED: '🛰️', QUEUED: '📋', PENDING: '⏳', DECISION: '❓' };

// Emoji variation selectors are stripped before matching, so ✅ and ✅️ both match.
const HEADER_RE = /^\s{0,3}(?:#{1,6}\s*)?(?:\*\*\s*)?[🔴▶✅🛰📋⏳❓]\s*(?:\*\*\s*)?(BROKEN|IN PROGRESS|DONE|DISPATCHED|QUEUED|PENDING|DECISION)\b/u;
const FINISHED_RE = /^\s{0,3}#{1,6}\s*🏁\s*ALL FINISHED/u;
const TITLE_RE = /^\s{0,3}(?:[-*]\s+)?\*\*[^*].*\*\*\s*$/;   // a bare bold title line
const OVERFLOW_RE = /(?:…|\.\.\.)\s*and\s+\d+\s+more/i;       // "…and 4 more (a, b)"
const SOFT_ASK_RE = /\b(want me to|should i\b|shall i\b|would you like me to|let me know if|do you want me to|say the word|happy to)\b/i;
const MAX_ITEMS = 3;
const MAX_DONE_ITEMS = 6;       // DONE is split into IMPORTANT and ALSO
const PREAMBLE_MAX_WORDS = 3;

const words = (s) => (s.match(/\S+/g) || []).length;
const stripVS = (s) => s.replace(/[︎️]/g, '');
const stripFences = (s) => s.replace(/^\s*```[\s\S]*?^\s*```/gm, '');
const prep = (s) => stripFences(stripVS(s));

/** Shape violations for a message that uses section headers ([] if none or clean). */
function lintStructure(raw) {
  const lines = prep(raw).split('\n');
  const heads = [];
  lines.forEach((line, i) => { const m = HEADER_RE.exec(line); if (m) heads.push({ key: m[1], i }); });
  if (!heads.length) return [];
  const finishedAt = lines.findIndex((l) => FINISHED_RE.test(l));
  const violations = [];

  const ranks = heads.map((h) => SECTIONS.indexOf(h.key));
  if (!ranks.every((r, i) => i === 0 || r > ranks[i - 1])) {
    violations.push(`section order must be ${SECTIONS.map((k) => `${EMOJI[k]} ${k}`).join(' → ')}; yours ran ${heads.map((h) => h.key).join(' → ')}`);
  }

  heads.forEach((h, s) => {
    let end = s + 1 < heads.length ? heads[s + 1].i : lines.length;
    if (finishedAt > h.i && finishedAt < end) end = finishedAt;
    const items = [];
    let overflow = false;
    for (const line of lines.slice(h.i + 1, end)) {
      if (OVERFLOW_RE.test(line)) { overflow = true; continue; }
      if (/^\s*#{1,6}\s/.test(line)) continue;                 // ### IMPORTANT / ### ALSO
      if (TITLE_RE.test(line)) { items.push({ dash: /^\s{0,3}[-*]\s/.test(line), desc: [] }); continue; }
      if (items.length) items[items.length - 1].desc.push(line); // blank lines kept: they separate paragraphs
    }
    if (!items.length) return;
    const cap = h.key === 'DONE' ? MAX_DONE_ITEMS : MAX_ITEMS;
    if (items.length > cap && !overflow) violations.push(`${EMOJI[h.key]} ${h.key} has ${items.length} items, cap is ${cap}; collapse the rest to "…and N more (names)"`);
    if (items.some((it) => it.dash)) violations.push(`${EMOJI[h.key]} ${h.key}: drop the dash, a title is a bare **bold** line`);
    const paras = (it) => it.desc.join('\n').split(/\n\s*\n/).filter((p) => p.trim()).length;
    if (h.key === 'DONE') {
      if (items.some((it) => paras(it) > 1)) violations.push(`${EMOJI.DONE} DONE items are a bold title plus one sentence, nothing more`);
    } else if (items.some((it) => paras(it) === 0)) {
      violations.push(`${EMOJI[h.key]} ${h.key} has an item with no description under its bold title`);
    }
    if (h.key === 'PENDING' || h.key === 'DECISION') {
      const packed = (l) => ((l.match(/·/g) || []).length + (l.match(/;/g) || []).length >= 2) || ((l.match(/`[^`]+`/g) || []).length >= 2);
      const runOn = items.some((it) => it.desc.some((l) => !/^\s*\|/.test(l) && packed(l)));
      if (runOn) violations.push(`${EMOJI[h.key]} ${h.key} has a RUN-ON LIST: when the reader must choose between several things, give each its own row, never one packed line`);
    }
  });
  return violations;
}

/** Prose wrapped AROUND the sections: preamble, text after ALL FINISHED, soft asks. */
function lintProse(raw) {
  const text = prep(raw);
  const lines = text.split('\n');
  const first = lines.findIndex((l) => HEADER_RE.test(l));
  if (first < 0) return [];
  const out = [];
  const pre = words(lines.slice(0, first).join(' '));
  if (pre > PREAMBLE_MAX_WORDS) out.push(`${pre} words of preamble before the first section header; start AT the header`);
  const fin = lines.findIndex((l) => FINISHED_RE.test(l));
  if (fin >= 0 && words(lines.slice(fin + 1).join(' '))) out.push('text after ALL FINISHED; it must be the last line');
  const soft = SOFT_ASK_RE.exec(text);
  if (soft) out.push(`soft ask present ("${soft[0]}"); if it asks whether to continue, take the next action instead`);
  return out;
}

/** Walk the transcript back to the last real user prompt; collect this turn's tool calls. */
function analyzeTurn(transcriptPath) {
  let raw;
  try { raw = fs.readFileSync(transcriptPath, 'utf8'); } catch { return null; }
  const lines = raw.split('\n').filter(Boolean);
  let lastText = '', endedOnToolUse = false, sawAssistant = false;
  const tools = [];
  for (let i = lines.length - 1; i >= 0; i--) {
    let rec; try { rec = JSON.parse(lines[i]); } catch { continue; }
    if (rec.type === 'response_item' && rec.payload) {   // Codex rollout format
      const p = rec.payload;
      const textOf = (c) => (Array.isArray(c) ? c.map((x) => (x && x.text) || '').join('\n') : String(c || ''));
      if (p.type === 'message' && p.role === 'user') {
        if (/^<(hook_prompt|environment_context|user_instructions|skills_instructions)/.test(textOf(p.content).trim())) continue;
        break;                  // a real user prompt: the turn boundary
      }
      if (p.type === 'message' && p.role === 'assistant') {
        if (!sawAssistant) { sawAssistant = true; endedOnToolUse = false; }
        if (!lastText) lastText = textOf(p.content);
      } else if (p.type === 'function_call') {
        if (!sawAssistant) { sawAssistant = true; endedOnToolUse = true; }
        let a = {}; try { a = JSON.parse(p.arguments); } catch {}
        const cmd = a.cmd || a.command || '';
        tools.push({ name: p.name === 'apply_patch' ? 'Edit' : /exec_command|shell/.test(p.name) ? 'Bash' : p.name, cmd: Array.isArray(cmd) ? cmd.join(' ') : String(cmd), codex: true });
      }
      continue;
    }
    const content = rec.message && rec.message.content;
    if (rec.type === 'user') {
      const isResult = Array.isArray(content) && content.some((c) => c && c.type === 'tool_result');
      if (!isResult) break;       // a real user prompt: the turn boundary
      continue;
    }
    if (rec.type !== 'assistant') continue;
    const hasToolUse = Array.isArray(content) && content.some((c) => c && c.type === 'tool_use');
    if (!sawAssistant) { sawAssistant = true; endedOnToolUse = hasToolUse; }
    if (Array.isArray(content)) {
      if (!lastText) lastText = content.filter((c) => c && c.type === 'text' && c.text).map((c) => c.text).join('\n');
      for (const c of content) if (c && c.type === 'tool_use') tools.push({ name: c.name || '', cmd: (c.input && c.input.command) || '' });
    }
  }
  return { lastText, tools, endedOnToolUse };
}

// Codex shell commands also count when they write: a redirect, tee, touch or mkdir.
const CODEX_WRITE = /(^|[^>&0-9])>{1,2}\s*[^\s&]|\btee\b|\btouch\b|\bmkdir\b/;
const turnMutated = (tools) => tools.some((t) => MUT_TOOL.test(t.name) || (t.name === 'Bash' && (MUT_BASH.test(t.cmd) || (t.codex && CODEX_WRITE.test(t.cmd)))));

const PRESENCE_MSG =
  'This turn changed state, so the final message needs the status format (a report): sections in order ' +
  `${SECTIONS.map((k) => `${EMOJI[k]} ${k}`).join(', ')} (omit empty ones). Each item is a **bold title** on its own line, a blank line, ` +
  'then one plain sentence. Sort every item by WHO OWES THE NEXT ACTION: if you can do it, it is IN PROGRESS and you go do it; ' +
  'if the user must act, PENDING (`you: …` plus every fact they need); if they must choose, DECISION via AskUserQuestion. ' +
  'The report must stand alone: no bare IDs or file names, no "see above". Re-send your last message in that format.';

const hasDecisionSection = (raw) => prep(raw).split('\n').some((l) => { const m = HEADER_RE.exec(l); return m && m[1] === 'DECISION'; });

const PICKER_MSG =
  'You posed a ❓ DECISION in prose without calling the question tool (AskUserQuestion). A decision belongs in the ' +
  'picker, with the deciding facts in the question body, each option stating its consequence and risk, and the ' +
  'recommended option first. Keep the rest of the report as it is; move only the decision out of the prose.';

function main() {
  if (process.env.CLAUDE_DISABLE_STATUS_FORMAT === '1') process.exit(0);
  let input = {};
  try { input = JSON.parse(readStdin() || '{}'); } catch { process.exit(0); }
  if (input.stop_hook_active) process.exit(0);

  // Claude Code and Codex both pass transcript_path; Codex also passes last_assistant_message.
  const turn = input.transcript_path ? analyzeTurn(input.transcript_path) : null;
  const lastText = input.last_assistant_message || (turn && turn.lastText) || '';
  if (!lastText || (turn && turn.endedOnToolUse)) process.exit(0);   // mid-work narration is not a report

  // Whether this turn changed anything comes from the transcript. If this agent's transcript
  // can't be read that way, only reports that already use section headers are checked.
  const known = !!(turn && turn.tools.length);
  const mutated = known ? turnMutated(turn.tools) : null;
  const askedPicker = known && turn.tools.some((t) => t.name === 'AskUserQuestion');
  const needsPicker = known && hasDecisionSection(lastText) && !askedPicker;
  if (mutated === false && !needsPicker) process.exit(0);            // chat, lookups, read-only turns are exempt

  const hasReport = prep(lastText).split('\n').some((l) => HEADER_RE.test(l));
  let msg;
  if (needsPicker) {
    const prose = lintProse(lastText);
    msg = PICKER_MSG + (prose.length ? '\n\nWhile you re-send:\n' + prose.map((v) => `  - ${v}`).join('\n') : '');
  } else if (!hasReport) {
    if (mutated !== true) process.exit(0);
    msg = PRESENCE_MSG;
  } else {
    const structural = lintStructure(lastText);
    if (!structural.length) process.exit(0);
    // Prose problems ride along only once a structural one has earned the fire.
    msg = 'Your report uses the right sections but breaks the shape rules:\n' +
      structural.concat(lintProse(lastText)).map((v) => `  - ${v}`).join('\n') +
      '\n\nRe-send your last message with ONLY those fixed, no commentary about this correction.';
  }

  const file = path.join(scratchDir(), `status-format-fired-${input.session_id || 'nosession'}.json`);
  let fired = {};
  try { fired = JSON.parse(fs.readFileSync(file, 'utf8')); } catch {}
  const hash = crypto.createHash('sha1').update(lastText).digest('hex').slice(0, 12);
  const cap = Number.parseInt(process.env.STATUS_FORMAT_MAX_FIRES || '2', 10);
  if (fired[hash] || (Number(fired.__fires) || 0) >= cap) process.exit(0);
  fired[hash] = true;
  fired.__fires = (Number(fired.__fires) || 0) + 1;
  try { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(fired)); } catch {}

  // "block" does not reject the turn: the agent continues with `reason` as its next prompt.
  process.stdout.write(JSON.stringify({ decision: 'block', reason: msg }));
}

if (require.main === module) main();
module.exports = { lintStructure, lintProse };

Using it with Codex

Codex’s Stop hook takes the same decision: block answer, so the same file works. I ran it on Codex CLI 0.154: the agent replied in plain prose, the hook blocked, and the agent re-sent the report in the format.

  1. Register it in ~/.codex/hooks.json, using an absolute path:
{"hooks": {"Stop": [{"hooks": [{
  "type": "command",
  "command":
    "node /abs/path/report.js",
  "timeout": 30
}]}]}}
  1. Run /hooks in Codex and trust it. An untrusted hook is skipped with no warning.
  2. Put the format text from above in AGENTS.md.

The hook is JavaScript, so it runs on Node. The logic is a transcript read and a few string checks, so your agent can port it to any language.

What else I’ve built

I use the same setup to run 20+ agents at once: they drive my phone, my VR headset and my Mac, show me images and video inside the terminal, and sort my email. I write up each one as I go. Subscribe below if you want the next one.