Weekly cross-team status: Linear + Notion → Slack
Linear Cycle signals paired with each team's Notion update, in one weekly Slack digest.
Linear's own "health" field looks like at-risk, but it's filed by hand on a Project Update, never touches Cycle/Issue data, and a team with no Project has none. This computes an objective signal instead: it reads each team's active Cycle from the GraphQL API, flags issues overdue or gone quiet by rules in the setup steps, and pairs that with the team's Notion update — one Slack message to leadership instead of chasing updates per team. It doesn't replace Project health where a team keeps it current; that stays optional, not superseded.
How it flows
- 01
Scheduled trigger kicks off the run
An external cron or serverless schedule (e.g. Monday 08:00) starts the job. Slack's own Workflow Builder "On a schedule" trigger cannot call out to Linear's or Notion's APIs, so the orchestration has to live outside Slack.
- 02
Linear: pull each team's Cycle signal
Completion by issue count, plus the overdue and stale issue lists, computed per the rules in the Linear setup steps.
- 03
Notion: pull this week's team updates
A team with no matching row this week is marked "no update posted" rather than silently skipped.
- 04
Merge per team
Each team becomes one entry: completion rate, overdue/stale counts, and whatever qualitative update Notion had — or its absence.
- 05
Sort at-risk teams to the top and assemble the message
Teams with at least one overdue or stale issue lead the digest; the rest follow in the order teams were configured.
- 06
Post to the leadership channel
chat.postMessage delivers the digest. If @-mentions are enabled, emails resolve through users.lookupByEmail first.
Set up each app
Work through these in order — later apps usually need a token or an id from an earlier one.
Linear
Objective Cycle/Issue signals — completion rate, overdue and stale issues
- 01
Create a personal API key (or an OAuth app scoped to read)
Linear → Settings → Security & access → Personal API keys → New API key (linear.app/settings/account/security). Send it as the raw Authorization header value — no "Bearer " prefix, that's for OAuth tokens and the single most common 400 here. A personal key carries the same permissions as the account that created it and cannot be scoped down; if you're distributing this beyond your own use, register an OAuth app instead and request only the `read` scope. Either way this workflow only ever reads — it never needs `write`, `issues:create`, or `admin`.
- 02
Bootstrap: list teams and confirm Cycles are on
Run the query below once and keep the `id` and `cycleDuration` of every team you want in the rollup. Check `cyclesEnabled` for each — a team that has never turned on Cycles always returns `activeCycle: null` and will silently drop out of every later query instead of erroring. Worth knowing before you sell this as "cross-team": Linear's Free plan caps a workspace at 2 teams, so a report that meaningfully spans three or more teams usually implies a paid plan already.
Bootstrap queryquery Bootstrap { teams { nodes { id name cyclesEnabled cycleDuration } } } - 03
Read completion by issue count, not by `progress`
`Cycle.progress` looks like the obvious field, but Linear's own schema comment defines it as (completed estimate points + 0.25 × in-progress estimate points) / total estimate points, and states it "returns 0 if no estimate points exist." A team that doesn't use story points shows 0% forever on that field, which reads as total failure. `issueCountHistory` and `completedIssueCountHistory` are two arrays Linear updates once a day regardless of estimates; the last entry of each is "as of today", and `completedIssueCountHistory[-1] / issueCountHistory[-1]` is the ratio to actually report. Those two arrays only advance on Linear's own daily refresh, so a run that fires before that day's refresh reads yesterday's snapshot — a non-issue for a weekly Monday-morning rollup, but worth knowing if this ever gets rescheduled to run more than once a day.
- 04
Fetch overdue and stale issues — and where this differs from Linear's own at-risk field
"At-risk" in this workflow means two objective, per-issue conditions checked against Cycle/Issue data, no manual input involved: an issue is **overdue** when `dueDate` is before today and `state.type` is not `completed`, `canceled`, or `duplicate` (Linear's WorkflowState type enum has three non-active values relevant here, not two — dropping `duplicate` would misreport a duplicated issue that still carries a due date as overdue). An issue is **stale** when `state.type` is `started` and `updatedAt` is older than `staleAfterDays`, computed per team as `max(2, round(cycleDuration_weeks × 7 × 0.2))` — 20% of that team's own Cycle length in days, floored at 2, rather than one fixed day count applied to a 1-week and a 4-week Cycle alike. A team is flagged at-risk in the rollup when it has one or more overdue or stale issues this Cycle. This is deliberately not the same signal as the `health` field on Project and Initiative (onTrack / atRisk / offTrack): Linear's own docs state health "is not automatically calculated from issue or cycle data" and rely entirely on someone publishing a Project Update, and the field lives on Project/Initiative — one level above Cycle — so a team without a Project attached has no health value at all. If a team already keeps Project health current, add `project.health` into the merge step of the flow as a supplementary line, not a replacement for the query below.
Cross-team Cycle + at-risk queryquery CrossTeamRollup($teamIds: [ID!], $overdueBefore: TimelessDateOrDuration!, $staleUpdatedBefore: DateTimeOrDuration!) { teams(filter: { id: { in: $teamIds } }) { nodes { id name activeCycle { id name startsAt endsAt issueCountHistory completedIssueCountHistory overdue: issues(filter: { dueDate: { lt: $overdueBefore } state: { type: { nin: ["completed", "canceled", "duplicate"] } } }) { nodes { id identifier title url dueDate assignee { email name } } } stale: issues(filter: { state: { type: { eq: "started" } } updatedAt: { lt: $staleUpdatedBefore } }) { nodes { id identifier title url updatedAt assignee { email name } } } } } } } # variables — overdueBefore is always "P0D" (today, per the scalar's own # semantics: the duration is added to the current date). staleUpdatedBefore is # computed per team from the formula above, e.g. "-P6D" for a 4-week Cycle # (28 days × 0.2 = 5.6, rounded to 6). { "teamIds": ["<team-uuid-1>", "<team-uuid-2>"], "overdueBefore": "P0D", "staleUpdatedBefore": "-P6D" }
Notion
Qualitative team updates, matched by team and week
- 01
Create the "Team Updates" database
Properties: Team (Select), Week (Date), Highlights (Text), Risks / Blockers (Text), Owner (Person, optional). This assumes each team already writes a short update here weekly — if nobody does, that half of the rollup will just read "no update posted this week" for every team, which is itself a signal but won't feel like much on day one. A page-per-update wiki works too, but reading it means paginating `/v1/blocks/{block_id}/children` across dozens of block types with no server-side date filter; this workflow assumes the database model instead, matching the shape this catalogue's other status-record seeds already use.
- 02
Create an internal connection and add it to the database
app.notion.com/developers/connections → Build → Internal connections → Create a new connection → pick the workspace → Configuration tab → copy the Installation access token. Then open the Team Updates database → ••• (top right) → Connections → + Add connection → your connection → confirm. Skipping that last part is the usual cause of a 404 on a database you can plainly see. Leave the connection without "user information" capability unless you plan to resolve the Owner property to an email for @-mentions — without that capability `/v1/users` returns 403, and this workflow's main job doesn't need per-person routing.
- 03
Query this week's rows
Retrieve the database once to get its data source id, then query with the filter below, using this week's Monday as the date. A team with no matching row is the "no update posted" case — treat a missing row as data, not as an error to retry.
POST /v1/data_sources/{data_source_id}/query · Notion-Version: 2026-03-11{ "filter": { "property": "Week", "date": { "on_or_after": "2026-08-03" } }, "sorts": [{ "property": "Team", "direction": "ascending" }] }
Slack
Delivers the merged rollup to the leadership channel
- 01
Create the bot and install it to the leadership channel
api.slack.com/apps → Create New App → From scratch → OAuth & Permissions → Bot Token Scopes → add `chat:write` (also add `chat:write.public` if you'd rather not manually invite the bot into a public channel) → Install to Workspace → `/invite @<bot-name>` into the leadership channel.
- 02
Post the rollup
One message, one `section` block per team, at-risk teams first. Block Kit caps a message at 50 blocks and a `section.text` at 3000 characters — with the handful of teams this workflow targets, neither limit is close.
POST https://slack.com/api/chat.postMessage{ "channel": "#leadership", "text": "Cross-team Status Rollup — week of Aug 10", "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "Cross-team Status Rollup — week of Aug 10" } }, { "type": "section", "text": { "type": "mrkdwn", "text": "*Platform* — 3/10 issues complete, cycle ends in 2 days ⚠️ · 0 overdue · 1 stale\n_Update:_ no update posted this week." } }, { "type": "section", "text": { "type": "mrkdwn", "text": "*Growth* — 6/9 issues complete this cycle · 2 overdue\n_Update:_ Pricing experiment shipped, waiting on analytics." } }, { "type": "divider" }, { "type": "context", "elements": [{ "type": "mrkdwn", "text": "Linear Cycle snapshots + Notion team updates · generated Mon 08:00" }] } ] } - 03
(Optional) resolve emails to Slack ids for @-mentions
To @-mention an at-risk team's owner instead of just naming them, add the `users:read.email` scope and look up their Slack id from the email that Linear's `assignee.email` or Notion's Owner property already gives you. Skip this step entirely if the message is meant for the whole channel to read rather than to page any one person — it is the only optional setup step in this workflow.
GET https://slack.com/api/users.lookupByEmailGET https://slack.com/api/users.lookupByEmail?email=<from Linear assignee.email or Notion Owner> Authorization: Bearer <SLACK_BOT_TOKEN>