A 9-engineer team in Hyderabad spent the last two hours of every sprint hand-compiling a retro doc: who shipped what, which issues slipped, what the cycle-time looked like. Their team lead did it manually in Notion, copying issue titles out of Linear one by one. We replaced it with a 15-node n8n workflow that pulls every closed Linear issue from the sprint, groups them per engineer, drafts a structured Notion retro page, and posts the link to Slack — all triggered the moment the sprint cycle ends. This post is the complete build, with the Linear GraphQL queries, the Notion block payload, error handling, and run cost in ₹.
What does this retro auto-drafter do?
When a sprint cycle closes, the workflow queries Linear's GraphQL API for every issue completed in that cycle, groups them by assignee, calculates per-engineer counts and median cycle time, sends the raw data to Claude for a short narrative summary, and creates a Notion page with a section per engineer plus a "what slipped" block for carried-over issues. It then drops the Notion link into the team's Slack channel. Tested on n8n v1.62, July 2025.
Why auto-draft the retro instead of writing it live?
The retro doc is not the retro — the conversation is. But teams burn the first 20 minutes of every retro reconstructing what happened because nobody compiled it beforehand. Linear's API already knows what shipped; Linear recommends filtering issues in the GraphQL request rather than fetching everything. Pulling that into a pre-written Notion doc means the meeting starts with discussion, not archaeology. We run this on our own sprints and shipped it for one client team after they saw ours.
The 15 nodes, grouped
Step 1: Set up the Linear, Notion, and Claude credentials
Linear uses a personal API key — generate it in Linear under Settings → Security & access → Personal API keys, and pass it in the Authorization header (no Bearer prefix). Notion uses an internal integration token from notion.so/my-integrations, and you must share the target parent page with that integration or the create-page call returns 404. Claude uses an Anthropic API key with the x-api-key header.
Step 2: The Linear completed-issues query
Node 3 runs a GraphQL query against https://api.linear.app/graphql. You filter issues by cycle.id and a completed state. The HTTP node body:
{
"parameters": {
"method": "POST",
"url": "https://api.linear.app/graphql",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{ "name": "Authorization", "value": "={{ $credentials.linearApi.apiKey }}" },
{ "name": "Content-Type", "value": "application/json" }
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ query: 'query($cycleId: ID!) { issues(filter: { cycle: { id: { eq: $cycleId } }, completedAt: { null: false } }) { nodes { identifier title estimate completedAt startedAt assignee { name } } } }', variables: { cycleId: $json.cycle_id } }) }}"
},
"name": "Linear Completed Issues",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2
} Verify this node by running it against a finished cycle and counting the returned nodes[] against Linear's cycle view in the UI. If counts differ, your state filter is wrong — Linear distinguishes "completed" from "cancelled," and you usually want completed only.
Step 3: Group by engineer and compute cycle time
Node 4 is a Code node that buckets issues per assignee and computes median cycle time from startedAt to completedAt. This is the math the team lead used to eyeball:
// n8n Code node — group by engineer
const issues = $json.data.issues.nodes;
const byEngineer = {}; for (const i of issues) {
const name = i.assignee?.name || 'Unassigned';
if (!byEngineer[name]) byEngineer[name] = { count: 0, points: 0, cycleHours: [] };
byEngineer[name].count += 1;
byEngineer[name].points += i.estimate || 0;
if (i.startedAt && i.completedAt) {
const h = (new Date(i.completedAt) - new Date(i.startedAt)) / 36e5;
byEngineer[name].cycleHours.push(h);
}
}
const median = (arr) => {
if (!arr.length) return 0;
const s = [...arr].sort((a, b) => a - b);
const m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
};
const rows = Object.entries(byEngineer).map(([name, d]) => ({
name, count: d.count, points: d.points,
medianCycleHours: Math.round(median(d.cycleHours))
}));
return [{ json: { rows } }];
Step 4: Build the Notion page
Node 9 assembles Notion block objects, and node 11 creates the page. Notion's create-page endpoint takes a parent and a children array of block objects. The create-page node body:
{
"parameters": {
"method": "POST",
"url": "https://api.notion.com/v1/pages",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{ "name": "Authorization", "value": "=Bearer {{ $credentials.notionApi.apiKey }}" },
{ "name": "Notion-Version", "value": "2022-06-28" },
{ "name": "Content-Type", "value": "application/json" }
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({\n parent: { page_id: $env.NOTION_RETRO_PARENT },\n properties: { title: { title: [ { text: { content: 'Sprint Retro — ' + $json.cycle_name } } ] } },\n children: $json.blocks\n}) }}"
},
"name": "Create Notion Retro",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2
}Notion caps a single create-page call at 100 child blocks, so if your retro has more than that, create the page first and append the rest with the blocks endpoint in node 12 — that is exactly why the workflow splits create and append into two nodes.
- Linear personal API key in the Authorization header (no Bearer)
- Notion parent page shared with the integration
- Claude API key with a low
max_tokensfor the summary - Cycle ID resolved from the cycle name or schedule
- State filter set to completed, not cancelled
- Block count under 100 per create call, append for the rest
- Slack channel ID for the retro link post
How do you handle errors in this workflow?
Three failure modes matter. First, the Notion 404 from an unshared parent page — covered above, but also add an Error Trigger that posts the exact Notion error to Slack so you see it immediately. Second, Linear pagination — if a cycle has more than 50 issues you must page through pageInfo.hasNextPage, so add a Loop node, or your retro silently drops issues past the first page. Third, an empty cycle (a sprint where nothing closed) — add an IF after the Linear node that skips Notion creation and posts "no completed issues this cycle" to Slack instead of creating a blank doc.
max_tokens at 400 so a runaway response cannot surprise you.
How much does this cost to run?
Almost nothing. Linear and Notion APIs are free on their paid plans, Slack is free, and the only metered cost is the Claude summary per sprint, plus the flat Hetzner box. Here is the per-run cost breakdown:
The economics are obvious: the workflow pays for itself the first time it runs, because two hours of a senior engineer's time costs far more than a year of Claude tokens. Our AI and automation team sets these up as part of larger developer-productivity engagements.
When should you not build this?
If your team does not use Linear cycles (some teams run continuous flow with no sprints), there is no cycle boundary to trigger on — use a date range instead, or skip this. If your retros are already short and data-light, automating the data pull adds little. And if nobody reads the retro doc, do not generate it; a doc nobody opens is worse than no doc because it signals the ritual is dead.
Real example: the Hyderabad product team
The client's team lead spent the last two hours of every two-week sprint compiling the retro by hand, which meant the lead's own sprint work shrank. After we shipped this flow, the doc is ready before the retro meeting starts, and the lead got those two hours back per sprint — about 52 hours a year. The cycle-time-per-engineer number, which nobody used to compute, now surfaces quietly bad patterns like one engineer consistently sitting on large issues. We applied the same Linear-to-doc spine when building internal tooling around ExamReady's release cycle.
Computing median cycle time per engineer was something we always meant to do and never did. The workflow does it every sprint without anyone asking, and that is where the real insight came from.
Frequently asked questions
Does this work if we use GitHub Issues or Jira instead of Linear?
The shape is the same — swap the Linear GraphQL node for the GitHub or Jira API node and adjust the field names. Jira's JQL filter and GitHub's REST search both return closed issues with assignees and timestamps. The grouping and Notion nodes stay identical.How does it handle issues with no assignee?
The Code node buckets them under "Unassigned" so they still appear in the retro. Unassigned completed issues are often a sign of process drift worth surfacing, so we deliberately do not hide them.Can the summary use a model other than Claude?
Yes. The summary node is a plain HTTP request, so any LLM with an API works. We use Claude Haiku for cost and speed, but the node swaps cleanly to another provider if you standardise elsewhere.What if a sprint has more than 50 closed issues?
Linear paginates. Add a Loop node that followspageInfo.hasNextPage and accumulates nodes, or the retro silently drops everything past the first page. This is the single most common bug in DIY versions of this flow.Does it overwrite the previous sprint's retro?
No. Each run creates a new Notion page titled with the cycle name, under a shared parent page. Past retros stay as a browsable history, which teams find useful for trend-spotting.How long does it take to build?
A working v1 takes 4 to 6 working days, including resolving the Notion sharing setup and agreeing what the retro doc template should contain with your team lead.Want this sprint-retro auto-drafter wired to your Linear and Notion?
We ship a self-hosted n8n developer-productivity workflow in 6 working days. Suitable if your team runs Linear cycles and someone burns an afternoon compiling retros by hand. No slides — show us your Linear workspace and we will draft the template.
Book a 20-min CallRelated reading
