WorkflowsMCP

PTO coverage handoff: Calendar → Linear → Notion

Reassign a teammate's open Linear issues to their backup while out, confirmed with one click.

@workflowsmcpVerified project-managementptocoveragehandoff

When someone goes out of office, their open Linear issues sit until they're back. This watches Google Calendar for Out of Office events via the API's own eventType field, not a title keyword, looks up who's covering in a Notion roster, and DMs the backup a reassignment proposal. Nothing in Linear changes until the backup clicks Approve; then issues reassign with a comment naming who's out and back, and Notion records it. Slack's own OOO sync stays untouched on purpose — it never reassigns or notifies; that gap is why this exists.

How it flows

  1. 01

    Daily scan of team calendars

    Each team member's calendar is queried with eventTypes=outOfOffice; anyone with an event of that type active today is picked up for the run.

  2. 02

    Backup looked up in the Notion roster

    The OOO person's email is matched against the Team Roster database to get their Backup Email.

  3. 03

    Open issues fetched from Linear

    Every issue assigned to the OOO person with state.type unstarted or started — nothing already done or already canceled moves.

  4. 04

    Backup is DMed the proposal

    The backup's email resolves to a Slack id, and they get the exact issue list plus an Approve button. No issue moves without this click.

  5. 05

    Approval reassigns the issues

    One issueBatchUpdate call reassigns every issue at once; a commentCreate loop still runs once per issue since comments have no batch form.

  6. 06

    Coverage brief written to Notion

    A new page records the OOO window, the backup, and the reassigned issues with links — a paper trail beyond the Slack thread.

  7. 07

    Team channel gets the summary

    Slack's own OOO status sync is left alone — it only ever changes a status emoji. This is the notification of the actual handoff: who's covering and what moved.

Set up each app

Work through these in order — later apps usually need a token or an id from an earlier one.

Google Calendar

Detects who is actually out today, straight from the calendar

  1. 01

    Enable the Calendar API and create a service account

    Google Cloud Console → pick or create a project → APIs & Services → Library → enable "Google Calendar API". Then IAM & Admin → Service Accounts → Create Service Account — this creation flow is also where you tick "Enable Google Workspace Domain-wide Delegation" if you are taking that path in the next step.

  2. 02

    Get access to teammates' calendars — pick one of two paths

    This workflow reads other people's calendars, not just your own, which is a bigger permission ask than a personal automation, and there is no single-click way around it. Path A, needs a Workspace super admin: Admin console → Security → Access and data control → API controls → Manage Domain Wide Delegation → Add new → paste the service account's Client ID and the OAuth scope below → Authorize. Google states this can take up to 24 hours to propagate, usually faster. This path does not exist on a personal Gmail account — domain-wide delegation is a Workspace-only admin feature. Path B, no admin needed but does not scale: each team member opens their own calendar's settings → "Share with specific people" → shares with the service account's email, granting "See all event details". Fine for a handful of people; painful past that, and it silently breaks the day someone forgets to add a new hire.

  3. 03

    Pull each person's current Out of Office events

    Call events.list once per team member, filtered server-side with eventTypes=outOfOffice. The Calendar API's Event resource carries an explicit eventType field (default / focusTime / outOfOffice / and others), so this is an exact match against how the event was created, not a guess at whatever word someone typed into a title. calendarId is just the person's work email — no separate id lookup. Scope: calendar.events.readonly is enough; the broader calendar.readonly also exposes the calendar list and settings, which this workflow never touches. One thing this buys you nothing against: it only catches events actually created with Calendar's native Out of Office type. A plain event titled "on leave" with no OOO type set is invisible to this query, so it's worth telling the team once to use the real Out of Office button rather than a normal event with a description. The event's end.date (all-day OOO) or end.dateTime (timed) is the "back on" date used everywhere downstream — in the Linear comment, the Slack DM, and the team broadcast. Read it off this same events.list response, no second call needed.

    GET events.list — filtered to Out of Office
    curl -s -G "https://www.googleapis.com/calendar/v3/calendars/dana@acme.com/events" \
      -H "Authorization: Bearer $SERVICE_ACCOUNT_ACCESS_TOKEN" \
      -d eventTypes=outOfOffice \
      -d timeMin=2026-08-08T00:00:00Z \
      -d timeMax=2026-08-15T00:00:00Z \
      -d singleEvents=true

Notion

Holds the backup roster and the coverage brief

  1. 01

    Create the integration and share both the roster and the destination page

    notion.so/my-integrations → New integration. Capabilities: Content → "Read content" and "Insert content" (this workflow only ever creates pages, never edits one after the fact, so leave "Update content" off); User information → "User information with email addresses", needed to resolve a backup by email. Copy the Internal Integration Secret. Then share two things with the integration separately, via each page's ••• → Connections — the Team Roster database, and the parent page under which coverage briefs get created. The Team Roster database and the Coverage Handoffs parent page are two separate resources — sharing one does not share the other, so share both independently.

  2. 02

    Build the "Team Roster" database

    Properties: Name (Title), Email (Email), Backup Email (Email), Team (Select). One row per person. This table is the one piece of the whole workflow a human has to keep current by hand — nothing downstream will tell you a backup has left the team, it will just quietly notify the wrong person. Printing the backup's name on the coverage brief (below) at least makes a stale row visible instead of silently wrong.

  3. 03

    Create the coverage brief once a handoff is approved

    POST to /v1/pages with Notion-Version: 2026-03-11 on every request, parent set to the page_id shared in the first step. Get that id from the page's URL — the 32-character (or hyphenated UUID) segment after the last "-" in notion.so/Coverage-Handoffs-<id>. This call only fires after the Slack approval below returns — there is no half-written brief for a proposal nobody confirmed.

    POST https://api.notion.com/v1/pages · Notion-Version: 2026-03-11
    {
      "parent": { "type": "page_id", "page_id": "<coverage-handoffs-parent-id>" },
      "properties": {
        "title": { "title": [{ "text": { "content": "Coverage — Dana OOO Aug 8–15" } }] }
      },
      "children": [
        { "object": "block", "type": "paragraph",
          "paragraph": { "rich_text": [{ "type": "text",
            "text": { "content": "Backup: Sam. 3 issues reassigned: GRW-412, GRW-413, GRW-414." } }] } }
      ]
    }

Linear

Where the open work actually lives

  1. 01

    Create a personal API key

    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 prefix is for OAuth tokens, and pasting it here is the most common way to get a 400 on an otherwise-correct request.

  2. 02

    Find the OOO person's open issues

    Filter by the assignee's email and by state.type. "Open" means the state type is unstarted or started, and type is a system enum shared by every team, so one filter works regardless of which team the issue lives in — no need to look up team or state ids first. `WorkflowStateFilter.type` is a `StringComparator`, which carries an `in` field, so the two-type filter below is a single schema-valid query, not a guess.

    Open issues for the OOO person
    query OpenIssuesFor {
      issues(filter: {
        assignee: { email: { eq: "dana@acme.com" } }
        state: { type: { in: ["unstarted", "started"] } }
      }) {
        nodes { id identifier title url }
      }
    }
  3. 03

    Resolve the backup, then wait for approval before touching anything

    users(filter: { email: { eq } }) resolves the backup's email to their Linear user id, using the same eq-filter shape as the issues query above. Nothing in Linear is written yet at this point: this id plus the issue list from the previous step are exactly what goes into the Slack proposal below. Only after the backup clicks Approve does this run: reassigning is one issueBatchUpdate(input: { assigneeId }, ids: [...]) call for every issue at once — Linear does expose a batch mutation for this. What has no batch form is commentCreate (there is no commentBatchCreate), so the per-issue "reassigned from X, OOO until Y" comment still needs one call per issue.

    Resolve backup id, then reassign — runs only after Slack approval
    query ResolveBackup {
      users(filter: { email: { eq: "sam@acme.com" } }) { nodes { id name } }
    }
    
    mutation ReassignAll {
      issueBatchUpdate(ids: ["<id1>", "<id2>", "<id3>"], input: { assigneeId: "<backup-user-id>" }) {
        success
        issues { id identifier url }
      }
    }
    
    mutation LeaveNote {
      commentCreate(input: {
        issueId: "<issue-id>"
        body: "Reassigned from Dana (OOO until Aug 15) to Sam for coverage."
      }) { success }
    }

Slack

Proposes the handoff and gets a human to confirm it

  1. 01

    Create the app and scopes, then invite it to the team channel

    api.slack.com/apps → Create New App → OAuth & Permissions → Bot Token Scopes: chat:write (send the DM and the broadcast), users:read.email (resolve the backup's email to a Slack id). Install to Workspace, copy the Bot User OAuth Token, then /invite @<app-name> in whichever channel gets the post-handoff broadcast. This needs a bot token and the Web API rather than a simple Incoming Webhook: the backup is worked out at run time from whoever the roster names that day, not a fixed channel picked at setup, and an Incoming Webhook can only ever post to the one channel it was created for.

  2. 02

    Turn on interactivity for the Approve button

    api.slack.com/apps → your app → Interactivity & Shortcuts → toggle Interactivity on → set a Request URL. Skip this and the Approve button below renders but does nothing when clicked — Slack POSTs the click to that URL as a form-encoded payload and expects an acknowledgement within 3 seconds; acknowledge first, then call Linear.

  3. 03

    Resolve the backup's Slack id and send the proposal — nothing is reassigned yet

    users.lookupByEmail on the Backup Email from the Notion roster row, then DM that user id with the specific issues about to move and an Approve button. This is the only checkpoint in the entire workflow before Linear or Notion get written to, and it exists on purpose: a backup is "the default contact when this person is out," not necessarily the right new owner for every one of their tickets. The approve click is the person who would actually know, saying so.

    DM to the backup — approve action carries the proposed reassignment
    {
      "channel": "<backup-slack-id>",
      "text": "Dana is OOO through Aug 15 — 3 Linear issues would move to you.",
      "blocks": [
        { "type": "section",
          "text": { "type": "mrkdwn",
            "text": "*Dana is OOO through Aug 15.* Proposed handoff to you:\n• GRW-412 · GRW-413 · GRW-414" } },
        { "type": "actions",
          "elements": [
            { "type": "button", "style": "primary",
              "text": { "type": "plain_text", "text": "Approve handoff" },
              "value": "pto:dana@acme.com:sam@acme.com:GRW-412,GRW-413,GRW-414",
              "action_id": "pto_handoff_approve" }
          ] }
      ]
    }
  4. 04

    Broadcast the confirmed handoff to the team channel

    Once the backup approves and Linear and Notion have both run, post one message to the team channel: who's out, who's covering, and a link to the Notion brief. channel here is the channel id from the first step, not a user id, so this one lands in public rather than a DM.

    POST https://slack.com/api/chat.postMessage — team broadcast
    {
      "channel": "C04TEAMOPS",
      "text": "Coverage: Sam is covering for Dana (OOO through Aug 15). 3 Linear issues reassigned — details: <notion-brief-url>"
    }