Docs / Recipes / How-to
Review what each agent did this week
Once a week, look at how each agent worked, not only what it finished: did it plan, report progress, ask clear questions and say how it checked its work? This script reads your events with an API key and prints one scorecard per agent, the events behind anything it flags, and the instruction lines that would fix it.
It only calls GET /api/events (see events and the live stream), so it changes nothing. To read a single run as it happens instead, use the live board.
What it measures
- Tasks finished and steps finished, per agent label ("Claude Code in api", "Codex in web"), counting only top-level tasks as tasks.
- Plans posted with taskhold_plan, and progress reports. Reports an agent sends in quick succession are stored as one event, so the count is the updates you could see.
- Completions with a real summary: what changed and how it was checked, at least 20 characters.
- Blocked questions, how long each waited for your answer (until your next note or status change on that task), and questions too short to answer without a follow-up.
- Tasks you reopened after the agent marked them done.
Run it
TASKHOLD_KEY=atd_sk_... bun agent-review.ts # last 7 days
TASKHOLD_KEY=atd_sk_... bun agent-review.ts --days 30Needs Bun 1.1 or later and an API key from Settings. Save the script below as agent-review.ts.
#!/usr/bin/env bun
/**
* Review what each coding agent did this week, from Taskhold's events API: a scorecard per agent, dated
* evidence for anything worth fixing, and the instruction lines to add to CLAUDE.md / AGENTS.md. Read-only.
*
* TASKHOLD_KEY=atd_sk_... bun agent-review.ts [--days 7] [--base https://taskhold.com]
* bun agent-review.ts --fixture events.json (a saved /api/events response)
*
* Uses only GET /api/events (https://taskhold.com/docs/events). Progress reports an agent sends in quick
* succession are stored as one event, so "Progress reports" counts the updates you could see.
*/
type Ev = {
id: number; type: string; actor: "agent" | "human"; actor_name?: string | null; task_id: string; root_task_id?: string | null;
task_title?: string; summary?: string; data?: { from?: string; to?: string; blocked_reason?: string | null; completed_summary?: string | null };
created_at: string;
};
const args = process.argv.slice(2);
const opt = (k: string, d?: string) => (args.includes(k) ? args[args.indexOf(k) + 1] : d);
const days = Number(opt("--days", "7"));
const since = Date.now() - days * 86_400_000;
async function load(): Promise<Ev[]> {
const fx = opt("--fixture");
if (fx) return (await Bun.file(fx).json()).events;
const key = process.env.TASKHOLD_KEY;
if (!key) throw new Error("set TASKHOLD_KEY (an API key from Settings) or pass --fixture");
const site = opt("--base", "https://taskhold.com")!;
const out: Ev[] = [];
let cursor = 0;
for (;;) {
const r = await fetch(`${site}/api/events?since=${cursor}&limit=500`, { headers: { Authorization: `Bearer ${key}` } });
if (!r.ok) throw new Error(`events ${r.status}`);
const j = (await r.json()) as { events: Ev[]; cursor: number };
out.push(...j.events);
if (j.events.length < 500 || j.cursor === cursor) break;
cursor = j.cursor;
}
return out;
}
const evs = (await load()).filter((e) => Date.parse(e.created_at) >= since || opt("--fixture"));
const isRoot = (e: Ev) => !e.root_task_id || e.root_task_id === e.task_id;
type Card = { tasks: number; steps: number; blocks: number; withSummary: number; progress: number; plans: number; reopened: number; waitMin: number[]; evidence: string[] };
const cards = new Map<string, Card>();
const card = (n: string) => cards.get(n) ?? (cards.set(n, { tasks: 0, steps: 0, blocks: 0, withSummary: 0, progress: 0, plans: 0, reopened: 0, waitMin: [], evidence: [] }), cards.get(n)!);
const lastAgentOnTask = new Map<string, string>();
const openBlocks = new Map<string, { agent: string; at: number }>();
for (const e of evs.sort((a, b) => a.id - b.id)) {
const day = e.created_at.slice(0, 10);
if (e.actor === "agent") {
// Only events that count open a card: task.created carries the name guessed from the client before
// the agent's own label ("Claude Code" vs "Claude Code in api"), which would add an empty card.
if (!["subtasks.planned", "task.progress", "task.status"].includes(e.type)) continue;
const name = e.actor_name || "agent";
lastAgentOnTask.set(e.root_task_id || e.task_id, name);
const c = card(name);
if (e.type === "subtasks.planned") c.plans++;
if (e.type === "task.progress") c.progress++;
if (e.type === "task.status" && e.data?.to === "done") {
if (isRoot(e)) {
c.tasks++;
if ((e.data.completed_summary ?? "").trim().length >= 20) c.withSummary++;
else c.evidence.push(`${day} finished "${e.task_title}" without a summary of what changed and how it was checked`);
} else c.steps++;
}
if (e.type === "task.status" && e.data?.to === "blocked") {
c.blocks++;
openBlocks.set(e.task_id, { agent: name, at: Date.parse(e.created_at) });
if ((e.data.blocked_reason ?? "").length < 15) c.evidence.push(`${day} blocked "${e.task_title}" with a vague question: "${e.data.blocked_reason ?? ""}"`);
}
} else {
// A human note or status change on a blocked task = the answer; measure how long the agent waited.
const b = openBlocks.get(e.task_id);
if (b && (e.type === "task.note" || e.type === "task.status")) {
card(b.agent).waitMin.push(Math.round((Date.parse(e.created_at) - b.at) / 60_000));
openBlocks.delete(e.task_id);
}
if (e.type === "task.status" && e.data?.from === "done" && e.data?.to !== "done") {
const who = lastAgentOnTask.get(e.root_task_id || e.task_id) ?? "agent";
card(who).reopened++;
card(who).evidence.push(`${day} you reopened "${e.task_title}" after the agent marked it done`);
}
}
}
const median = (xs: number[]) => (xs.length ? [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)] : NaN);
console.log(`# Agent review, last ${days} days (${evs.length} events)\n`);
for (const [name, c] of cards) {
const summaryRate = c.tasks ? Math.round((100 * c.withSummary) / c.tasks) : NaN;
console.log(`## ${name}\n`);
console.log("| KPI | Value | Bar |\n|---|---|---|");
console.log(`| Tasks finished | ${c.tasks} | - |`);
console.log(`| Steps finished | ${c.steps} | - |`);
console.log(`| Plans posted | ${c.plans} | 1 per multi-step task |`);
console.log(`| Progress reports | ${c.progress} | a few per long task |`);
console.log(`| Done with a real summary | ${Number.isNaN(summaryRate) ? "-" : summaryRate + "%"} | 100% |`);
console.log(`| Blocked questions | ${c.blocks} | - |`);
console.log(`| Median wait for your answer | ${Number.isNaN(median(c.waitMin)) ? "-" : median(c.waitMin) + " min"} | your side of the deal |`);
console.log(`| Reopened by you | ${c.reopened} | 0 |`);
console.log(`\nEvidence:\n${c.evidence.slice(0, 8).map((x) => `- ${x}`).join("\n") || "- nothing to flag"}\n`);
const plan: string[] = [];
if (c.tasks && summaryRate < 100) plan.push("Done: taskhold_complete with what changed and how you checked it (tests run, page opened).");
if (c.tasks >= 2 && c.plans < c.tasks / 2) plan.push("More than a couple of steps: taskhold_plan first, then start and complete each step.");
if (c.reopened) plan.push("Before taskhold_complete, re-read the task's acceptance check and run it.");
if (c.evidence.some((x) => x.includes("vague question"))) plan.push("Need a decision: taskhold_block with Decide/Answer/Approve/Access first, options and your recommendation (https://taskhold.com/docs/agent-messages).");
console.log(`Instruction changes for CLAUDE.md / AGENTS.md:\n${plan.map((p) => `- ${p}`).join("\n") || "- none this week"}\n`);
}
export {};
Sample output
From a run on October 26, 2026 against the live API, on a test account where one agent planned, reported and finished with a summary, and another asked a one-word question, finished without a summary and had its task reopened:
# Agent review, last 7 days (18 events)
## Claude Code in api
| KPI | Value | Bar |
|---|---|---|
| Tasks finished | 1 | - |
| Steps finished | 3 | - |
| Plans posted | 1 | 1 per multi-step task |
| Progress reports | 1 | a few per long task |
| Done with a real summary | 100% | 100% |
| Blocked questions | 0 | - |
| Median wait for your answer | - | your side of the deal |
| Reopened by you | 0 | 0 |
Evidence:
- nothing to flag
Instruction changes for CLAUDE.md / AGENTS.md:
- none this week
## Codex in web
| KPI | Value | Bar |
|---|---|---|
| Tasks finished | 1 | - |
| Steps finished | 0 | - |
| Plans posted | 0 | 1 per multi-step task |
| Progress reports | 0 | a few per long task |
| Done with a real summary | 0% | 100% |
| Blocked questions | 1 | - |
| Median wait for your answer | 1 min | your side of the deal |
| Reopened by you | 1 | 0 |
Evidence:
- 2026-10-26 blocked "Fix CSV export" with a vague question: "which one?"
- 2026-10-26 finished "Fix CSV export" without a summary of what changed and how it was checked
- 2026-10-26 you reopened "Fix CSV export" after the agent marked it done
Instruction changes for CLAUDE.md / AGENTS.md:
- Done: taskhold_complete with what changed and how you checked it (tests run, page opened).
- Before taskhold_complete, re-read the task's acceptance check and run it.
- Need a decision: taskhold_block with Decide/Answer/Approve/Access first, options and your recommendation (https://taskhold.com/docs/agent-messages).Turn the suggestions into instructions
Each line under "Instruction changes" is written to paste into the agent's CLAUDE.md or AGENTS.md as is. Add the ones that match what you saw in the evidence, run the review again next week, and drop a line once that agent's scorecard is clean. The full instruction block is on the quickstart; clear block questions are covered in agent messages.
Read next
Updated 2026-10-26 · Markdown for agents · Questions: hello@greatwork.company