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
SEO Content Production Workflow
[YourCompany.com] · Marketing Department · Prepared by FullSpec · [Today's Date]
This document gives the FullSpec build team everything needed to construct, connect, and deploy the three automation agents that replace the most time-intensive manual steps in the SEO content production process. It covers the current-state step map, full agent specifications with IO contracts, an end-to-end data flow trace, and the recommended build stack. You and your team retain editorial review and final publish approval at all times. FullSpec handles all build, integration, and testing work end to end. Questions during build should be directed to support@gofullspec.com.
01Process snapshot
02Agent specifications
Triggered by a Notion status change to 'Approved', this agent pulls the target keyword from the Notion article row, queries the Ahrefs API for keyword difficulty, monthly search volume, SERP competitor URLs, and top-ranking page titles, then populates a standardised Google Docs brief template. Output fields include target keyword, secondary keywords, intent classification, recommended H2 and H3 structure, suggested word count, and up to five competitor reference URLs. The completed brief is linked back to the Notion row and the status is advanced to 'Brief Ready'. Estimated build time: 18 hours. Complexity: Moderate.
// Input notion.row.article_id STRING -- unique Notion page ID notion.row.target_keyword STRING -- primary keyword confirmed at approval notion.row.assigned_writer STRING -- Notion user ID or Slack handle notion.row.target_publish_date DATE -- ISO 8601 // Ahrefs API call (Keywords Explorer v3) GET /v3/keywords-explorer/overview params: keyword=<target_keyword>, country=us returns: difficulty, volume, global_volume, cpc GET /v3/keywords-explorer/serp-overview params: keyword=<target_keyword>, country=us, limit=5 returns: serp[].url, serp[].title, serp[].word_count // Output google_docs.brief.target_keyword STRING google_docs.brief.secondary_keywords ARRAY[STRING] -- derived from SERP overlap google_docs.brief.intent_classification STRING -- informational|transactional|navigational google_docs.brief.recommended_headings ARRAY[STRING] -- H2/H3 suggestions google_docs.brief.suggested_word_count INTEGER google_docs.brief.competitor_urls ARRAY[STRING] -- top 5 SERP URLs google_docs.brief.doc_url STRING -- shareable Google Docs link notion.row.brief_url STRING -- written back to Notion notion.row.status STRING -- patched to 'Brief Ready' slack.message.channel STRING -- assigned_writer DM or writer channel slack.message.body STRING -- brief link, word count, due date, confirm prompt
- Ahrefs API access requires a plan that includes API units. Confirm the team's current Ahrefs subscription tier before build. The Keywords Explorer v3 endpoint costs API units per request; set a monthly cap in the orchestration layer to prevent quota overruns at high article volumes (12 to 20 articles/month requires a minimum of 40 API calls/month accounting for retries).
- Google Docs brief template must be created in the [YourCompany.com] Google Workspace account before build begins. The agent copies the master template and injects fields by placeholder tag (e.g. {{TARGET_KEYWORD}}, {{COMPETITOR_URL_1}}). Confirm placeholder naming convention with the content team prior to template creation.
- Dedupe: if a Notion row is accidentally moved back to 'Approved' after a brief already exists, the agent must check for a non-empty brief_url field and skip re-generation, logging a warning instead of creating a duplicate document.
- Fallback: if the Ahrefs API returns an error or zero results for a keyword, the agent must post a Slack alert to the content manager with the article ID and keyword, set the Notion status back to 'Needs Review', and halt without creating a partial brief.
- Notion polling interval must not exceed 5 minutes. If the workspace supports webhooks via the Notion API (available on Business plan and above), switch to event-driven triggering to reduce latency and API call overhead.
- Must confirm before build: Notion field schema (field names, exact status label strings), Google Docs template ID, Ahrefs API key with confirmed unit quota, Slack workspace token and target channel or DM routing logic.
Triggered when a writer marks the Notion article row as 'Draft Ready', this agent retrieves the current Google Docs draft content, exports it as plain text, and submits it to the Surfer SEO Content Editor API for scoring against the target keyword. It retrieves the overall content score and a section-level recommendation list, identifies any headings or paragraphs scoring below the configured threshold (default: 67), and packages the results into a structured Slack scorecard message delivered to the content manager. The content score and a link to the Surfer SEO report are also written back to the Notion article row. Estimated build time: 16 hours. Complexity: Moderate.
// Input
notion.row.article_id STRING
notion.row.target_keyword STRING
notion.row.google_docs_draft STRING -- Google Docs document ID
notion.row.assigned_editor STRING -- Slack handle for content manager
notion.row.surfer_threshold INTEGER -- default 67, configurable per project
// Google Docs export
GET /v1/documents/{documentId}/export?mimeType=text/plain
returns: plain_text_content STRING
// Surfer SEO API call (Content Editor)
POST /v1/content-editor/analyze
body: { keyword: target_keyword, content: plain_text_content, locale: 'en-US' }
returns: content_score INTEGER, sections[].heading STRING,
sections[].score INTEGER, sections[].recommendations ARRAY[STRING]
// Output
slack.scorecard.content_score INTEGER
slack.scorecard.flagged_sections ARRAY[{ heading STRING, score INTEGER, notes STRING }]
slack.scorecard.draft_link STRING -- Google Docs URL
slack.scorecard.surfer_report_url STRING -- direct Surfer SEO report link
slack.scorecard.below_threshold BOOLEAN -- true if content_score < surfer_threshold
notion.row.seo_score INTEGER -- written back
notion.row.surfer_report_url STRING -- written back
notion.row.status STRING -- patched to 'In Review'- Surfer SEO API access must be confirmed on the team's current plan. Not all Surfer SEO tiers expose the Content Editor API. Confirm API availability and monthly analysis credit limit before build begins.
- Google Docs export: the draft document must be shared with the service account or OAuth client used by the automation platform. Confirm sharing permissions before integration test.
- Score threshold of 67 is the default. This value must be stored as a configurable environment variable in the orchestration layer, not hardcoded, so the content manager can adjust it without a code change.
- Dedupe: if the Notion row is set to 'Draft Ready' more than once (e.g. after revisions), the agent must re-run and overwrite the previous Surfer score in Notion rather than creating a duplicate Slack message chain. Use the article_id as the idempotency key.
- Fallback: if the Surfer SEO API is unavailable or returns an error, send a Slack alert to the content manager stating the audit failed, preserve the 'Draft Ready' status in Notion, and log the error to the central error table. Do not silently skip.
- Must confirm before build: Surfer SEO API key, plan tier and credit limit, score threshold agreed with SEO Lead, Slack bot token and channel ID for editor notifications, Google Docs service account OAuth scope (drive.readonly or drive.file).
Triggered when the content manager marks the Notion article row 'Approved for Publish', this agent reads the final approved draft from Google Docs and the full set of metadata fields from the Notion record, then creates a WordPress post via the WordPress REST API. It maps Notion fields directly to WordPress post parameters: meta title, meta description, slug, categories, tags, featured image URL, and scheduled publish date. After WordPress confirms the post is created or scheduled, the agent writes the canonical URL back to the Notion row and updates the status to 'Published'. This is the only agent that writes to the production CMS and must be tested exclusively against a WordPress staging environment before go-live. Estimated build time: 20 hours. Complexity: Complex.
// Input
notion.row.article_id STRING
notion.row.google_docs_final STRING -- Google Docs document ID (approved draft)
notion.row.meta_title STRING -- max 60 chars
notion.row.meta_description STRING -- max 160 chars
notion.row.slug STRING -- URL-safe, lowercase, hyphenated
notion.row.wp_categories ARRAY[STRING] -- must match existing WP category names
notion.row.wp_tags ARRAY[STRING]
notion.row.featured_image_url STRING -- publicly accessible URL
notion.row.publish_date DATETIME -- ISO 8601, used for scheduling
notion.row.author_id INTEGER -- WordPress user ID
// Google Docs export
GET /v1/documents/{documentId}/export?mimeType=text/html
returns: html_content STRING -- cleaned before POST to WordPress
// WordPress REST API calls
POST /wp/v2/media
body: { source_url: featured_image_url } -- sideload featured image
returns: media_id INTEGER
POST /wp/v2/posts
body: {
title: meta_title,
content: html_content,
slug: slug,
status: 'future' | 'publish',
date: publish_date,
categories: [category_id, ...],
tags: [tag_id, ...],
featured_media: media_id,
author: author_id,
meta: {
_yoast_wpseo_title: meta_title,
_yoast_wpseo_metadesc: meta_description
}
}
returns: post_id INTEGER, link STRING (canonical URL)
// Output
notion.row.canonical_url STRING -- WordPress post permalink
notion.row.wp_post_id INTEGER -- for future reference
notion.row.status STRING -- patched to 'Published'
notion.row.published_date DATE -- actual or scheduled date confirmed by WP- WordPress REST API authentication must use Application Passwords (WordPress 5.6+) or OAuth 2.0 via the WP OAuth Server plugin. Do not use admin credentials directly. Create a dedicated WordPress user with Editor role and generate an application password scoped to that account only.
- Category and tag resolution: Notion stores category and tag names as strings. The agent must resolve these to WordPress term IDs before the POST request using GET /wp/v2/categories?search={name} and GET /wp/v2/tags?search={name}. If a term does not exist, the agent must either create it (confirm this behaviour with the content team) or halt and alert via Slack.
- HTML cleaning: Google Docs HTML export includes inline styles and Google-specific span wrappers. The agent must strip all inline styles and non-semantic spans before posting to WordPress to avoid visual inconsistencies in the CMS theme.
- Yoast SEO meta fields are written via the post meta API. Confirm that the Yoast SEO plugin is installed and the REST API meta fields are exposed. Alternative: if the team uses RankMath, the meta field keys differ (_rank_math_title and _rank_math_description).
- Featured image sideloading requires the WordPress media endpoint to accept the source_url parameter. This is available in WordPress 5.8+. Confirm WordPress version before build.
- This agent must never run against the production WordPress environment during testing. A staging environment with identical plugin configuration must be provisioned before any integration test begins.
- Dedupe: if an article row is accidentally set to 'Approved for Publish' a second time (e.g. after a manual status correction), the agent must check for a non-empty wp_post_id field in Notion and skip creation, posting a Slack warning to the content manager instead.
- Must confirm before build: WordPress application password credentials, staging environment URL and credentials, Yoast or RankMath plugin and meta field key names, category and tag creation policy, Google Docs OAuth scope for final draft (drive.readonly minimum).
03End-to-end data flow
// ─────────────────────────────────────────────────────────────
// TRIGGER: Content Manager sets Notion row status to 'Approved'
// ─────────────────────────────────────────────────────────────
notion.database.poll OR notion.webhook.status_changed
-> filter: row.status == 'Approved'
-> extract: article_id, target_keyword, assigned_writer,
target_publish_date
// ─────────────────────────────────────────────────────────────
// AGENT 1: Brief Generation Agent
// ─────────────────────────────────────────────────────────────
ahrefs.keywords_explorer.overview(
keyword = target_keyword,
country = 'us'
) -> keyword_difficulty, volume, cpc
ahrefs.keywords_explorer.serp_overview(
keyword = target_keyword,
country = 'us',
limit = 5
) -> serp[].url, serp[].title, serp[].word_count
google_docs.template.copy(master_template_id)
-> new_doc_id
google_docs.document.replace_placeholders(new_doc_id, {
TARGET_KEYWORD: target_keyword,
SECONDARY_KEYWORDS: derived_from_serp_overlap,
INTENT: intent_classification,
HEADINGS: recommended_headings[],
WORD_COUNT: suggested_word_count,
COMPETITOR_URL_1: serp[0].url,
COMPETITOR_URL_2: serp[1].url,
COMPETITOR_URL_3: serp[2].url,
COMPETITOR_URL_4: serp[3].url,
COMPETITOR_URL_5: serp[4].url
})
-> brief_doc_url
notion.row.patch(article_id, {
brief_url: brief_doc_url,
status: 'Brief Ready'
})
slack.send_dm(assigned_writer, {
text: 'Brief ready for [target_keyword]. Link: [brief_doc_url]',
word_count: suggested_word_count,
due_date: target_publish_date,
action: 'Please confirm receipt and mark Notion row Draft Ready when done.'
})
// ─────────────────────────────────────────────────────────────
// HANDOFF: Writer completes draft in Google Docs.
// Writer sets Notion row status to 'Draft Ready'.
// ─────────────────────────────────────────────────────────────
notion.database.poll OR notion.webhook.status_changed
-> filter: row.status == 'Draft Ready'
-> extract: article_id, target_keyword, google_docs_draft,
assigned_editor, surfer_threshold
// ─────────────────────────────────────────────────────────────
// AGENT 2: SEO Audit Agent
// ─────────────────────────────────────────────────────────────
google_docs.export(google_docs_draft, mime_type='text/plain')
-> plain_text_content
surfer_seo.content_editor.analyze({
keyword: target_keyword,
content: plain_text_content,
locale: 'en-US'
})
-> content_score, sections[].heading, sections[].score,
sections[].recommendations[], surfer_report_url
// Flag sections below threshold
flagged_sections = sections[].filter(s => s.score < surfer_threshold)
slack.send_message(assigned_editor_channel, {
content_score: content_score,
below_threshold: content_score < surfer_threshold,
flagged_sections: flagged_sections[].{ heading, score, notes },
draft_link: google_docs_draft_url,
surfer_report_url: surfer_report_url
})
notion.row.patch(article_id, {
seo_score: content_score,
surfer_report_url: surfer_report_url,
status: 'In Review'
})
// ─────────────────────────────────────────────────────────────
// HANDOFF: Content Manager reads draft and SEO scorecard.
// Content Manager sets Notion row to 'Approved for Publish'.
// THIS IS A DELIBERATE HUMAN CHECKPOINT. NO AUTOMATION
// MAY ADVANCE THIS STATUS WITHOUT CONTENT MANAGER ACTION.
// ─────────────────────────────────────────────────────────────
notion.database.poll OR notion.webhook.status_changed
-> filter: row.status == 'Approved for Publish'
-> guard: row.wp_post_id == null // dedupe check
-> extract: article_id, google_docs_final, meta_title,
meta_description, slug, wp_categories[], wp_tags[],
featured_image_url, publish_date, author_id
// ─────────────────────────────────────────────────────────────
// AGENT 3: CMS Publishing Agent
// ─────────────────────────────────────────────────────────────
google_docs.export(google_docs_final, mime_type='text/html')
-> raw_html_content
html_cleaner.strip_inline_styles(raw_html_content)
-> clean_html_content
// Resolve category and tag names to WordPress term IDs
wordpress.api.GET('/wp/v2/categories', { search: wp_categories[] })
-> category_ids[]
wordpress.api.GET('/wp/v2/tags', { search: wp_tags[] })
-> tag_ids[]
// Sideload featured image
wordpress.api.POST('/wp/v2/media', { source_url: featured_image_url })
-> media_id
// Create or schedule the WordPress post
wordpress.api.POST('/wp/v2/posts', {
title: meta_title,
content: clean_html_content,
slug: slug,
status: publish_date > now() ? 'future' : 'publish',
date: publish_date,
categories: category_ids[],
tags: tag_ids[],
featured_media: media_id,
author: author_id,
meta: {
_yoast_wpseo_title: meta_title,
_yoast_wpseo_metadesc: meta_description
}
})
-> post_id, canonical_url
notion.row.patch(article_id, {
canonical_url: canonical_url,
wp_post_id: post_id,
status: 'Published',
published_date: publish_date
})
// ─────────────────────────────────────────────────────────────
// END: Notion content calendar row reflects Published status.
// Canonical URL available to entire content team.
// ─────────────────────────────────────────────────────────────04Recommended build stack
More documents for this process
Every document generated for SEO Content Production Workflow.