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
Win/Loss Analysis Automation
[YourCompany.com] · Sales Department · Prepared by FullSpec · [Today's Date]
This document gives the FullSpec build team everything needed to implement the Win/Loss Analysis automation end to end. It covers the current manual process and its bottlenecks, full specifications for each of the three agents, the complete data flow from trigger to output, and the recommended build stack. The owner side is not expected to action anything in this document. All build decisions, credential configuration, and integration wiring are handled by the FullSpec team.
01Process snapshot
02Agent specifications
Monitors HubSpot for deal stage changes to Closed Won or Closed Lost, extracts the full deal record including contact, company, rep, deal value, close date, and stage history, then dispatches a personalised Typeform survey to the buyer via Gmail and sends a structured Slack form to the assigned rep. Handles the entire data collection trigger loop without any human input. This agent must fire within 5 minutes of the CRM property change and must be idempotent: re-triggering on the same deal ID must not dispatch duplicate emails or Slack messages.
// Input
hubspot.webhook.payload {
deal_id: string,
dealstage: 'closedwon' | 'closedlost',
dealname: string,
amount: number,
closedate: ISO8601,
hubspot_owner_id: string,
associations.contacts[0].id: string
}
// Enriched deal payload (fetched via HubSpot API after trigger)
deal_record {
deal_id, dealname, amount, closedate, dealstage,
rep_name, rep_email, rep_slack_id,
contact_name, contact_email,
company_name, stage_history[]
}
// Output
gmail.send -> buyer survey email {
to: contact_email,
subject: 'A quick question about your recent decision, {{contact_name}}',
body: typeform_prefilled_link (hidden fields: deal_id, dealstage, company_name)
}
slack.send_message -> rep notification {
channel: rep_slack_id (direct message),
blocks: structured form {
primary_reason: select (taxonomy options),
competitor_named: text input,
one_thing_differently: text input,
response_deadline: now + 24h
}
}
internal.state_store -> { deal_id, dispatched_at, survey_sent: true, form_sent: true }- HubSpot subscription must be on a tier that supports webhook subscriptions (Starter or above). Confirm pipeline stage internal names ('closedwon', 'closedlost') against the live HubSpot portal before wiring the trigger, as custom pipelines use different internal IDs.
- Typeform survey link must use hidden fields (deal_id, dealstage, company_name) pre-filled via query parameters. Confirm Typeform plan supports hidden fields (Basic plan and above).
- Slack rep notification uses Block Kit structured form (block_actions). The rep's Slack member ID must be resolved from rep_email using the Slack users.lookupByEmail API call. A fallback to a configured sales-team channel must fire if the lookup fails.
- Dedupe check: before dispatching, query the internal state store for the deal_id. If survey_sent is already true, abort and log. This prevents double sends on webhook retries.
- Gmail sending identity must be the shared sales team address (e.g. sales@[YourCompany.com]). OAuth consent for the Gmail scope must be granted by a Google Workspace admin before build starts.
- A 7-day timer must be set at dispatch time. If no Typeform response and no Slack form submission are recorded against the deal_id after 7 days, and deal amount is greater than $10,000, flag the deal in the Google Sheet as 'Needs manual follow-up' and notify the sales manager via Slack.
Reads the rep's Slack form submission and any buyer Typeform response associated with a given deal_id, passes the combined text through an AI classification step that maps language to a locked win/loss reason taxonomy, then writes the standardised outcome tag, reason code, and a short generated summary back to the HubSpot deal record and appends a structured row to the Google Sheets win/loss tracker. The taxonomy is fixed at build time and must not be modified at runtime without a deliberate workflow update. Classification runs on each response as it arrives: the agent does not wait for both responses before writing, but a 'classification_complete' flag is only set when both have been processed or the 7-day window has elapsed.
// Input (Typeform)
typeform.response {
form_id: string,
hidden.deal_id: string,
hidden.dealstage: 'closedwon' | 'closedlost',
answers[]: { field_id, type, text | choice }
}
// Input (Slack rep form)
slack.block_actions {
action_id: 'rep_outcome_form_submit',
payload.deal_id: string,
payload.primary_reason: string (taxonomy option),
payload.competitor_named: string | null,
payload.one_thing_differently: string | null
}
// Classification step (AI prompt inputs)
classification_input {
dealstage, rep_primary_reason, rep_competitor,
buyer_response_text (concatenated answer strings),
taxonomy: string[] (locked 12-category list)
}
// Output
hubspot.deal.update (deal_id) {
win_loss_reason_code: taxonomy_code, // e.g. 'PRICE_COMPETITIVE'
win_loss_summary: string (max 200 chars),
outcome_classification_status: 'complete' | 'partial'
}
google_sheets.append_row {
deal_id, dealname, amount, closedate, dealstage,
rep_name, company_name,
primary_reason_code, primary_reason_label,
competitor_named, buyer_response_summary,
rep_response_summary, classification_status,
row_created_at: ISO8601
}- The 12-category taxonomy must be locked and provided by the sales manager before build starts. It is stored as a static configuration variable in the workflow, not fetched at runtime. Adding categories post-launch requires a workflow edit and redeployment.
- AI classification uses a structured prompt with the taxonomy list injected as a constraint. The model must return a JSON object with 'reason_code' and 'summary' fields only. Output must be validated against the taxonomy list before writing to HubSpot. If the returned code is not in the list, log the raw output and write 'UNCLASSIFIED' to HubSpot, then post an alert to the sales manager Slack channel.
- HubSpot custom properties 'win_loss_reason_code', 'win_loss_summary', and 'outcome_classification_status' must be created in the portal before build. Confirm property internal names with the HubSpot admin.
- Google Sheets tracker must have a defined column schema. The sheet ID and tab name must be confirmed before build. Append only: never update existing rows. Duplicate deal_id rows are acceptable when both rep and buyer responses arrive separately.
- Typeform webhook must be configured with a signing secret. The agent must verify the X-Typeform-Signature header on every inbound request before processing.
- Validate on 20 historical closed deals during QA. Target: fewer than 2 UNCLASSIFIED results in 20 runs. If accuracy is below this threshold, refine the classification prompt before go-live.
Runs on the first business day of each calendar month via a scheduled trigger. Reads all rows from the prior calendar month in the Google Sheets win/loss tracker, aggregates win drivers and loss reasons by frequency, identifies any competitor patterns, and writes a structured findings page to the team Notion workspace. Immediately after the Notion page is created, posts a formatted digest to the designated Slack sales channel, tagging the sales manager. The agent must handle months where fewer than 5 rows exist gracefully: it writes the report with a low-volume notice rather than failing.
// Input
google_sheets.read_rows (filter: closedate within prior calendar month) {
rows[]: {
deal_id, dealname, amount, closedate, dealstage,
rep_name, company_name, primary_reason_code,
primary_reason_label, competitor_named,
buyer_response_summary, classification_status
}
}
// Aggregated analysis (computed in workflow)
analysis {
total_deals: number,
won_count: number, lost_count: number,
win_rate_pct: number,
top_win_reasons: [{ code, label, count }] (top 3),
top_loss_reasons: [{ code, label, count }] (top 3),
competitor_mentions: [{ name, count }] (top 3),
avg_deal_value_won: number,
avg_deal_value_lost: number,
unclassified_count: number
}
// Output
notion.page.create {
parent_page_id: configured_notion_page_id,
title: 'Win/Loss Report — {{MMMM YYYY}}',
content: structured blocks {
summary_stats, top_win_reasons_table,
top_loss_reasons_table, competitor_section,
unclassified_note (if unclassified_count > 0)
}
}
slack.post_message {
channel: '#sales' (configured channel ID),
text: digest with win rate, top 3 wins, top 3 losses,
mention: sales_manager_slack_id,
notion_page_url: link to created page
}- Notion integration requires a Notion internal integration token with insert and read permissions on the target parent page. The parent page ID must be confirmed before build. The integration must be manually added to the parent page in the Notion UI by the workspace owner.
- Scheduled trigger must calculate 'first business day of month' dynamically. Use a lookup against a US federal holiday list (or configurable region). If the computed date is a holiday, advance to the next weekday.
- Google Sheets read must filter by 'closedate' column for the prior calendar month. If the sheet contains no rows for the month, write the Notion page with a low-volume notice ('Fewer than 5 deals closed this month. Patterns not statistically significant.') and still post the Slack digest.
- Slack channel ID (not channel name) must be stored as a credential variable. The sales manager Slack member ID must be stored separately for the @mention.
- The agent does not modify any rows in Google Sheets. It is read-only at runtime.
03End-to-end data flow
// ============================================================
// WIN/LOSS ANALYSIS AUTOMATION — END-TO-END DATA FLOW
// ============================================================
// TRIGGER
// HubSpot webhook fires when deal property 'dealstage' changes
// to 'closedwon' or 'closedlost'
TRIGGER hubspot.webhook {
deal_id, // e.g. '12345678'
dealstage, // 'closedwon' | 'closedlost'
dealname,
amount, // numeric, USD
closedate, // ISO8601
hubspot_owner_id, // used to resolve rep identity
contact_id // associations.contacts[0].id
}
// ---- AGENT 1: DEAL CAPTURE AGENT ----
// Step 1: Dedupe check
READ state_store WHERE deal_id = webhook.deal_id
IF survey_sent == true -> ABORT (log: 'duplicate webhook, skipped')
// Step 2: Enrich deal record via HubSpot CRM API
GET hubspot.deals/{deal_id}?properties=dealname,amount,closedate,
dealstage,hubspot_owner_id
GET hubspot.owners/{hubspot_owner_id}
-> rep_name, rep_email, rep_slack_id (from owner profile)
GET hubspot.contacts/{contact_id}?properties=firstname,lastname,email
-> contact_name, contact_email
GET hubspot.companies (associated) -> company_name
// Step 3: Build enriched deal payload
deal_payload {
deal_id, dealname, amount, closedate, dealstage,
rep_name, rep_email, rep_slack_id,
contact_name, contact_email, company_name
}
// Step 4: Send buyer survey via Gmail
typeform_link = BASE_URL + '?deal_id={{deal_id}}'
+ '&dealstage={{dealstage}}'
+ '&company={{company_name}}'
SEND gmail.message {
to: contact_email,
from: sales@[YourCompany.com],
subject: 'A quick question about your recent decision, {{contact_name}}',
body: template with typeform_link embedded
}
// Step 5: Send rep Slack form
slack_member_id = CALL slack.users.lookupByEmail(rep_email)
IF lookup fails -> fallback channel = '#sales-team'
SEND slack.chat.postMessage {
channel: slack_member_id | fallback_channel,
blocks: Block Kit form {
primary_reason (static_select, taxonomy options),
competitor_named (plain_text_input),
one_thing_differently (plain_text_input)
},
metadata: { deal_id, dealstage, response_deadline: now+24h }
}
// Step 6: Write dispatch record to state store
WRITE state_store {
deal_id, dispatched_at: now,
survey_sent: true, form_sent: true,
amount, rep_slack_id
}
// Set 7-day timer: if no responses, check amount threshold
SCHEDULE follow_up_check AT dispatched_at + 7 days
// ---- HANDOFF: Deal Capture Agent -> Outcome Classification Agent ----
// Triggered independently by inbound webhooks (Typeform or Slack callback)
// ---- AGENT 2: OUTCOME CLASSIFICATION AGENT ----
// Trigger path A: Typeform submission
RECEIVE typeform.webhook {
VERIFY X-Typeform-Signature (HMAC-SHA256, signing_secret)
hidden.deal_id,
hidden.dealstage,
answers[]: { field_id, type, value } // buyer free-text responses
}
buyer_response_text = CONCAT(answers[].value WHERE type='text')
// Trigger path B: Slack block_actions callback
RECEIVE slack.block_actions {
action_id: 'rep_outcome_form_submit',
payload.deal_id,
payload.primary_reason, // taxonomy label string
payload.competitor_named, // string | null
payload.one_thing_differently // string | null
}
// Step 7: AI classification
classification_prompt {
system: 'You classify sales outcomes. Return JSON only.',
user: 'dealstage: {{dealstage}}\n'
+ 'rep_reason: {{primary_reason}}\n'
+ 'buyer_text: {{buyer_response_text}}\n'
+ 'taxonomy: {{taxonomy_list_12_items}}\n'
+ 'Return: { reason_code: string, summary: string (max 200 chars) }'
}
classification_result { reason_code, summary }
IF reason_code NOT IN taxonomy_list ->
reason_code = 'UNCLASSIFIED'
ALERT slack sales_manager_slack_id
// Step 8: Write back to HubSpot
PATCH hubspot.deals/{deal_id} {
win_loss_reason_code: reason_code,
win_loss_summary: summary,
outcome_classification_status: 'complete' | 'partial'
}
// Step 9: Append row to Google Sheets tracker
APPEND google_sheets.row (sheet_id, tab: 'Win_Loss_Tracker') {
deal_id, dealname, amount, closedate, dealstage,
rep_name, company_name,
primary_reason_code, primary_reason_label,
competitor_named, buyer_response_summary,
rep_response_summary,
classification_status,
row_created_at: ISO8601
}
// ---- HANDOFF: Outcome Classification Agent -> Insight Report Agent ----
// Triggered by monthly schedule, reads accumulated Google Sheets data
// ---- AGENT 3: INSIGHT REPORT AGENT ----
// Trigger: scheduled, first business day of month, 08:00 local
SCHEDULE cron {
evaluate: is today the first Mon-Fri of month AND NOT in holiday_list?
IF yes -> execute; IF no -> advance 1 day and recheck
}
// Step 10: Read prior month rows from Google Sheets
READ google_sheets.rows (sheet_id, tab: 'Win_Loss_Tracker') {
filter: closedate >= MONTH_START(now-1) AND closedate < MONTH_START(now)
}
IF row_count < 5 -> low_volume_flag = true
// Step 11: Aggregate analysis
analysis {
total_deals, won_count, lost_count, win_rate_pct,
top_win_reasons[3]: GROUP BY primary_reason_code WHERE dealstage='closedwon'
top_loss_reasons[3]: GROUP BY primary_reason_code WHERE dealstage='closedlost'
competitor_mentions[3]: GROUP BY competitor_named ORDER BY count DESC
avg_deal_value_won, avg_deal_value_lost,
unclassified_count: COUNT WHERE primary_reason_code='UNCLASSIFIED'
}
// Step 12: Write Notion report page
POST notion.pages {
parent: { page_id: NOTION_PARENT_PAGE_ID },
properties.title: 'Win/Loss Report — {{MMMM YYYY}}',
children: [
heading_1: 'Summary',
table: [total_deals, won_count, lost_count, win_rate_pct],
heading_2: 'Top Win Drivers',
bulleted_list: top_win_reasons[],
heading_2: 'Top Loss Reasons',
bulleted_list: top_loss_reasons[],
heading_2: 'Competitor Mentions',
bulleted_list: competitor_mentions[],
callout (if unclassified_count > 0): unclassified warning,
callout (if low_volume_flag): low-volume notice
]
}
-> notion_page_url: returned in API response
// Step 13: Post Slack digest
POST slack.chat.postMessage {
channel: SLACK_SALES_CHANNEL_ID,
text: 'Win/Loss Report for {{MMMM YYYY}} is ready.',
blocks: [
section: win_rate_pct, total_deals,
section: top 3 win reasons,
section: top 3 loss reasons,
section: top competitor mentions,
button: 'View full report in Notion' -> notion_page_url
],
mention: <@SALES_MANAGER_SLACK_ID>
}
// ============================================================
// END OF FLOW
// ============================================================04Recommended build stack
More documents for this process
Every document generated for Win/Loss Analysis.