---
name: gbp-review-desk
description: Run review management for a Google Business Profile location - sync reviews, score sentiment and themes with a model, triage what needs a reply, draft replies for human approval, catch rejected replies, and report rating trends. Use after an authorized Business Profile OAuth connection already exists.
---

# Google Business Profile Review Desk

Turns raw Google reviews into a daily work queue: what is new, what is angry, what Google rejected, what to say back, and whether things are getting better.

This skill starts **after** you can already call the Business Profile API for a location (OAuth done, location chosen). It does not cover OAuth, posts, or editing the listing.

## Facts from Google's API reference (checked 2026-11-06)

- List reviews: `GET https://mybusiness.googleapis.com/v4/{parent=accounts/*/locations/*}/reviews`. `pageSize` max **50**. `orderBy` is `updateTime desc` (default), `rating`, or `rating desc`. Response has `reviews[]`, `averageRating`, `totalReviewCount`, `nextPageToken`.
- Reply: `PUT https://mybusiness.googleapis.com/v4/{name=accounts/*/locations/*/reviews/*}/reply` with body `{"comment": "..."}`. Reply `comment` max **4096 bytes**. Delete with `deleteReply`.
- Scope: `https://www.googleapis.com/auth/business.manage`.
- `starRating` is an enum: `ONE`..`FIVE` (or `STAR_RATING_UNSPECIFIED`). `comment` is missing on rating-only reviews. `reviewer.isAnonymous` can be true.
- Replies are moderated. `reviewReply.reviewReplyState` is `PENDING`, `APPROVED` or `REJECTED`; when rejected, `policyViolation` says why. New enum values can appear, so treat unknown values as "needs a human".
- Push instead of polling: grant `pubsub.topics.publish` on your topic to `mybusiness-api-pubsub@system.gserviceaccount.com`, then `PATCH https://mybusinessnotifications.googleapis.com/v1/accounts/{accountId}/notificationSetting?updateMask=pubsubTopic,notificationTypes` with `{"name":"accounts/{accountId}/notificationSetting","pubsubTopic":"projects/<p>/topics/<t>","notificationTypes":["NEW_REVIEW","UPDATED_REVIEW"]}`. Treat a notification as "sync this location now", not as the data itself.

Google's contribution policy says merchants must not offer incentives (payment, discounts, free goods or services) for reviews, must not discourage or prohibit negative reviews, and must not selectively solicit positive reviews (review gating). Build nothing that does those things, including in reply text.

## Workflow

1. **Sync.** Page through all reviews (`pageSize=50`, follow `nextPageToken`). Store each review by `reviewId`. Run `mergeSync` from the helpers below: new IDs are added, a changed `updateTime` marks an edit and clears the old analysis, and IDs missing from a full sync are marked `removedAt` (never hard-delete; the reviewer or Google may have removed it, and you want the history).
2. **Analyze** only reviews with no analysis (new or edited). Send the text and stars to a model with the prompt below. Validate with `parseAnalysis`; if it returns null, retry once, then leave it unanalyzed. Do not let a bad model answer block the queue.
3. **Triage** every live review with `triage`:
   - **P1, today:** 1-2 stars without a reply, anything the model flags urgent or safety, any reply Google `REJECTED`.
   - **P2, this week:** 3 stars without a reply; 4-5 stars whose text reads negative or mixed (star/text mismatch often hides a real complaint).
   - **P3:** positive or rating-only reviews without a reply.
4. **Draft** replies with a model, then run `checkReply`. A person approves every reply before the `PUT`. Never auto-post replies to P1 reviews.
5. **Post and confirm.** After the `PUT`, re-read the review. `PENDING` is normal; check again later. `REJECTED` goes back to P1 with the `policyViolation` shown to the approver.
6. **Report** weekly with `metrics`: rating in the window vs all time, star distribution, response rate, time to reply, unanswered negatives, and top negative themes.

## Sentiment prompt (return JSON only)

```
You label one customer review for a local business.
Stars: {stars} (null = none). Text: {text}
Return JSON only:
{"score": number -1..1, "label": "positive"|"neutral"|"negative"|"mixed",
 "aspects": [{"aspect": short noun like "wait time" or "billing", "polarity": "positive"|"negative"|"neutral"}],
 "urgent": true if it reports harm, a legal threat, discrimination, or a data/privacy problem,
 "safety": true if it reports injury or a health/safety risk}
Use at most 8 aspects. Reuse the same aspect words across reviews. Do not invent details not in the text.
```

Keep aspect words consistent (give the model your existing theme list) so themes roll up cleanly. Rating-only reviews: skip the model and use stars alone.

## Reply drafting rules

- Thank the person by first name if shown; never guess one for anonymous reviewers.
- Name the specific thing they mentioned. One short paragraph. No templated opener used on every reply.
- Negative: acknowledge, do not argue facts in public, invite them to contact the business directly (by name, not with a link or phone number in the reply).
- Never confirm someone was a customer or patient, and never include order numbers, appointment details, health or payment information.
- No links, discounts, coupons, or requests to change or remove the review.
- `checkReply` catches the mechanical cases (links, incentives, phone, email, record numbers, length). A person still reads every reply.

## Helpers (`reviewDesk.mjs`, Node 18+, no dependencies)

```js
// Review desk helpers: pure functions, no network, no dependencies.
// Input is the Review resource from GET mybusiness.googleapis.com/v4/{location}/reviews.
export const STARS = { ONE: 1, TWO: 2, THREE: 3, FOUR: 4, FIVE: 5 };
export const REPLY_MAX_BYTES = 4096;
const HOUR = 36e5;

export function normalizeReview(r) {
  return {
    reviewId: r.reviewId,
    name: r.name,
    stars: STARS[r.starRating] ?? null,
    text: (r.comment || "").trim(),
    reviewer: r.reviewer?.isAnonymous ? "Anonymous" : r.reviewer?.displayName || "Google user",
    createTime: r.createTime,
    updateTime: r.updateTime,
    reply: r.reviewReply ? { text: r.reviewReply.comment || "", updateTime: r.reviewReply.updateTime, state: r.reviewReply.reviewReplyState || "REVIEW_REPLY_STATE_UNSPECIFIED", violation: r.reviewReply.policyViolation || null } : null,
  };
}

/** Merge a fresh FULL sync into the stored map. Never hard-delete: mark missing reviews as removed. */
export function mergeSync(stored, fresh, now = new Date()) {
  const out = { ...stored }, seen = new Set(), changes = { added: [], edited: [], removed: [] };
  for (const raw of fresh) {
    const r = normalizeReview(raw); seen.add(r.reviewId);
    const old = stored[r.reviewId];
    if (!old) changes.added.push(r.reviewId);
    else if (old.updateTime !== r.updateTime || old.removedAt) changes.edited.push(r.reviewId);
    out[r.reviewId] = { ...r, analysis: old && old.updateTime === r.updateTime ? old.analysis : undefined, removedAt: undefined };
  }
  for (const id of Object.keys(stored)) if (!seen.has(id) && !stored[id].removedAt) { out[id] = { ...stored[id], removedAt: now.toISOString() }; changes.removed.push(id); }
  return { reviews: out, changes };
}

/** Validate model output before storing it. Returns null when the model output is unusable. */
export function parseAnalysis(json) {
  let a; try { a = typeof json === "string" ? JSON.parse(json) : json; } catch { return null; }
  if (!a || typeof a.score !== "number" || a.score < -1 || a.score > 1) return null;
  if (!["positive", "neutral", "negative", "mixed"].includes(a.label)) return null;
  const aspects = Array.isArray(a.aspects) ? a.aspects.filter(x => x && typeof x.aspect === "string" && ["positive", "negative", "neutral"].includes(x.polarity)).slice(0, 8) : [];
  return { score: a.score, label: a.label, aspects, urgent: a.urgent === true, safety: a.safety === true };
}

/** Priority: P1 = act today, P2 = reply this week, P3 = thank-you reply, null = nothing to do. */
export function triage(r, now = new Date()) {
  if (r.removedAt) return null;
  if (r.reply?.state === "REJECTED") return { level: "P1", reason: "Reply rejected by Google moderation" + (r.reply.violation ? ` (${r.reply.violation})` : "") };
  if (r.reply) return null;
  const age = (now - new Date(r.createTime)) / HOUR, a = r.analysis;
  if (a?.safety || a?.urgent) return { level: "P1", reason: "Flagged urgent or safety issue" };
  if (r.stars !== null && r.stars <= 2) return { level: "P1", reason: `${r.stars}-star review without a reply (${Math.round(age)}h old)` };
  if (r.stars !== null && r.stars >= 4 && a && (a.label === "negative" || a.label === "mixed")) return { level: "P2", reason: "High stars but negative or mixed text" };
  if (r.stars === 3) return { level: "P2", reason: "3-star review without a reply" };
  return { level: "P3", reason: r.text ? "Positive review without a reply" : "Rating-only review" };
}

export function metrics(list, now = new Date(), days = 90) {
  const since = now - days * 24 * HOUR;
  const live = list.filter(r => !r.removedAt && r.stars !== null);
  const recent = live.filter(r => new Date(r.createTime) >= since);
  const replied = recent.filter(r => r.reply && r.reply.state !== "REJECTED");
  // reply.updateTime is the LAST edit of the reply, so this is an upper bound on time-to-reply.
  const waits = replied.map(r => (new Date(r.reply.updateTime) - new Date(r.createTime)) / HOUR).filter(h => h >= 0).sort((x, y) => x - y);
  const avg = xs => xs.length ? Math.round(xs.reduce((s, r) => s + r.stars, 0) / xs.length * 100) / 100 : null;
  const themes = {};
  for (const r of recent) for (const x of r.analysis?.aspects || []) { const t = (themes[x.aspect.toLowerCase()] ||= { positive: 0, negative: 0, neutral: 0 }); t[x.polarity]++; }
  return {
    windowDays: days, reviews: recent.length, averageAll: avg(live), averageWindow: avg(recent),
    distribution: [1, 2, 3, 4, 5].map(s => recent.filter(r => r.stars === s).length),
    responseRate: recent.length ? Math.round(replied.length / recent.length * 100) : null,
    medianHoursToReplyUpperBound: waits.length ? Math.round(waits[Math.floor(waits.length / 2)]) : null,
    unansweredNegative: recent.filter(r => r.stars <= 2 && !r.reply).length,
    topNegativeThemes: Object.entries(themes).filter(([, v]) => v.negative).sort((a, b) => b[1].negative - a[1].negative).slice(0, 5).map(([k, v]) => ({ theme: k, ...v })),
  };
}

/** Block replies that would break policy or leak data before a human approves them. */
export function checkReply(text) {
  const problems = [];
  if (!text.trim()) problems.push("empty");
  if (new TextEncoder().encode(text).length > REPLY_MAX_BYTES) problems.push("over 4096 bytes");
  if (/https?:\/\/|www\./i.test(text)) problems.push("contains a link");
  if (/\b(discount|coupon|% off|free (gift|service)|gift card)\b/i.test(text)) problems.push("offers an incentive");
  if (/\b\d{3}[-.\s]?\d{3}[-.\s]?\d{4}\b/.test(text)) problems.push("contains a phone number");
  if (/[\w.+-]+@[\w-]+\.[\w.]+/.test(text)) problems.push("contains an email address");
  if (/\b(order|invoice|account|appointment|booking)\s*(#|no\.?|number)\s*\w+/i.test(text)) problems.push("references a private record");
  return { ok: problems.length === 0, problems };
}
```

## Storage shape

One record per review: `reviewId`, `name`, `stars`, `text`, `reviewer`, `createTime`, `updateTime`, `reply` {text, updateTime, state, violation}, `analysis`, `removedAt`. Keep a separate append-only log of reply drafts, who approved them, and what Google returned.

## Gotchas

- `reply.updateTime` is the last edit of the reply, not when it was first posted. Time-to-reply from the API is an upper bound; log your own post time if you need the exact number.
- `averageRating` and `totalReviewCount` come from Google. Your own average only counts reviews you synced, so show Google's numbers as the headline.
- A review can change stars. Re-run triage after every sync, not only for new reviews.
- Pub/Sub delivers at least once. Make the sync idempotent (it is, if keyed by `reviewId` + `updateTime`).
- Rate limits: back off on HTTP 429 and keep one sync per location at a time.

## Tested

The helpers ran 14/14 checks on fixture reviews: rating-only normalize, add/edit/remove merge, edited review clears stale analysis, bad model output rejected, triage levels including a `REJECTED` reply with `policyViolation`, metrics math, theme rollup, and the reply checker. Not tested: live Google API calls, Pub/Sub delivery, or a real model.
