skills/google-contacts-label-sync/SKILL.md
Sync a list of people into Google Contacts tagged with a specific label. Creates the label if missing, dedupes by email (adds the label to existing contacts instead of creating duplicates), and creates new contacts with the label attached in a single API call. Uses the Google People API directly via the gog CLI's stored OAuth refresh token. Use this whenever the user wants to add, tag, label, group, or organize contacts in Google Contacts — including phrases like "add these people to my contacts", "tag them as X", "sync this signup list", "put them in a Google Contacts group", "label all of these". Also the right skill when the user has a list (spreadsheet, CRM export, signup data) and wants it represented in Google Contacts with a recognizable label for later bulk actions.
npx skillsauth add razbakov/skills skills/google-contacts-label-syncInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Given a list of people and a label name, make sure every person is in Google Contacts with that label attached. Dedupe by email — existing contacts get the label added, not a duplicate entry created.
Natural user phrasings that should invoke this skill:
If the user has a list of names + emails and wants them discoverable later by a shared tag, this is the skill.
gog contactsgog contacts create doesn't support memberships (contact group / label assignment) as a flag. Attaching a label requires either:
gog, then call people:updateContact to patch memberships (two round trips, two failure points per contact), orpeople:createContact directly with memberships in the request body (one round trip, atomic).This skill uses option 2 via curl + an access token bootstrapped from gog's stored refresh token. It's still authenticated as the gog-managed user — no new OAuth flow needed.
gog is installed and authorized. Verify with gog auth status — credentials_exists should be true.https://www.googleapis.com/auth/contacts scope. Step 1 of the process verifies this; if missing, the fix is gog auth manage (adds scopes interactively).Always normalize input to a JSON file first — makes the run idempotent and debuggable.
Minimal per-person record:
{"given": "...", "family": "...", "email": "...", "phone": "..."}
Optional fields the skill will use if present: organization, title, urls, note, address.
Label name: the human-readable Google Contacts label (e.g., MysteryGames, FestivalAttendees-2026). If the label doesn't exist, this skill creates it. Labels are per-account and don't conflict with groups from other Google Workspace domains.
Optional --bio: a short string added to every created contact's biography (e.g., "Source: MysteryGames signup, 2026-04-17"). Helpful for tracing provenance months later. Not applied to contacts that already exist — you're just adding a label to them, not editing their entry.
Discover config paths rather than hardcoding — gog's install location varies by OS. gog auth status emits TSV with the relevant paths:
eval "$(gog auth status 2>/dev/null | awk -F'\t' '
$1=="account" {printf "ACC=%s\n", $2}
$1=="credentials_path" {printf "CRED=%s\n", $2}
')"
If $ACC or $CRED are empty, abort with an instruction to run gog auth manage first.
Export the refresh token for that account and exchange it for a short-lived access token:
RTFILE=$(mktemp -t gog-rt.XXXXXX.json)
ATFILE=$(mktemp -t gog-at.XXXXXX.txt)
chmod 600 "$RTFILE" "$ATFILE" # owner-only; /tmp is world-readable on multi-user systems
gog auth tokens export "$ACC" --out="$RTFILE" --overwrite >/dev/null
RT=$(python3 -c "import json,sys; print(json.load(open('$RTFILE'))['refresh_token'])")
CID=$(python3 -c "import json,sys; print(json.load(open('$CRED'))['client_id'])")
CSEC=$(python3 -c "import json,sys; print(json.load(open('$CRED'))['client_secret'])")
curl -s -X POST https://oauth2.googleapis.com/token \
-d "client_id=$CID" -d "client_secret=$CSEC" \
-d "refresh_token=$RT" -d "grant_type=refresh_token" \
| python3 -c "import json,sys; print(json.load(sys.stdin)['access_token'])" > "$ATFILE"
AT=$(cat "$ATFILE")
Verify scope before any People API call:
HAS_SCOPE=$(curl -s "https://oauth2.googleapis.com/tokeninfo?access_token=$AT" \
| python3 -c "import json,sys; s=json.load(sys.stdin).get('scope',''); print('yes' if 'auth/contacts' in s else 'no')")
If no, abort and tell the user: "The gog-authorized account is missing the Contacts scope. Run gog auth manage and re-authorize with the Contacts scope enabled, then re-run."
Trap cleanup so secrets get removed even on error:
trap 'rm -f "$RTFILE" "$ATFILE"' EXIT
This pattern applies for the rest of the skill — treat the access token file as write-once/read-many and the refresh token file as sensitive.
List user-defined groups:
GROUP=$(curl -s "https://people.googleapis.com/v1/contactGroups?pageSize=200" \
-H "Authorization: Bearer $AT" \
| python3 -c "
import json,sys
target = sys.argv[1]
for g in json.load(sys.stdin).get('contactGroups', []):
if g.get('groupType') == 'USER_CONTACT_GROUP' and g.get('name') == target:
print(g['resourceName'])
break
" "$LABEL_NAME")
If $GROUP is empty, create it:
GROUP=$(curl -s -X POST "https://people.googleapis.com/v1/contactGroups" \
-H "Authorization: Bearer $AT" -H "Content-Type: application/json" \
-d "{\"contactGroup\":{\"name\":\"$LABEL_NAME\"}}" \
| python3 -c "import json,sys; print(json.load(sys.stdin)['resourceName'])")
$GROUP will have the form contactGroups/<id>. Keep the whole string — the membership endpoint needs it.
The people:searchContacts endpoint is eventually consistent — it relies on an index that lags direct writes. Warm the cache with one empty query, pause briefly, then run the real lookups:
import subprocess, urllib.parse, time, json
AT = open(ATFILE).read().strip()
# Warm the searchContacts cache — the index is eventually consistent
subprocess.run(['curl','-s',
'https://people.googleapis.com/v1/people:searchContacts?query=&readMask=names',
'-H', f'Authorization: Bearer {AT}'], capture_output=True)
time.sleep(2)
def lookup(email):
url = (f"https://people.googleapis.com/v1/people:searchContacts"
f"?query={urllib.parse.quote(email)}"
f"&readMask=names,emailAddresses,memberships&pageSize=30")
r = subprocess.run(['curl','-s',url,'-H',f'Authorization: Bearer {AT}'],
capture_output=True, text=True)
matches = []
for item in json.loads(r.stdout).get('results', []):
person = item.get('person', {})
for ea in person.get('emailAddresses', []):
if ea.get('value','').lower() == email.lower():
matches.append(person)
break
return matches
For each input person:
missingexists with that resourceNameambiguous, report to user, ask which one (don't silently pick the first — duplicates in contacts usually signal a real distinction the user knows about, like personal vs. work)Pace requests (time.sleep(0.2) between lookups). Save the dedup map to /tmp/<slug>-dedup.json for debugging and retry.
One API call per missing contact:
body = {
"names": [{"givenName": p['given'], "familyName": p.get('family','')}],
"emailAddresses": [{"value": p['email']}],
"phoneNumbers": [{"value": p['phone']}] if p.get('phone') else [],
"memberships": [{"contactGroupMembership": {"contactGroupResourceName": GROUP}}],
}
if BIO_TEXT:
body["biographies"] = [{"value": BIO_TEXT, "contentType": "TEXT_PLAIN"}]
# Retry on empty response — occasional transient network blips
for attempt in range(3):
r = subprocess.run(['curl','-s','--max-time','20','-X','POST',
'https://people.googleapis.com/v1/people:createContact',
'-H', f'Authorization: Bearer {AT}',
'-H', 'Content-Type: application/json',
'-d', json.dumps(body)], capture_output=True, text=True)
if r.stdout.strip():
break
time.sleep(1 + attempt) # 1s, 2s, 3s backoff
Parse the response and collect the returned resourceName into a created list. Pace with time.sleep(0.25) between creates.
For people bucketed as exists, add them to the group in one batch call. members:modify accepts up to 1000 resourceNames:
curl -s -X POST "https://people.googleapis.com/v1/${GROUP}/members:modify" \
-H "Authorization: Bearer $AT" -H "Content-Type: application/json" \
-d "{\"resourceNamesToAdd\":[\"people/c...\", \"people/c...\"]}"
Empty {} response on success. Adding someone already in the group is a no-op — idempotent.
Fetch the group to confirm the final member count:
curl -s "https://people.googleapis.com/v1/$GROUP?maxMembers=1000" \
-H "Authorization: Bearer $AT" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print(f\"{d['name']}: {d.get('memberCount',0)} members\")"
Report to the user:
https://contacts.google.com/label/<id> (extract <id> from the contactGroups/<id> resource name)The trap in Step 1 handles this, but state it explicitly for readers who copy-paste fragments:
rm -f "$RTFILE" "$ATFILE"
These files contain a refresh token (long-lived) and an access token (1h). Leaving them behind is a real risk on shared machines — they grant access to the user's entire Google account scope bundle, not just Contacts.
| Symptom | Cause | Fix |
|---|---|---|
| gog auth tokens export fails | Refresh token missing or expired | gog auth manage to re-auth |
| Token exchange returns invalid_grant | Refresh token revoked | gog auth manage |
| tokeninfo response missing auth/contacts scope | gog authorized without Contacts scope | gog auth manage, re-authorize with Contacts |
| 409 on group create | Race — label created between list and create | Re-list, use found resourceName |
| 429 rate limit | Too-tight loop | Increase sleep to 0.5s between calls |
| Empty response body on createContact | Transient network | Skill retries 3x with backoff; surface persistent failures |
| 2+ matches for same email | Existing duplicate contacts | Ask user which one to label; don't silently pick |
gog contacts create is simpler and doesn't need OAuth juggling.gog contacts create in a loop.members:modify endpoint supports resourceNamesToRemove if needed later.Re-running with the same input list is safe:
/tmp/<slug>-dedup.json shows which bucket each person landed in.This makes incremental syncs trivial: when the source list grows (e.g., 3 new signups since last run), just re-run — only the new ones will hit createContact.
Phone number format: People API stores whatever string you pass — no validation, no normalization. If a downstream consumer needs a specific format (E.164 for WhatsApp, for instance), normalize in a wrapper skill before calling this one. This skill deliberately doesn't touch phone format because "what's the right format" depends on where the phone is going next.
development
Seed a new or empty Instagram account with a 9-post grid (3×3) so the profile looks established the moment a new visitor lands. Designed for festivals, new businesses, product launches, conferences, communities — any time an empty IG profile would hurt conversion from external traffic (QR scans, flyer drops, cross-promo). Generates assets via /image-from-gemini (per content-publishing rules — never HTML), writes captions with hashtag sets, and outputs a posting order + cadence plan. Trigger generously: phrases like '9 posts for instagram', 'fill my IG', 'starter grid', 'launch grid', 'instagram seed', '9-post grid', 'IG account not to look empty', 'first instagram posts', 'feed bootstrap', '3x3 grid', 'instagram launch content'. Even if the user mentions only one piece (just the images, just the captions, just the order), use this skill — the grid only works as an integrated bundle.
testing
Translate one English blog post into multiple target languages via parallel sub-agents, preserving frontmatter conventions, hero image, and brand voice. Use when the user shares a published English post URL or markdown path and says 'translate it', 'add other languages', 'publish in DE/ES/RU/UK', 'translate to 5 languages', or asks for localized versions of a specific post.
development
Build a complete press kit for an event, product launch, or campaign — in multiple languages — and publish it as a shareable Google Drive folder ready to send to journalists, partners, or a delegate. Produces press releases (typically DE/EN/ES, or configurable), uploads press photos and flyers, creates an Overview document for at-a-glance briefing, and creates a Handover document with pending tasks, contacts, risks, and decisions so press distribution can be delegated. Use when the user says 'I need a press release', 'create a press kit', 'press release in X languages', 'set up a Drive folder for press', 'handover doc for someone else to run press', or has an upcoming announcement that needs to be sent to media. Trigger generously: even partial requests (just a press release, just a flyer folder) typically evolve into the full kit.
development
Track ticket sales for a live event (concert, festival, conference, workshop) with daily snapshots, generate a burndown chart comparing actual sales to ideal-linear targets and tier-cumulative milestones, and report whether the event is on pace. Use when the user asks how sales are going, wants to know if their event will sell out, asks for a daily sales report, wants to set up sales tracking for an upcoming event, or asks about ticket pace / velocity / projection. Trigger generously: phrases like 'how is concert sales going', 'burndown for my event', 'are we going to sell out', 'sales velocity', 'daily ticket chart', 'how many tickets do we need to sell', or any case where the user has a ticketed event with a fixed sales window and wants visibility on pacing.