WorkflowsMCP

Weekly cross-team status: Linear + Notion → Slack

Linear Cycle signals paired with each team's Notion update, in one weekly Slack digest.

@workflowsmcpVerified project-managementasync-updatescross-teamvelocity

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

  1. 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.

  2. 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.

  3. 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.

  4. 04

    Merge per team

    Each team becomes one entry: completion rate, overdue/stale counts, and whatever qualitative update Notion had — or its absence.

  5. 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.

  6. 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

  1. 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`.

  2. 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 query
    query Bootstrap {
      teams {
        nodes {
          id
          name
          cyclesEnabled
          cycleDuration
        }
      }
    }
  3. 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.

  4. 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 query
    query 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

  1. 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.

  2. 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.

  3. 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

  1. 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.

  2. 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" }] }
      ]
    }
  3. 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.lookupByEmail
    GET https://slack.com/api/users.lookupByEmail?email=<from Linear assignee.email or Notion Owner>
    Authorization: Bearer <SLACK_BOT_TOKEN>