Developer Handover Pack
Everything needed to build the automation from scratch: current state, full agent specs, data flow, and the build stack.
Developer Handover Pack
Content Calendar Management
[YourCompany.com] · Marketing Department · Prepared by FullSpec · [Today's Date]
This document gives the FullSpec build team everything needed to construct, connect, and validate the three-agent Content Calendar Management automation. It covers the current-state step map with bottleneck flags, full agent specifications with IO contracts, an end-to-end data flow trace, and the recommended build stack. The process owner is not expected to action anything here. All build, integration, and testing work is handled by FullSpec. Questions should be directed to support@gofullspec.com.
01Process snapshot
02Agent specifications
Watches the Notion content database for two distinct event types: a new content row being created, and an existing row reaching its deadline date field without a draft link present. On a new-row event it reads the topic, format, target audience, assigned writer, channel, and deadline fields and composes a structured brief message, then dispatches it to the assigned writer's Slack handle via direct message. On a deadline event it checks whether the draft_link field is populated; if not, it sends an overdue reminder to the writer and posts a copy to the marketing manager's Slack DM. No human input is required after the Notion record is created.
// Input notion.database_id: string // Confirmed Notion DB ID, set in credential store notion.row.title: string // Content piece title or working headline notion.row.topic: string // Topic or keyword target notion.row.format: string // e.g. 'Blog post', 'LinkedIn carousel', 'Short video' notion.row.channel: string // Target channel: LinkedIn, Instagram, Blog, etc. notion.row.assigned_writer: string // Notion person field, maps to Slack user ID via lookup table notion.row.deadline: date // ISO 8601 date, used for reminder trigger notion.row.draft_link: url | null // Null triggers overdue reminder path // Output slack.dm.to: writer_slack_id // Resolved from Notion person -> Slack lookup slack.dm.message: formatted_brief // Structured block with title, format, channel, deadline slack.dm.to: manager_slack_id // Copied on overdue reminder only slack.dm.message: overdue_reminder // Includes content title, deadline, and direct Notion link
- Notion plan must be Team tier or above to enable API access. Confirm the workspace plan before build starts.
- The Notion database must have standardised field names before any mapping is done: 'title', 'topic', 'format', 'channel', 'assigned_writer' (person), 'deadline' (date), 'draft_link' (URL), 'status' (select). A messy or inconsistently named database is the primary build delay risk.
- The Notion-to-Slack user ID lookup must be built as a static mapping table in the credential store during Notion audit week. It does not resolve dynamically at runtime.
- The deadline reminder must fire on the exact deadline date at 09:00 in the workspace's local timezone, not UTC, unless the team confirms UTC is acceptable.
- Dedupe: if the Notion row is updated multiple times on the same day (e.g. deadline field edited), the agent must check for a sent_brief flag on the record to avoid duplicate brief messages. Set a boolean custom property 'brief_sent' in Notion on first dispatch.
- Overdue reminder cadence: one reminder on deadline day, one 24 hours later if draft_link is still null. Do not send more than two reminders per piece without manager confirmation.
- Confirm with the team whether briefs should also be posted to a shared Slack channel (e.g. #content-briefs) in addition to the writer DM, or DM only.
Monitors the Notion content database for two conditions: a Google Docs URL appearing in the draft_link field on a row whose status is not yet Approved, and a row status field changing to Approved. On draft submission it sends a Slack direct message to the assigned reviewer with the document link and the content title. When the reviewer marks the Notion record as Approved, the agent reads the approved copy text, target channel, and scheduled publish datetime from Notion, creates the corresponding post in Buffer, then writes the status back to Notion as Scheduled and creates or updates the linked HubSpot campaign record with the publish date and channel. If the reviewer sets status to Needs Revision the agent sends a Slack message back to the writer with the reviewer's comment field from Notion.
// Input (review routing path) notion.row.draft_link: url // Google Docs URL logged by writer notion.row.title: string notion.row.assigned_reviewer: string // Notion person field -> Slack user ID // Output (review routing path) slack.dm.to: reviewer_slack_id slack.dm.message: review_request // Includes title, draft_link, and Notion record link // Input (publish path) notion.row.status: 'Approved' notion.row.approved_copy: string // Text body of the post, stored in Notion rich text field notion.row.channel: string // Must map to a configured Buffer profile ID notion.row.publish_datetime: datetime // ISO 8601; used as Buffer scheduled_at value notion.row.hubspot_campaign_id: string | null // Optional; if null, a new campaign is created // Output (publish path) buffer.post.profile_id: string // Resolved from channel name -> Buffer profile ID map buffer.post.text: string // Pulled from approved_copy field buffer.post.scheduled_at: datetime buffer.post.id: string // Stored back to notion.row.buffer_post_id notion.row.status: 'Scheduled' hubspot.campaign.publish_date: date hubspot.campaign.channel: string hubspot.campaign.notion_record_url: url // On approval (revision path) slack.dm.to: writer_slack_id slack.dm.message: revision_notice // Includes title, reviewer comment from Notion, record link
- Buffer API requires an Essentials plan or above to access the scheduling endpoint. Confirm the active Buffer tier before build. The team plan or above is needed for multi-channel access.
- The channel-to-Buffer-profile-ID mapping must be hardcoded in the credential store during initial setup. Buffer does not return profile names in a lookup-friendly format at runtime.
- HubSpot integration uses the Campaigns API (Marketing Hub Starter or above required). Confirm HubSpot subscription tier. If no campaign exists for the piece, the agent creates one using the content title as the campaign name. If a hubspot_campaign_id is already on the Notion row, the agent updates that record instead of creating a duplicate.
- The approved_copy field in Notion must be a rich text field, not a linked Google Doc. Writers must paste or type final approved copy into Notion before the approval status is set. Document this clearly in the team SOP.
- The Buffer post_id returned on creation must be written back to the Notion record (buffer_post_id field) to enable future performance lookups by the Performance Sync Agent.
- Fallback: if the Buffer API call fails, the agent must log the error to the error table in Supabase, alert the manager via Slack, and set the Notion status back to Approved (not Scheduled) so the piece is not silently dropped.
- Confirm whether HubSpot contacts should be associated with campaign records at publish time, or whether the campaign-level log is sufficient for this team's reporting needs.
Runs on a recurring weekly schedule, every Monday at 07:00 workspace local time, to pull engagement metrics from Buffer for all content rows in the Notion database whose status is Scheduled or Published and whose publish_datetime falls within the preceding 30 days. For each matching record it fetches the Buffer post analytics using the stored buffer_post_id, extracts the relevant metrics, and writes the values back into the corresponding Notion content record fields. The agent does not make any publishing decisions and does not send Slack notifications unless a Buffer API error is encountered.
// Input
notion.database_query.filter: status in ['Scheduled', 'Published']
notion.database_query.filter: publish_datetime >= now() - 30 days
notion.row.buffer_post_id: string // Used to call Buffer analytics endpoint
// Output (per matching Notion row)
notion.row.impressions: number
notion.row.clicks: number
notion.row.likes: number
notion.row.shares: number
notion.row.comments: number
notion.row.reach: number // Where available from Buffer; null if not supported
notion.row.last_synced_at: datetime // Timestamp of the most recent successful sync
// Error path
supabase.error_log.insert: { agent: 'PerformanceSyncAgent', buffer_post_id, error_message, timestamp }
slack.dm.to: manager_slack_id // Alert only on sync failure- Buffer's analytics API does not return granular engagement data for LinkedIn native posts or TikTok. At launch, reach and impressions for those channels should be flagged as 'not available' in the Notion record rather than left blank, to avoid confusion. A manual pull may still be needed for those channels post-launch.
- The buffer_post_id field on each Notion record is the join key. If it is missing (i.e. a post was scheduled outside the automation), the agent must skip that row and log a warning, not error out.
- Notion API rate limit is 3 requests per second. For databases with more than 40 active content rows, implement a 400ms delay between sequential write operations to avoid 429 errors.
- The 30-day lookback window is a default. Confirm with the process owner whether a longer window (e.g. 60 days for slower channels) is needed before finalising the filter.
- Mark this agent as Simple complexity; it has no branching logic and a single data direction (Buffer read, Notion write).
03End-to-end data flow
// ============================================================
// TRIGGER: New Notion content row created OR deadline reached
// ============================================================
notion.event_type: 'page_created' | 'property_changed'
notion.database_id: CONTENT_CALENDAR_DB_ID // Set in credential store
notion.row.id: page_id // Unique Notion page ID, carried throughout
notion.row.title: string // e.g. '5 Tips for Q2 LinkedIn Content'
notion.row.topic: string
notion.row.format: string // 'Blog post' | 'LinkedIn carousel' | etc.
notion.row.channel: string // 'LinkedIn' | 'Instagram' | 'Blog' | etc.
notion.row.assigned_writer: notion_person_id // Resolved to slack_user_id via lookup
notion.row.assigned_reviewer: notion_person_id // Resolved to slack_user_id via lookup
notion.row.deadline: ISO8601_date
notion.row.draft_link: null // Null at creation
notion.row.status: 'Briefed' // Set by agent after brief dispatch
notion.row.brief_sent: false // Boolean flag; prevents duplicate briefs
// ============================================================
// AGENT 1: Calendar Orchestrator Agent
// Handles: brief dispatch and overdue reminder
// ============================================================
// -- Path A: New row -> Brief dispatch
lookup(notion_person_id) -> slack_user_id // Static map in credential store
compose(brief_message) {
title: notion.row.title
topic: notion.row.topic
format: notion.row.format
channel: notion.row.channel
deadline: notion.row.deadline
notion_link: 'https://notion.so/' + page_id
}
slack.chat.postMessage(
channel: writer_slack_user_id,
text: brief_message
)
notion.pages.update(page_id, { brief_sent: true, status: 'Briefed' })
// -- Path B: Deadline reached, draft_link still null -> Overdue reminder
schedule_check: deadline_date at 09:00 local_time
if (notion.row.draft_link == null) {
slack.chat.postMessage(channel: writer_slack_user_id, text: overdue_reminder_1)
slack.chat.postMessage(channel: manager_slack_user_id, text: overdue_copy_1)
// 24 hours later, if still null:
slack.chat.postMessage(channel: writer_slack_user_id, text: overdue_reminder_2)
}
// ============================================================
// AGENT 2: Review and Publish Agent
// Handles: review routing, Buffer scheduling, HubSpot logging
// ============================================================
// -- Trigger condition A: draft_link populated on Notion row
notion.row.draft_link: url // Writer logs Google Docs URL in Notion
notion.row.status: 'Draft Submitted' // Set by agent on detection
slack.chat.postMessage(
channel: reviewer_slack_user_id,
text: review_request {
title: notion.row.title,
draft_link: notion.row.draft_link,
notion_link: 'https://notion.so/' + page_id
}
)
// -- Trigger condition B: status changes to 'Approved'
notion.row.status: 'Approved'
notion.row.approved_copy: string // Final post text in Notion rich text field
notion.row.publish_datetime: ISO8601_datetime
notion.row.channel -> buffer_profile_id // Resolved from static channel->profile map
buffer.updates.create(
profile_ids: [buffer_profile_id],
text: notion.row.approved_copy,
scheduled_at: notion.row.publish_datetime // UTC ISO8601
) -> buffer_response.id // Stored as notion.row.buffer_post_id
notion.pages.update(page_id, {
status: 'Scheduled',
buffer_post_id: buffer_response.id
})
hubspot.campaigns.create_or_update({
campaign_id: notion.row.hubspot_campaign_id | null, // Create if null
name: notion.row.title,
publish_date: notion.row.publish_datetime,
channel: notion.row.channel,
notion_url: 'https://notion.so/' + page_id
}) -> hubspot_campaign_id
notion.pages.update(page_id, { hubspot_campaign_id: hubspot_campaign_id })
// -- Trigger condition C: status changes to 'Needs Revision'
notion.row.status: 'Needs Revision'
notion.row.reviewer_comment: string // Free text field on Notion record
slack.chat.postMessage(
channel: writer_slack_user_id,
text: revision_notice {
title: notion.row.title,
comment: notion.row.reviewer_comment,
notion_link: 'https://notion.so/' + page_id
}
)
notion.pages.update(page_id, { status: 'In Revision' })
// ============================================================
// AGENT 3: Performance Sync Agent
// Handles: weekly Buffer analytics pull and Notion write-back
// ============================================================
// -- Schedule: Every Monday 07:00 local time
notion.databases.query(CONTENT_CALENDAR_DB_ID, {
filter: {
and: [
{ property: 'status', select: { is_one_of: ['Scheduled', 'Published'] } },
{ property: 'publish_datetime', date: { past_month: {} } }
]
}
}) -> [page_id, buffer_post_id, ...]
for each row in query_results {
buffer.analytics.post(buffer_post_id) -> {
impressions: number,
clicks: number,
likes: number,
shares: number,
comments: number,
reach: number | null
}
notion.pages.update(page_id, {
impressions: buffer_analytics.impressions,
clicks: buffer_analytics.clicks,
likes: buffer_analytics.likes,
shares: buffer_analytics.shares,
comments: buffer_analytics.comments,
reach: buffer_analytics.reach,
last_synced_at: now()
})
delay(400ms) // Respect Notion 3 req/sec rate limit
}
// ============================================================
// ERROR PATH (all agents)
// ============================================================
on_error {
supabase.from('automation_errors').insert({
agent: agent_name,
notion_page: page_id | null,
error_msg: error.message,
timestamp: now()
})
slack.chat.postMessage(channel: manager_slack_user_id, text: error_alert)
}04Recommended build stack
More documents for this process
Every document generated for Content Calendar Management.