WorkflowsMCP

Content calendar gap-fill: Notion → Slack → Todoist

Finds empty calendar days, rotates an owner, files a Todoist draft task.

@workflowsmcpVerified contentmarketingschedulingeditorial-calendar

Notion's Recurring and Property-edited automations already remind you about a day that has a draft — this does not rebuild that. It finds the opposite: days in the next week with no calendar row (a gap no Formula, Rollup, or trigger can express), assigns each empty day by Last Assigned with an immediate write-back so one run never double-books, creates a placeholder, files a Todoist task, and DMs the owner. If your calendar already chases Ready drafts through approval, this does not collide — it watches the blank days approval never sees.

How it flows

  1. 01

    Daily job queries the next 7 days of the calendar

    One data source query per day, on_or_after today through before today+7, pulling every row's Publish Date in that window.

  2. 02

    Gap days are computed locally, not by a Notion filter

    The job builds the 7 expected calendar-day strings, normalizes every returned Publish Date to date-only (Notion's Date property carries time-of-day, and two rows on the same day can differ down to the second), then diffs the two sets — the remainder is the gap days. Notion has no built-in way to do this: a Formula property only sees its own row, and a Rollup only aggregates through an explicit Relation to another data source, not a reverse "does any row in this range exist" check on the same database. No gaps this run means the job stops here — nothing gets posted on a clean week.

  3. 03

    Each gap day claims the next rotation owner and writes back immediately

    For each gap day, in date order: query Content Rotation for Active rows sorted by Last Assigned ascending, take the first, and PATCH that row's Last Assigned to today before moving to the next gap day. Skipping that immediate write-back is the one bug that assigns every gap day in a multi-day run to the same person.

  4. 04

    A fully inactive roster falls back to the team channel

    If every Content Rotation row is unchecked, skip the per-person assignment for that day (no Owner on the placeholder) and route the later Slack step to #content-calendar instead of a DM — do not send here; the message still goes out in the final notify step so Notion/Todoist links are included once.

  5. 05

    Placeholder calendar row is created

    A new Content Calendar row for the gap day: Owner = the assigned person (omit if the roster was fully inactive), Status = a value in your To-do group, Publish Date = the gap day.

  6. 06

    Draft task filed in Todoist

    A task in the Content Calendar project, due the gap day or 1–2 days earlier to leave drafting time — assignee_id only on a plan that supports Assigned tasks; otherwise put the owner's name in the task content. If no owner, omit assignee_id and say "unassigned gap" in content.

  7. 07

    Owner is DMed with both links

    Slack DM (or the fallback channel) names the assigned day, the new Notion row URL, and the Todoist task URL, so the owner has everything in one message.

Set up each app

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

Notion

Editorial calendar (source of truth) and the rotation roster

  1. 01

    Build the "Content Calendar" database

    Create a new page → Table. Properties: Title (Title), Publish Date (Date), Owner (Person), Status (Status — values must sit in Notion's three built-in groups To-do / In Progress / Complete; the API's status-group filter and this workflow's placeholders key off the group). If this same calendar already feeds an approval-and-schedule pipeline, reuse its Status options and set gap placeholders to a To-do-group value (e.g. Idea or Drafting) — do not invent a parallel Status vocabulary on the shared database. If you reuse the same database as an approval/schedule pipeline that already named the date property Publish At (with time), filter and sort on that exact property name — do not add a second date column. Gap detection still normalizes to date-only before the set diff.

  2. 02

    Build the "Content Rotation" database, kept separate

    A second database, not extra properties bolted onto the calendar — rotation state is read and written by different logic than content status, and merging them couples the two. Properties only: Person (Person), Active (Checkbox — uncheck someone who is out or off the team instead of deleting the row), Last Assigned (Date). No Order column: sorting Active rows by Last Assigned ascending already gives a self-balancing queue; a separate ordinal is a second place to keep the same information in sync.

  3. 03

    Connect the integration to both databases

    Settings → Connections → New internal connection, name it, copy the token (ntn_ on integrations created recently, or secret_ on older ones — both work as Bearer today). Then, on each database, ••• (top right) → Connections → Add connection → your connection. Content Calendar needs Read, Update, and Insert content (this workflow creates placeholder rows on gap days); Content Rotation only needs Read and Update. Skipping either grant is what turns into a 404 on the query below or a 403 on the write-back.

  4. 04

    Retrieve the calendar data source id and confirm the 7-day query

    GET /v1/databases/{database_id} on the calendar returns data_sources[].id — queries and writes target that data source id, not the database id (Notion-Version: 2026-03-11 split the two). Compute the two date bounds yourself from the job's own clock rather than a Notion relative filter like next_week, so the window does not depend on when Notion last recalculated anything. Use your date property name in the filter (this seed's example uses Publish Date; if the shared DB already uses Publish At, substitute that exact name).

    POST /v1/data_sources/{data_source_id}/query · date prop example: Publish Date (or your Publish At) · Notion-Version: 2026-03-11
    {
      "filter": {
        "and": [
          { "property": "Publish Date", "date": { "on_or_after": "2026-08-10" } },
          { "property": "Publish Date", "date": { "before": "2026-08-17" } }
        ]
      },
      "sorts": [{ "property": "Publish Date", "direction": "ascending" }]
    }
  5. 05

    Retrieve the rotation data source id; plan the per-gap write-back

    Same GET /v1/databases/{database_id} against Content Rotation. The flow queries Active rows sorted by Last Assigned ascending, takes the first Person, then PATCHes that row's Last Assigned to today before picking the next gap day — skipping the immediate write-back is what assigns every empty day in one run to the same person. Use the page id from the rotation query result; Notion-Version: 2026-03-11. Date property shape PENDING-WEB confirm on developers.notion.com/reference/page-property-values.

    POST rotation query, then PATCH /v1/pages/{rotation_page_id} · write Last Assigned before next gap
    {
      "filter": { "property": "Active", "checkbox": { "equals": true } },
      "sorts": [{ "property": "Last Assigned", "direction": "ascending" }]
    }
    ---
    PATCH /v1/pages/{rotation_page_id}
    {
      "properties": {
        "Last Assigned": { "date": { "start": "2026-08-09" } }
      }
    }

Slack

DMs the assigned owner (channel only as inactive-roster fallback)

  1. 01

    Create the app and add bot scopes

    api.slack.com/apps → Create New App → From scratch → OAuth & Permissions → Bot Token Scopes: users:read.email (resolve the rotation Person's email to a Slack user id — plain users:read is not enough), im:write (open the DM), chat:write (send it). Install to the workspace for the bot token.

  2. 02

    Look up the user, open a DM, post — then check the body

    users.lookupByEmail on the assigned rotation row's email, conversations.open with that user id to get a channel id, then chat.postMessage to it. Slack wraps failures in an HTTP 200 — a bad token or missing scope still comes back {"ok":false,"error":"..."} at status 200, so read ok from the body on every call, not just the status code.

    users.lookupByEmail → conversations.open → chat.postMessage
    curl -s -G "https://slack.com/api/users.lookupByEmail" \
      -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
      --data-urlencode "email=owner@company.com"
    
    curl -X POST "https://slack.com/api/conversations.open" \
      -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"users":"U0REP123"}'
    
    curl -X POST "https://slack.com/api/chat.postMessage" \
      -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"channel":"D0XXXXXXX","text":"You are up: Aug 14 has no draft on the calendar. New placeholder row + Todoist task created."}'
  3. 03

    Create the fallback channel for a fully inactive roster

    Create #content-calendar (or reuse an existing editorial channel) and keep its channel id in config. When every Content Rotation row is unchecked, the flow cannot DM anyone — it posts the gap alert there instead. Skipping this step is fine only if you accept "all inactive → log and skip notify" as the failure mode; otherwise this channel is required for that branch.

Todoist

Holds the draft task for the gap day

  1. 01

    Create a dedicated project

    Make a "Content Calendar" project and copy its project_id from the URL (or GET /api/v1/projects), so gap-fill tasks land somewhere separate from anyone's personal to-do list.

  2. 02

    Grab a personal API token

    Todoist web app → avatar → Integrations → Developer → copy the API token. Bearer auth, no OAuth flow to run.

  3. 03

    Pull collaborators once and check your plan tier

    GET the project's collaborators to map each rotation Person's email to a Todoist user_id for assignee_id. Todoist's pricing page lists "Assigned tasks" under Pro/Business only, not Free/Beginner — on Free, drop assignee_id and put the owner's name in the task content instead. POST to /api/v1/tasks; /rest/v2/tasks returns 410 Gone.

    POST api.todoist.com/api/v1/tasks
    curl -X POST "https://api.todoist.com/api/v1/tasks" \
      -H "Authorization: Bearer $TODOIST_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "content": "Draft: 2026-08-14 content slot (gap-fill)",
        "project_id": "2203306141",
        "due_date": "2026-08-13",
        "assignee_id": "6X6WMMqgq2PWxjCX"
      }'