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
Customer Feedback Loop
[YourCompany.com] · Management Department · Prepared by FullSpec · [Today's Date]
This document gives the FullSpec build team everything needed to implement, test, and hand over the Customer Feedback Loop automation. It covers the current-state process map with bottleneck flags, full specifications for each of the three agents, an end-to-end data flow trace, and the recommended build stack. The FullSpec team owns all build and integration work described here. Your team's responsibility is limited to supplying confirmed API credentials, approving sentiment score thresholds, and reviewing escalated negative feedback alerts once the system is live.
01Process snapshot
02Agent specifications
Monitors two connected input channels for new feedback: a Typeform webhook and a Gmail inbox filter for feedback-tagged messages. On each trigger event the agent extracts structured fields including customer identity, source channel, submission timestamp, and raw response text, then writes a normalised record to the central Google Sheet. This agent is the entry point for the entire workflow and its output quality directly determines the reliability of all downstream scoring and routing.
// Input Typeform: form_id, response_id, submitted_at, answers[] answers[]: field_id, field_type, field_ref, value (text|choice|rating) Gmail: message_id, thread_id, from_email, subject, body_plain, received_at // Output (to Google Sheets row) record_id : uuid v4 generated at intake source : 'typeform' | 'gmail' submitted_at : ISO 8601 timestamp (UTC) customer_email : string (extracted from Typeform answer or Gmail from_email) customer_name : string | null raw_text : string (full response body or email body_plain) intake_status : 'pending_scoring' gmail_message_id : string | null (populated for Gmail source only) typeform_response_id: string | null (populated for Typeform source only)
- Typeform plan must be at least the Basic tier to expose webhook events. Confirm plan level before build starts. The webhook endpoint must be registered under Typeform's Connect > Webhooks panel with the automation platform's inbound URL.
- Gmail connection requires OAuth 2.0 with scopes gmail.readonly and gmail.modify. A dedicated Gmail label named 'feedback-intake' must be created and all relevant filters applied before the agent is activated. The agent polls or listens for this label only, not the full inbox.
- Google Sheets target must have a named sheet tab 'feedback_log' with columns pre-created in this exact order: record_id, source, submitted_at, customer_email, customer_name, raw_text, sentiment_score, theme_tag, sentiment_label, hubspot_contact_id, hubspot_update_status, ack_email_sent, notion_appended, intake_status.
- Dedupe: check record_id and typeform_response_id or gmail_message_id against existing rows before appending. If a duplicate is found, skip and log to error table with reason 'duplicate_intake'.
- Fallback: if raw_text is empty or under 5 characters after trim, set intake_status to 'invalid_response' and halt the record. Do not pass it to the Sentiment and Routing Agent.
- Confirm before build: the exact Typeform form IDs to monitor, the Gmail account address and label naming convention, and whether the Google Sheet is owned by a service account or a user OAuth token.
Reads each pending record from the Google Sheet written by the Intake Agent, sends the raw_text to an AI language model for sentiment scoring and theme classification, updates the Sheet row with results, attempts a HubSpot contact match on customer_email, appends a feedback note to the matched contact record, and fires a Slack alert for any score of 4 or below. This agent contains the primary business logic of the workflow and must handle both matched and unmatched HubSpot contacts gracefully.
// Input (from Google Sheets row)
record_id : uuid
raw_text : string
customer_email : string | null
customer_name : string | null
source : 'typeform' | 'gmail'
submitted_at : ISO 8601 timestamp
// AI scoring payload
prompt_input : raw_text
model_response: {
sentiment_score : integer 1-10
sentiment_label : 'positive' | 'neutral' | 'negative'
theme_tag : 'product_quality' | 'delivery' | 'support' | 'pricing' | 'other'
}
// HubSpot lookup
search_by : customer_email
match_result : { contact_id: string } | null
// Output (Sheet row updated)
sentiment_score : integer 1-10
sentiment_label : string
theme_tag : string
hubspot_contact_id : string | 'no_match'
hubspot_update_status: 'updated' | 'no_match' | 'error'
intake_status : 'scored'
// Slack alert (when sentiment_score <= 4)
channel : #feedback-alerts
payload: {
customer_name : string | 'Unknown'
customer_email : string
sentiment_score : integer
theme_tag : string
raw_text_preview : first 280 characters of raw_text
submitted_at : formatted local time
hubspot_link : url | 'No CRM match'
}- The AI scoring call must use a system prompt that anchors the 1-to-10 scale: 1 to 3 is strongly negative with explicit dissatisfaction, 4 to 6 is mixed or neutral, 7 to 10 is clearly positive. The prompt must instruct the model to return only valid JSON with keys sentiment_score, sentiment_label, and theme_tag. No prose.
- Threshold for Slack alert is sentiment_score 4 or below. This value must be stored as a workflow variable named SENTIMENT_ALERT_THRESHOLD so it can be updated without touching node logic. Confirm the agreed threshold value with the process owner before go-live.
- HubSpot API: use the Contacts Search endpoint (POST /crm/v3/objects/contacts/search) filtering by the email property. If zero results are returned, set hubspot_contact_id to 'no_match' and hubspot_update_status to 'no_match'. Do not create a new contact automatically.
- HubSpot note creation: use the Engagements API (POST /crm/v3/objects/notes) with associations to the matched contact_id. The note body must include sentiment_score, sentiment_label, theme_tag, source, and submitted_at. A custom HubSpot contact property named 'latest_feedback_score' (type: number) must be created in the HubSpot portal before build and updated on each match.
- HubSpot tier must support private app tokens with scopes crm.objects.contacts.read, crm.objects.contacts.write, crm.objects.notes.write, and crm.schemas.contacts.write. Confirm the portal plan supports private apps (Starter or above).
- Slack connection requires a Slack App installed to the workspace with the chat:write scope and the target channel ID for #feedback-alerts. Confirm channel name and workspace before build.
- Fallback: if the AI scoring call fails or returns malformed JSON after two retries, set intake_status to 'scoring_error', log to the error table, and send a fallback Slack alert to #automation-errors with the record_id and raw error message.
- Short or ambiguous responses (under 20 words) should be flagged in the note body as 'low_confidence_score: true' so the process owner can review scoring accuracy during the first four weeks of live operation.
Reads each row where intake_status is 'scored', selects the correct Gmail acknowledgement template based on sentiment_label, personalises and sends the email to customer_email, then appends the classified record to the Notion feedback themes dashboard. This agent closes the customer-facing loop and keeps the reporting layer current without any manual effort. It must not send duplicate acknowledgement emails if triggered more than once for the same record_id.
// Input (from Google Sheets row)
record_id : uuid
customer_email : string
customer_name : string | 'there' // fallback salutation
sentiment_label : 'positive' | 'neutral' | 'negative'
sentiment_score : integer 1-10
theme_tag : string
submitted_at : ISO 8601 timestamp
// Template selection logic
if sentiment_label == 'positive' -> template_id: 'ack_positive'
if sentiment_label == 'neutral' -> template_id: 'ack_neutral'
if sentiment_label == 'negative' -> template_id: 'ack_negative'
// Gmail send payload
to : customer_email
from : connected Gmail address
subject : resolved from template (includes customer_name)
body_html : template rendered with { customer_name, theme_tag, submitted_at }
// Notion append payload
database_id : <confirmed Notion DB id>
properties: {
Name : customer_name | customer_email
Email : customer_email
Score : sentiment_score
Sentiment : sentiment_label
Theme : theme_tag
Source : source
Submitted : submitted_at
Record_ID : record_id
}
// Output (Sheet row updated)
ack_email_sent : true | false
notion_appended : true | false
intake_status : 'complete'- Gmail send requires OAuth 2.0 scope gmail.send on the same connected account used by the Intake Agent. The From address shown to customers must be agreed with the process owner before build.
- Three email templates must be authored and stored in the automation platform before this agent is activated: ack_positive, ack_neutral, and ack_negative. Template content must be approved by the process owner. Each template must support variable substitution for customer_name, theme_tag, and submitted_at.
- Dedupe guard: before sending, check ack_email_sent on the Sheet row. If already true, skip send and log reason 'duplicate_send_prevented' to the error table.
- Notion integration requires a Notion internal integration token with insert access to the target database. The database must have the following property types pre-created: Name (title), Email (email), Score (number), Sentiment (select with options positive, neutral, negative), Theme (select), Source (select), Submitted (date), Record_ID (rich text). Confirm the Notion database ID before build.
- If customer_email is null or malformed, skip the Gmail send step, set ack_email_sent to false, and still proceed with the Notion append.
- If the Notion append fails after two retries, set notion_appended to false, update intake_status to 'complete_notion_error', and log the failure to the error table with record_id.
- Confirm before build: whether the process owner wants the negative acknowledgement email sent immediately or held pending the Slack alert review. The default build sends immediately for all three sentiment categories.
03End-to-end data flow
// ───────────────────────────────────────���─────────────────────────
// TRIGGER
// ─────────────────────────────────────────────────────────────────
SOURCE: Typeform webhook (form_id, response_id, submitted_at, answers[])
OR Gmail inbox filter (message_id, thread_id, from_email, subject,
body_plain, received_at)
// ─────────────────────────────────────────────────────────────────
// AGENT 1: Feedback Intake Agent
// ─────────────────────────────────────────────────────────────────
EXTRACT:
customer_email <- answers[email_field].value | from_email
customer_name <- answers[name_field].value | null
raw_text <- answers[comment_field].value | body_plain
source <- 'typeform' | 'gmail'
submitted_at <- submitted_at | received_at (normalised to UTC ISO 8601)
GENERATE:
record_id <- uuid_v4()
intake_status <- 'pending_scoring'
DEDUPE CHECK:
if record_id or source_message_id exists in feedback_log -> HALT, log error
if len(raw_text.trim()) < 5 -> intake_status = 'invalid_response', HALT
WRITE -> Google Sheets [feedback_log]:
record_id, source, submitted_at, customer_email,
customer_name, raw_text, intake_status='pending_scoring'
// ── HANDOFF: Intake Agent -> Sentiment and Routing Agent ──────────
// Trigger condition: new row with intake_status = 'pending_scoring'
// ─────────────────────────────────────────────────────────────────
// AGENT 2: Sentiment and Routing Agent
// ─────────────────────────────────────────────────────────────────
READ <- Google Sheets [feedback_log] row where intake_status = 'pending_scoring'
fields: record_id, raw_text, customer_email, customer_name, submitted_at
AI SCORING CALL:
input: raw_text
output: sentiment_score (integer 1-10)
sentiment_label ('positive' | 'neutral' | 'negative')
theme_tag ('product_quality'|'delivery'|'support'|'pricing'|'other')
on_fail (after 2 retries): intake_status='scoring_error', log error, Slack #automation-errors
HUBSPOT LOOKUP:
POST /crm/v3/objects/contacts/search
filter: email = customer_email
on_match:
hubspot_contact_id <- contact.id
POST /crm/v3/objects/notes (body: sentiment_score, sentiment_label,
theme_tag, source, submitted_at)
PATCH /crm/v3/objects/contacts/{id}
properties.latest_feedback_score <- sentiment_score
hubspot_update_status <- 'updated'
on_no_match:
hubspot_contact_id <- 'no_match'
hubspot_update_status <- 'no_match'
ROUTING DECISION:
if sentiment_score <= SENTIMENT_ALERT_THRESHOLD (default: 4):
POST Slack channel #feedback-alerts
payload: customer_name, customer_email, sentiment_score,
theme_tag, raw_text[0:280], submitted_at, hubspot_link
UPDATE -> Google Sheets [feedback_log] row:
sentiment_score, sentiment_label, theme_tag,
hubspot_contact_id, hubspot_update_status,
intake_status <- 'scored'
// ── HANDOFF: Sentiment and Routing Agent -> Response and Reporting Agent ──
// Trigger condition: row updated with intake_status = 'scored'
// ────────────────────────────────────────────────────────��────────
// AGENT 3: Response and Reporting Agent
// ─────────────────────────────────────────────────────────────────
READ <- Google Sheets [feedback_log] row where intake_status = 'scored'
fields: record_id, customer_email, customer_name, sentiment_label,
sentiment_score, theme_tag, submitted_at, ack_email_sent
DEDUPE CHECK:
if ack_email_sent = true -> SKIP send, log 'duplicate_send_prevented'
TEMPLATE SELECTION:
sentiment_label = 'positive' -> template_id = 'ack_positive'
sentiment_label = 'neutral' -> template_id = 'ack_neutral'
sentiment_label = 'negative' -> template_id = 'ack_negative'
GMAIL SEND:
if customer_email is valid:
POST Gmail send
to: customer_email
subject: resolved from template
body_html: render(template_id, {customer_name, theme_tag, submitted_at})
ack_email_sent <- true
else:
ack_email_sent <- false
NOTION APPEND:
POST Notion database (database_id: <confirmed>)
properties: Name, Email, Score, Sentiment, Theme,
Source, Submitted, Record_ID
on_success: notion_appended <- true
on_fail (after 2 retries): notion_appended <- false,
intake_status <- 'complete_notion_error',
log to error table
UPDATE -> Google Sheets [feedback_log] row:
ack_email_sent, notion_appended,
intake_status <- 'complete'
// ─────────────────────────────────────────────────────────────────
// END STATE: record fully processed
// Google Sheets: complete row with all fields populated
// HubSpot: contact note added and latest_feedback_score updated (if matched)
// Slack: alert posted (if sentiment_score <= 4)
// Gmail: acknowledgement sent to customer
// Notion: entry appended to themes dashboard
// ─────────────────────────────────────────────────────────────────04Recommended build stack
More documents for this process
Every document generated for Customer Feedback Loop.