SOPs
Review Round-Trip
Why this exists
Section titled “Why this exists”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.
The walk-through example
Section titled “The walk-through example”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: 1234contact_name: John Smithcontact_name_aliases: [John Smith, John, J Smith]job_keywords: [toilet, plumbing, bathroom]requested_by: wayne@allthingshandy.comrequested_at: 2026-04-17 14:22business: all-things-handyThat 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.
The flow
Section titled “The flow”- Capture the request. Every time someone asks a customer for a review, a record is inserted into
review_requestswith contact id, name aliases, job keywords, who asked, when, and aclick_token. The review invite SMS sends the customer tohttps://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 toReview Requested, CRM API endpoint for manual capture (“just asked John for a review, toilet job”), SMS-to-CRM command for the field team. - 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. - Dedupe. Any review whose
google_review_idalready exists inreview_trackingis skipped. Everything else proceeds. - Match. For the new review, score it against every open
review_requestsrow 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. - 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).
- Draft two things.
claude -pdrafts 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 thereview_trackingrow. - 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.
- Queue fallback. The same review is also visible at
/reviews/pendingwhere 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. - 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_leftto true - marks the review request fulfilled
- posts the Google reply via
- Log it. Every review, matched or not, writes a row to
review_trackingwith the scores, the matched contact id if any, both drafts, and the posted/sent timestamps.
Confidence tiers
Section titled “Confidence tiers”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.
| Tier | Blended score | Action today | Future (auto-post phase) |
|---|---|---|---|
| High | ≥ 0.80 | Draft reply. Queue at /reviews/pending with a green confidence badge. SMS nudge with contact name and drafted reply. | Auto-post without approval. |
| Medium | 0.55 – 0.79 | Draft reply. Queue at /reviews/pending with a yellow confidence badge. Batched digest nudge at end of day. | Keep in approval queue. |
| Low | < 0.55 | No 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.
Reply generation
Section titled “Reply generation”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!”
Schema
Section titled “Schema”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.
What is already built and what is missing
Section titled “What is already built and what is missing”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) usingdifflib.SequenceMatcherwith a first-name-match shortcut - Match against
party_reminderstable (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_requeststable decoupled fromparty_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/pendingwith an approve/edit/post flow that callsreviews.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.
The Go Kart Park test case
Section titled “The Go Kart Park test case”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_requestsrow → lands in/reviews/pendingwith a Claude-drafted reply → I approve → reply posts to Google viareviews.reply
End-to-end pass criteria:
- The
review_requestsrow appears after sending the invite SMS - The Google review arrives and gets picked up within a few minutes
- The match confidence is above 0.80 (same email persona, name matches, keywords overlap)
- The drafted reply references the toilet specifically, not a generic thank-you
- Approving in the UI actually posts to Google and the reply is visible on the listing
- The contact record flips
review_left = trueand the pipeline moves toReview 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.
How to update this page
Section titled “How to update this page”- 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
businessenum 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 -preply quality drifts, link the incident into the Reply Generation section with a short postmortem. Do not re-litigate the pattern, just record what broke.
Pub/Sub setup (one-time)
Section titled “Pub/Sub setup (one-time)”This is how the event-driven trigger gets wired. All steps happen inside the shared GCP project 162690810654.
- 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).
- Create the Pub/Sub topic — in the Pub/Sub console, create a topic named
gbp-reviews. No schema. - Grant the GBP service account publish rights — add
mybusiness-api-pubsub@system.gserviceaccount.comas a Principal on the topic with rolePub/Sub Publisher. Without this, Google cannot write to the topic and the notification setup call fails silently. - Create a push subscription — subscription name
gbp-reviews-to-crm, push endpointhttps://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). - Register the notification setting — call
accounts.updateNotificationSettingon the My Business Notifications API with the account id, the topic name, and the notification typesNEW_REVIEWandUPDATED_REVIEW. This is a one-off REST call; see~/apps/claude-code-crm/tools/register_gbp_notifications.py(add when implementing). - Verify — leave a throwaway review on the listing and watch
~/apps/claude-code-crm/logs/crm.logfor “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.
Related
Section titled “Related”- 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