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
Knowledge Base Maintenance
[YourCompany.com] · Customer Support Department · Prepared by FullSpec · [Today's Date]
This document is the primary technical reference for the FullSpec team building the Knowledge Base Maintenance automation. It covers the current-state process map, all three agent specifications with IO contracts, the end-to-end data flow, and the recommended build stack. The owner's team supplies API credentials and approval on draft quality; FullSpec handles all build, wiring, and testing. Nothing in this document requires action from the business owner unless a credential or access step is explicitly flagged.
01Process snapshot
02Agent specifications
This agent runs on a daily scheduled poll against the Zendesk API. It retrieves tickets created or updated in the past seven days, applies a topic-clustering model to group tickets sharing common keywords or phrases, and scores each cluster against the existing Intercom knowledge base article inventory. A staleness and accuracy score is calculated per article based on cluster size, article age, and last-edited date. When a cluster meets or exceeds the configured threshold (default: five tickets on the same topic within seven days), the agent outputs a ranked list of articles requiring review. This agent replaces the most time-consuming bottleneck in the current process: the manual weekly ticket export and cross-reference task.
// Input
zendesk.tickets[] -> { id, subject, description, tags[], created_at, updated_at }
intercom.articles[] -> { article_id, title, body, updated_at, url }
// Processing
cluster_model(tickets) -> topic_clusters[] { topic_label, ticket_ids[], ticket_count }
score_articles(topic_clusters, articles) -> flagged_articles[] { article_id, title, url, staleness_score, cluster_size, trigger_ticket_ids[] }
// Output
flagged_articles[] -> {
article_id: string,
article_title: string,
article_url: string,
staleness_score: float (0.0 to 1.0),
cluster_size: integer,
cluster_topic: string,
trigger_ticket_ids: string[],
ticket_summaries: string[]
}- Zendesk plan must be Suite Team or above to expose the Search API and Ticket List endpoint with date filtering. Confirm plan tier before build.
- Intercom requires a workspace-level API key with Articles:Read scope. The agent must not have write access at this stage.
- Topic clustering: use a keyword-frequency approach as the baseline. If the team's ticket text is too terse for keyword clustering, fall back to embedding-based similarity grouping. Confirm average ticket description length with the support manager before committing to approach.
- Threshold (five tickets, seven-day window) is a configurable parameter stored in the workflow environment variables, not hardcoded. Label it CLUSTER_THRESHOLD and CLUSTER_WINDOW_DAYS.
- Dedupe: if the same article was flagged and processed in the past 14 days, suppress the output for that article unless staleness_score exceeds 0.85. Store last-flagged timestamps in the orchestration platform's key-value store or a lightweight Notion lookup table.
- On zero clusters meeting the threshold, the workflow terminates silently. No downstream agents are triggered. Log the run result to the audit table.
- Confirm with the owner whether Zendesk is the sole ticket source or whether Intercom Conversations are also in scope. The agent spec currently covers Zendesk only. Intercom Conversations require a separate API call and merge logic.
This agent receives the flagged article list from the Ticket Trend Analyst and processes each article in sequence. For each article, it retrieves the full current body from Intercom, reads the associated ticket summaries, and instructs the language model to produce a structured revision that preserves the existing article format while correcting or extending sections that are contradicted or not covered by the ticket evidence. The draft is written directly into a new Google Doc in the configured shared drive folder. Tracked changes are simulated by wrapping new or amended passages in a consistent highlight or comment block so reviewers can see exactly what changed. Once the Doc is created, the agent creates a corresponding row in the Notion review tracker and the workflow signals the next step.
// Input
flagged_article -> {
article_id: string,
article_title: string,
article_url: string,
staleness_score: float,
cluster_size: integer,
cluster_topic: string,
trigger_ticket_ids: string[],
ticket_summaries: string[]
}
intercom.article.body -> string (HTML or markdown, depending on Intercom plan)
// Processing
llm_prompt(article_body, ticket_summaries) -> revised_draft_markdown
google_docs.create(title, revised_draft_markdown) -> { doc_id, doc_url }
notion.pages.create(tracker_db_id, row_payload) -> { notion_page_id }
// Output
draft_record -> {
article_id: string,
article_title: string,
doc_id: string,
doc_url: string,
notion_page_id: string,
assigned_reviewer: string (email, from reviewer_map config),
created_at: ISO8601 timestamp
}- Google Docs API requires OAuth2 with scopes: https://www.googleapis.com/auth/drive.file and https://www.googleapis.com/auth/documents. Use a service account tied to the team's shared drive folder. Confirm the shared drive folder ID before build and store it as GDOCS_FOLDER_ID in the credential store.
- Intercom article body may be returned as HTML (older workspaces) or as a JSON block structure (newer workspaces). The agent must detect the format and normalise to plain markdown before passing to the language model. Test this against a live article before building the prompt.
- Notion database ID for the review tracker must be confirmed and stored as NOTION_KB_TRACKER_DB_ID. Required properties on the tracker row: Article Title (title), Google Doc URL (url), Ticket Count (number), Staleness Score (number), Assigned Reviewer (person or email), Status (select: Pending Review), Created Date (date), Article ID (rich text).
- Reviewer assignment: build a static reviewer_map in environment config mapping cluster topics or article categories to reviewer email addresses. Default to the support manager email (stored as DEFAULT_REVIEWER_EMAIL) if no mapping matches.
- Draft quality is directly dependent on ticket description richness. Run five sample articles through the agent before go-live and have the support manager score output quality. If average scores are below acceptable, adjust the prompt to be more conservative and flag for human rewrite rather than attempting a full AI draft.
- The Notion Slack alert (replacing step 4 and 6) is triggered by the completion of this agent, not by a separate agent. Post a formatted Slack message to the channel stored as SLACK_REVIEW_CHANNEL_ID with: article title, doc link, notion link, cluster size, and assigned reviewer mention.
This agent listens for an approval signal on each Google Doc draft. Approval is detected by polling the Google Docs comments API for a comment containing a configured approval keyword (default: 'APPROVED') left by an authorised reviewer, or by a status change on the linked Notion tracker row to 'Approved'. When approval is confirmed, the agent retrieves the final Doc body, transforms it to the format required by the Intercom Articles API, and PATCHes the existing article with the updated content. It does not create new articles and does not delete articles. After a successful publish, it updates the Notion tracker row to 'Published', records the published_at timestamp, and posts a Slack confirmation to the support channel. All publish events are written to an audit log.
// Input
draft_record -> {
article_id: string,
doc_id: string,
notion_page_id: string,
assigned_reviewer: string
}
google_docs.comments(doc_id) -> comments[] { author, content, created_at }
notion.page.status(notion_page_id) -> string
// On approval
google_docs.body(doc_id) -> final_draft_markdown
transform(final_draft_markdown) -> intercom_article_body (HTML)
intercom.articles.patch(article_id, { body: intercom_article_body, updated_at }) -> { status: 200 }
// Output
audit_record -> {
article_id: string,
article_title: string,
published_at: ISO8601 timestamp,
approved_by: string (reviewer email),
doc_url: string,
notion_page_id: string,
publish_status: string (success | failed)
}
notion.page.update(notion_page_id, { status: 'Published', published_at }) -> acknowledged
slack.message.post(SLACK_REVIEW_CHANNEL_ID, confirmation_payload) -> acknowledged- Intercom Articles PATCH requires the articles:write scope on the API key. Confirm this is scoped to update existing articles only. The agent must never call the POST /articles endpoint (create) or DELETE /articles endpoint. Validate this restriction by testing with a non-production article in the Intercom sandbox workspace before live build.
- Approval keyword is configurable via APPROVAL_KEYWORD env variable (default: 'APPROVED'). It must appear as the full content of a comment, case-insensitive. Partial matches must be rejected to avoid accidental triggers.
- Authorised reviewers: build an AUTHORISED_REVIEWERS list (email array) in config. Comments from addresses not in this list must not trigger publish, even if they contain the approval keyword.
- If the Notion status-based approval path is used in parallel, implement a mutex: whichever signal arrives first sets a processing_lock flag on the draft_record to prevent double publish.
- Markdown to Intercom HTML transform: Intercom accepts a subset of HTML. Test the transform against articles containing tables, ordered lists, code blocks, and images before go-live. Intercom does not support all markdown extensions.
- On publish failure (non-200 response from Intercom), the agent must: log the failure to the audit table, update the Notion row status to 'Publish Failed', and post an error alert to SLACK_ERROR_CHANNEL_ID. Do not retry automatically more than twice.
- Audit log table (Supabase or equivalent) must record: article_id, article_title, approved_by, published_at, publish_status, doc_url, notion_page_id. This table is append-only.
03End-to-end data flow
// ═══════════════════════════════════════════════════════════════════
// TRIGGER: Daily scheduled poll
// ═══════════════════════════════════════════════════════════════════
cron(CRON_SCHEDULE) -> workflow.start()
// ── AGENT 1: Ticket Trend Analyst ─────────────────���─────────────────
// Step 1: Fetch tickets from Zendesk (rolling 7-day window)
zendesk.GET /api/v2/search.json
params: { query: 'created>{{date_7d_ago}} type:ticket', per_page: 100 }
-> tickets[] { id, subject, description, tags[], created_at, updated_at }
// Step 2: Fetch article inventory from Intercom
intercom.GET /articles
params: { per_page: 50 }
-> articles[] { id, title, body, updated_at, url }
// Step 3: Cluster tickets by topic
cluster_model(tickets[].description, tickets[].subject)
-> topic_clusters[] {
topic_label: string,
ticket_ids[]: string[],
ticket_count: integer
}
// Step 4: Filter clusters below threshold
topic_clusters.filter(c => c.ticket_count >= CLUSTER_THRESHOLD)
-> qualifying_clusters[]
// Step 5: Score articles against qualifying clusters
score_articles(qualifying_clusters, articles[])
-> flagged_articles[] {
article_id: string,
article_title: string,
article_url: string,
staleness_score: float,
cluster_size: integer,
cluster_topic: string,
trigger_ticket_ids: string[],
ticket_summaries: string[]
}
// Step 6: Dedupe suppression check
flagged_articles.filter(a => last_flagged(a.article_id) > 14d
|| a.staleness_score > 0.85)
-> articles_to_process[]
// If articles_to_process is empty: log run, terminate workflow.
// ── HANDOFF 1 → AGENT 2: Article Draft Writer ────────────────────
// articles_to_process[] passed as input; one execution per article
// Step 7: Retrieve full article body from Intercom
intercom.GET /articles/{{article_id}}
-> { id, title, body: string (HTML or markdown) }
// Step 8: Normalise article body to markdown
normalise(body) -> article_markdown: string
// Step 9: Generate draft via language model
llm.complete(
system: KB_DRAFT_SYSTEM_PROMPT,
user: { article_markdown, ticket_summaries[] }
)
-> revised_draft_markdown: string
// Step 10: Create Google Doc in shared drive folder
google_docs.POST /documents
body: { title: 'DRAFT: {{article_title}} - {{date}}' }
-> { documentId: doc_id, document_url: doc_url }
google_docs.POST /documents/{{doc_id}}/batchUpdate
body: { insertText: revised_draft_markdown }
-> acknowledged
// Step 11: Create Notion tracker row
notion.POST /pages
body: {
parent: { database_id: NOTION_KB_TRACKER_DB_ID },
properties: {
'Article Title': { title: article_title },
'Google Doc URL': { url: doc_url },
'Ticket Count': { number: cluster_size },
'Staleness Score': { number: staleness_score },
'Assigned Reviewer':{ email: reviewer_email },
'Status': { select: 'Pending Review' },
'Created Date': { date: now() },
'Article ID': { rich_text: article_id }
}
}
-> { id: notion_page_id }
// Step 12: Post Slack review alert
slack.POST /chat.postMessage
body: {
channel: SLACK_REVIEW_CHANNEL_ID,
text: 'New KB draft ready for review: {{article_title}}',
blocks: [ article_title, doc_url, notion_url, cluster_size, reviewer_mention ]
}
-> acknowledged
// draft_record emitted: { article_id, doc_id, doc_url, notion_page_id, assigned_reviewer }
// ── HANDOFF 2 → AGENT 3: Publish and Audit Agent ─────────────────
// Polling loop begins on draft_record; interval = POLL_INTERVAL_MINUTES
// Step 13: Poll Google Docs comments for approval keyword
google_docs.GET /documents/{{doc_id}}/comments
-> comments[] { author_email, content, created_at }
comments.find(c =>
AUTHORISED_REVIEWERS.includes(c.author_email) &&
c.content.toLowerCase() === APPROVAL_KEYWORD.toLowerCase()
)
-> approval_comment | null
// Parallel: poll Notion status as secondary approval signal
notion.GET /pages/{{notion_page_id}}
-> { properties.Status.select.name: status_value }
// If neither signal found: wait POLL_INTERVAL_MINUTES, repeat.
// If approval found: set processing_lock = true, proceed.
// Step 14: Retrieve final approved Doc body
google_docs.GET /documents/{{doc_id}}
-> final_draft_markdown: string
// Step 15: Transform to Intercom HTML
transform_md_to_html(final_draft_markdown)
-> intercom_article_body: string (HTML subset)
// Step 16: Patch Intercom article
intercom.PUT /articles/{{article_id}}
body: { body: intercom_article_body, updated_at: now() }
-> { status: 200 } | { status: 4xx/5xx }
// Step 17: Update Notion tracker
notion.PATCH /pages/{{notion_page_id}}
body: {
properties: {
'Status': { select: 'Published' },
'Published At': { date: now() }
}
}
-> acknowledged
// Step 18: Post Slack confirmation
slack.POST /chat.postMessage
body: {
channel: SLACK_REVIEW_CHANNEL_ID,
text: 'Article published: {{article_title}}',
blocks: [ article_title, article_url, published_at, approved_by ]
}
-> acknowledged
// Step 19: Write audit log record
audit_table.insert({
article_id, article_title, approved_by,
published_at, publish_status: 'success',
doc_url, notion_page_id
})
// ── ON PUBLISH FAILURE ────────────────────────────────────────────
// intercom.PUT returns non-200:
audit_table.insert({ ..., publish_status: 'failed', error_detail })
notion.PATCH(notion_page_id, { status: 'Publish Failed' })
slack.POST(SLACK_ERROR_CHANNEL_ID, { text: 'Publish failed: {{article_title}}', error_detail })
// Retry up to 2 times with 5-minute back-off. Do not retry beyond attempt 2.
// ═══════════════════════════════════════════════════════════════════
// END OF FLOW
// ═══════════════════════════════════════════════════════════════════04Recommended build stack
More documents for this process
Every document generated for Knowledge Base Maintenance.