WorkflowsMCP

Recruiting pipeline: Gmail → Notion ATS → Calendar

Applications become candidate rows, screens get booked against real availability.

@workflowsmcpJan 28, 2026Verified Aug 2026recruitinghiringschedulingats

A lightweight ATS for teams that do not want a real one. Applications sent to a jobs alias are parsed into a Notion Candidates database with the résumé attached, dedupe against previous applications, and once a candidate moves to Screen the workflow checks the panel's free/busy and books a 30-minute Google Meet — writing the event link back onto the candidate row.

How it flows

  1. 01

    Application lands on the jobs alias

    Filter labels it Inbound/Applications and keeps it out of the inbox.

  2. 02

    Watch notification fires

    Pub/Sub push; the 7-day watch renewal is on its own schedule.

  3. 03

    Name, role and résumé extracted

    From the message headers, body and the PDF attachment.

  4. 04

    Deduped against existing candidates

    Match on email: a repeat applicant appends to their row rather than forking it.

  5. 05

    Candidate row created at stage Applied

    Résumé attached, source recorded, applied date stamped.

  6. 06

    Moving to Screen checks panel availability

    freebusy.query across the panel, in the candidate's timezone; three slots offered.

  7. 07

    Meet booked and written back

    Event with a Meet link created, Screen Booked set on the Notion row.

Set up each app

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

Gmail

Intake for the jobs alias

  1. 01

    Filter and label inbound applications

    Settings → See all settings → Filters and Blocked Addresses → Create a new filter. Matches: to:jobs@acme.com has:attachment. Apply the label Inbound/Applications and skip the inbox. Labels are what your watch subscribes to. The intermediate "See all settings" click is the one people miss.

    Gmail search used by the filter
    to:jobs@acme.com has:attachment filename:pdf -label:Inbound/Processed
  2. 02

    Create the Pub/Sub topic and let Gmail publish to it

    Create the topic in your Cloud project, grant the Pub/Sub Publisher role on it to gmail-api-push@system.gserviceaccount.com, then add a push subscription pointing at your endpoint. Miss the grant and users.watch simply returns an error and intake never starts — it is the single most common reason a first run does nothing. Scopes while you are here: gmail.readonly covers both the watch and the attachment fetch, and you only need gmail.modify if you also write labels back. The Calendar half needs calendar.events plus calendar.freebusy.

  3. 03

    Watch the label via Pub/Sub

    users.watch pushes change notifications to the topic you just granted. The subscription expires after 7 days, and Google's own advice is to renew it once a day — a weekly renewal has no margin at all, and intake stops silently on day eight.

    POST /gmail/v1/users/me/watch
    {
      "topicName": "projects/acme-hiring/topics/gmail-inbound",
      "labelIds": ["Label_8823714590213"],
      "labelFilterBehavior": "include"
    }
  4. 04

    Pull the résumé attachment

    users.messages.get with format=full gives you the part ids; then users.messages.attachments.get for the bytes, which arrive base64url-encoded. Keep the message id — it is your dedupe key alongside the email address.

Notion

The candidate database and pipeline stages

  1. 01

    Create the "Candidates" database

    Properties: Name (Title), Email (Email), Role (Select), Stage (Status: Applied → Screen → Onsite → Offer → Hired / Rejected), Source (Select: Inbound / Referral / Outbound), Résumé (File), Applied On (Date), Screen Booked (Date), Panel (Person), Notes (Text).

  2. 02

    Dedupe on email before inserting

    Query by Email first: POST /v1/data_sources/{data_source_id}/query with Notion-Version: 2026-03-11 (retrieve the database once for its data source id; /v1/databases/{id}/query is deprecated). A repeat applicant should append to the existing row's Notes with the new date and role, not create a second candidate — nothing damages trust in an ATS faster than two rows for one person.

    Existing-candidate lookup
    {
      "filter": { "property": "Email", "email": { "equals": "lee@northwind.co" } },
      "page_size": 1
    }
  3. 03

    Upload the résumé straight to Notion

    Notion hosts the file for you — no bucket, no signed URLs, no expiry to outlive. Three calls: POST /v1/file_uploads to get an upload id, POST /v1/file_uploads/{id}/send with the bytes as multipart/form-data, then PATCH the page with the property below. The gotcha to design around is the clock: attach the upload within one hour or it expires and you start over. Résumés over 20 MB need mode "multi_part"; free workspaces cap a single file at 5 MiB.

    Attach the upload to the Résumé property
    {
      "properties": {
        "Résumé": {
          "type": "files",
          "files": [
            {
              "type": "file_upload",
              "file_upload": { "id": "a3f1c7e2-59b4-4d80-9c16-2e7ab5d43f08" },
              "name": "lee-okafor.pdf"
            }
          ]
        }
      }
    }

Google Calendar

Books the screen against real availability

  1. 01

    Check free/busy before offering slots

    freebusy.query across the whole panel in the candidate's timezone. Offer three slots, never one — a single proposed time turns scheduling into a week of email. A group address in items expands to its members, so mind two ceilings: groupExpansionMax tops out at 100 identifiers, and free/busy comes back for at most 50 calendars. Calendar does ship a native booking page that would let the candidate self-book, but an appointment schedule publishes one person's availability and does not aggregate a panel's — which is exactly what this step needs, and why it asks freebusy instead.

    POST /calendar/v3/freeBusy
    {
      "timeMin": "2026-04-06T09:00:00-04:00",
      "timeMax": "2026-04-10T18:00:00-04:00",
      "timeZone": "America/New_York",
      "items": [{ "id": "rae@acme.com" }, { "id": "eng-panel@acme.com" }]
    }
  2. 02

    Create the event with a Meet link

    Pass conferenceDataVersion=1 on the insert or the requested Meet link is silently dropped and you send the candidate an event with no way to join.

    POST /calendar/v3/calendars/primary/events?conferenceDataVersion=1
    {
      "summary": "Acme — 30 min screen — Lee Okafor",
      "start": { "dateTime": "2026-04-07T14:00:00-04:00", "timeZone": "America/New_York" },
      "end":   { "dateTime": "2026-04-07T14:30:00-04:00", "timeZone": "America/New_York" },
      "attendees": [{ "email": "lee@northwind.co" }, { "email": "rae@acme.com" }],
      "conferenceData": {
        "createRequest": { "requestId": "screen-lee-okafor-0407", "conferenceSolutionKey": { "type": "hangoutsMeet" } }
      },
      "reminders": { "useDefault": false, "overrides": [{ "method": "email", "minutes": 1440 }] }
    }
  3. 03

    Write the booking back to Notion

    Set Screen Booked and put the Meet link in Notes. The candidate row should answer "when is this person talking to us" without opening a calendar.