Skip to content

SOPs

Review Round-Trip

Reviews are the single biggest lever for a local service business, and the current loop is broken into pieces that do not talk to each other. I ask a customer for a review (by SMS, by email, in person after a job), the review shows up on Google a few hours later, and the reply I post back is generic or late or never happens. The person who left the review never hears that I saw it, never gets thanked for the specific thing they said, and the next customer browsing reviews sees a business that does not engage.

The Google Business Profile API got approved on 2026-04-16 (see Google Business Profile API), which unlocks the public reply side. This page is the design for the full round-trip: I ask, a review arrives, I match it, I reply, and the whole thing is logged in the CRM next to the contact it belongs to.

Wayne does a toilet plumbing job for John Smith. When Wayne marks the job complete in GHL, the CRM captures a review-request record:

contact_id: 1234
contact_name: John Smith
contact_name_aliases: [John Smith, John, J Smith]
job_keywords: [toilet, plumbing, bathroom]
requested_by: wayne@allthingshandy.com
requested_at: 2026-04-17 14:22
business: all-things-handy

That evening the review monitor pulls reviews from the ATH Google Business Profile. A new review comes in from “John S.” saying “Wayne fixed our leaking toilet the same day we called. Fast, clean, friendly.”

The matcher scores it against every open review-request within the last 7 days:

  • Name similarity between “John S.” and “John Smith” — strong first-name match, partial last name, 0.82
  • Keyword overlap between [toilet, plumbing, bathroom] and the review text — “toilet” appears, 0.33
  • Click proximity — John tapped the tracked link 6 minutes before the review appeared, 1.0
  • Blended confidence: 0.82*0.5 + 0.33*0.3 + 1.0*0.2 = 0.71

0.71 lands in the medium tier, so the reply goes to the approval queue. If the review had said “John Smith” in full and mentioned all three keywords along with the click, we would be north of 0.90 — still drafts only under the current rule, but once I flip the auto-post switch on high tier, that review would post itself.

Either way, the contact record updates with review_left = true, review_stars = 5, review_text = ..., the pipeline moves to Review Received, and the referral offer email gets scheduled two days out.

  1. Capture the request. Every time someone asks a customer for a review, a record is inserted into review_requests with contact id, name aliases, job keywords, who asked, when, and a click_token. The review invite SMS sends the customer to https://crm.gokartpark.com/api/rr/{click_token} which logs the click (clicked_at, click_count) and then 302 redirects to the real Google review URL. The /api/ prefix is required because the CRM root path sits behind Cloudflare Access — customers do not have the auth cookie, but the /api/* pattern is bypassed. Triggers: GHL workflow action on pipeline move to Review Requested, CRM API endpoint for manual capture (“just asked John for a review, toilet job”), SMS-to-CRM command for the field team.
  2. Get notified of the review. Event-driven only. Google Business Profile publishes to a Cloud Pub/Sub topic when a review is created or updated. A push subscription posts to https://crm.gokartpark.com/api/webhooks/gbp, which decodes the message and processes the affected review immediately. No polling, no cron. Setup steps are in the Pub/Sub section at the bottom of this page.
  3. Dedupe. Any review whose google_review_id already exists in review_tracking is skipped. Everything else proceeds.
  4. Match. For the new review, score it against every open review_requests row from the last 7 days. Blended score = name_similarity * 0.5 + keyword_overlap * 0.3 + click_proximity * 0.2. The click signal comes from the tracked review link in step 1 — only the person who got the SMS can click it, so a click within minutes of the review landing is the strongest short-range proof of identity.
  5. Tier. The blended score drops into one of three buckets, and each bucket has a different notification behavior. None of them auto-post — every reply is draft-only until I click approve (see Confidence tiers below).
  6. Draft two things. claude -p drafts a PUBLIC Google reply (for the listing) and a PRIVATE thank-you email (sent directly to the customer). Both use the review text, star rating, matched contact, and job context. Both get stored on the review_tracking row.
  7. Email me. An approval email lands in my inbox via Resend. It shows the review, the match, the confidence breakdown, both drafts side by side, and one big Approve button.
  8. Queue fallback. The same review is also visible at /reviews/pending where I can edit either draft before approving. For cases where I want to tune the wording, I use the queue; for quick approvals I tap the email button.
  9. Approve — one click. Hitting the Approve link calls /api/reviews/{id}/approve-all?token=.... Token-gated, idempotent. Server-side it:
    • posts the Google reply via reviews.reply
    • sends the customer email via Resend
    • inserts a system note on the matched contact’s conversation history with the review text and stars
    • flips contact.custom_fields.review_left to true
    • marks the review request fulfilled
  10. Log it. Every review, matched or not, writes a row to review_tracking with the scores, the matched contact id if any, both drafts, and the posted/sent timestamps.

Every reply is draft-only right now. Nothing posts to Google without me clicking approve. The tiers still exist because they drive how loud the notification is and how the reply gets framed, but the posting step is gated on me for every tier. Once I have run this for a few weeks and trust the drafts, I will promote the high tier to auto-post.

TierBlended scoreAction todayFuture (auto-post phase)
High≥ 0.80Draft reply. Queue at /reviews/pending with a green confidence badge. SMS nudge with contact name and drafted reply.Auto-post without approval.
Medium0.55 – 0.79Draft reply. Queue at /reviews/pending with a yellow confidence badge. Batched digest nudge at end of day.Keep in approval queue.
Low< 0.55No auto-match. Review lands in /reviews/unmatched. I manually attach it to a contact or flag as unknown reviewer.Same.

The thresholds are a starting point. They should migrate based on how often the medium tier catches a real mis-match. If three months in, nothing in medium ever gets corrected, raise the high cutoff to 0.75 and shrink the queue.

Replies are drafted by claude -p (never the paid API — see the super CLAUDE.md rule). The prompt gets: the review text, the star rating, the contact name, the job that triggered the review request, and a short voice guide for the business. Output is a single short paragraph. No emoji spam, no generic “thanks for choosing us” language, no em dashes.

Today every tier produces a draft that I approve manually. Only medium and high get drafts on arrival. Low-tier (unmatched) reviews wait until I attach them to a contact, then drafting runs.

Replies reference the specific thing the reviewer mentioned when possible. “Thanks for the kind words about the toilet repair, John. Glad we could get it fixed same-day.” beats “Thanks for your review!”

Two new tables, one existing table extended.

CREATE TABLE review_requests (
id SERIAL PRIMARY KEY,
contact_id INTEGER REFERENCES contacts(id),
business TEXT NOT NULL, -- 'go-kart-park', 'all-things-handy', etc.
contact_name TEXT NOT NULL,
contact_name_aliases JSONB, -- ["John Smith", "John", "J Smith"]
job_keywords JSONB, -- ["toilet", "plumbing", "bathroom"]
requested_by TEXT, -- email or user id of who asked
requested_at TIMESTAMPTZ DEFAULT NOW(),
fulfilled_at TIMESTAMPTZ, -- set when a review is matched
fulfilled_review_id TEXT -- FK to review_tracking.google_review_id
);

The existing review_tracking table (already in review_monitor.py) gets two columns added:

ALTER TABLE review_tracking ADD COLUMN matched_request_id INTEGER;
ALTER TABLE review_tracking ADD COLUMN reply_posted_text TEXT;
ALTER TABLE review_tracking ADD COLUMN reply_posted_at TIMESTAMPTZ;
ALTER TABLE review_tracking ADD COLUMN tier TEXT; -- 'high', 'medium', 'low'

An approval queue view /reviews/pending just reads review_tracking rows where tier = 'medium' and reply_posted_at IS NULL.

The core matcher lives at ~/apps/claude-code-crm/review_monitor.py. Current state:

Built:

  • GBP OAuth and token management (get_gbp_credentials)
  • Location lookup and review fetch (fetch_reviews)
  • Fuzzy name matcher (name_similarity) using difflib.SequenceMatcher with a first-name-match shortcut
  • Match against party_reminders table (Go Kart Park specific)
  • Dedup via review_tracking.google_review_id
  • “Was that you?” confirmation email to the matched contact
  • Referral email scheduled 2 days post-match

Missing (the delta this doc is asking to build):

  • Generic review_requests table decoupled from party_reminders
  • Job keyword scoring (only name matches today)
  • Three-tier confidence routing (single 0.6 threshold today)
  • Event-driven trigger (today is a 4-hour cron; target is GBP Pub/Sub push, 5-minute poll fallback)
  • Claude-drafted reply text stored on every match
  • Approval queue UI at /reviews/pending with an approve/edit/post flow that calls reviews.reply
  • Multi-business support (starting with Go Kart Park; ATH and the rest follow once the pattern is proven)

DB layer note: The CRM is Postgres (not SQLite as the old CLAUDE.md claimed — that drift got fixed 2026-04-17). database.py wraps psycopg2 in a PgConnection shim so call sites keep using db.execute(sql, params).fetchone() style. Migrations target Postgres and use %s placeholders, jsonb types, and NOW() / TIMESTAMPTZ.

First wire-up is Go Kart Park. Test contact is me, using a second email and a test phone number so the loop can run end-to-end without bothering a real customer.

  • Test contact: OJ Hurst, OJHurst3@gmail.com, 801-784-8778
  • Test job context: pretend Wayne just fixed a toilet (yes, GKP is a birthday party business; the point is the matcher, not the service — we reuse the toilet example from the spec so the keyword set is [toilet, plumbing, bathroom])
  • Test review request SMS: “Hey, great having you out today. If you have a minute, would you mind leaving us a review? [GBP review link]”
  • What I do: click the link in the SMS, leave a real Google review as OJHurst3 that mentions the toilet
  • What the system does: GBP notification fires → matcher scores the review against the open review_requests row → lands in /reviews/pending with a Claude-drafted reply → I approve → reply posts to Google via reviews.reply

End-to-end pass criteria:

  1. The review_requests row appears after sending the invite SMS
  2. The Google review arrives and gets picked up within a few minutes
  3. The match confidence is above 0.80 (same email persona, name matches, keywords overlap)
  4. The drafted reply references the toilet specifically, not a generic thank-you
  5. Approving in the UI actually posts to Google and the reply is visible on the listing
  6. The contact record flips review_left = true and the pipeline moves to Review Received

If any of those fail, the failure mode goes into this section as a note so I know what the sharp edge was.

How this interacts with the approval queue

Section titled “How this interacts with the approval queue”

Claude Code CRM already has a staged message approval queue for outbound SMS (see claude_responder.py and the messaging flow). Review replies ride the same rails conceptually but live on their own surface at /reviews/pending because reviewing a Google reply is a different mental task than approving an SMS — I want to see the star rating, the review text, the matched contact’s job history, and the drafted reply side by side.

A medium-tier review sends me a single SMS nudge once per evening: “2 reviews waiting at crm.gokartpark.com/reviews/pending.” No per-review pings.

  • When the matcher weights change (the 0.6 / 0.4 blend, the tier thresholds), update the Confidence Tiers section and note the date the change landed.
  • When a new business gets wired in (ATH, MyTechSupport, Cascade), add it to the business enum and to the walk-through if the flow differs.
  • When the schema changes, the Schema section stays authoritative. If this page and the migration files disagree, the migration wins — fix this page.
  • When claude -p reply quality drifts, link the incident into the Reply Generation section with a short postmortem. Do not re-litigate the pattern, just record what broke.

This is how the event-driven trigger gets wired. All steps happen inside the shared GCP project 162690810654.

  1. Enable the APIs — in the API Library, enable Cloud Pub/Sub API and My Business Notifications API (separate from the Business Profile API and the legacy My Business API v4).
  2. Create the Pub/Sub topic — in the Pub/Sub console, create a topic named gbp-reviews. No schema.
  3. Grant the GBP service account publish rights — add mybusiness-api-pubsub@system.gserviceaccount.com as a Principal on the topic with role Pub/Sub Publisher. Without this, Google cannot write to the topic and the notification setup call fails silently.
  4. Create a push subscription — subscription name gbp-reviews-to-crm, push endpoint https://crm.gokartpark.com/api/webhooks/gbp. Set an Ack deadline of 60s. Under “Authentication”, enable OIDC and use a service account whose JWT the CRM can verify (or skip auth during dev and add verification later).
  5. Register the notification setting — call accounts.updateNotificationSetting on the My Business Notifications API with the account id, the topic name, and the notification types NEW_REVIEW and UPDATED_REVIEW. This is a one-off REST call; see ~/apps/claude-code-crm/tools/register_gbp_notifications.py (add when implementing).
  6. Verify — leave a throwaway review on the listing and watch ~/apps/claude-code-crm/logs/crm.log for “GBP Pub/Sub push received”. First push usually lands within a few seconds.

The webhook handler lives in server.py at /api/webhooks/gbp. It decodes the push envelope, extracts the notification type, and calls review_monitor.process_review for the affected review so the whole matching/drafting/approval-email flow runs.

  • Google Business Profile API — the auth and endpoint map this relies on
  • Cloudflare Pages Deploy — how the CRM dashboard ships
  • ~/apps/claude-code-crm/review_monitor.py — the implementation file
  • ~/apps/claude-code-crm/VISION.md — the broader CRM thesis this slots into
  • Super CLAUDE.md rule: “No Paid API Calls — Use Claude Code CLI” — why the reply drafting goes through claude -p