PR CRM — Operator Runbook
PR CRM — Runbook
Section titled “PR CRM — Runbook”Day-to-day cheatsheet. Copy-paste recipes for everything you’ll do regularly.
Where things live
- Master DB: Supabase (project
fylusmdrwhbvzqvqpyev)- Team UI: Attio
- Email sender: Smartlead
- Production app: https://pr-crm-eta.vercel.app
- Repo: https://github.com/designing-the-district/pr-crm
Discoverability: just run
make(ormake help) for the full list of targets with descriptions.
Creating a campaign
Section titled “Creating a campaign”Three flavors — pick whichever feels right in the moment.
1. From a stored config (Recommended for recurring pitches)
Section titled “1. From a stored config (Recommended for recurring pitches)”Best for monthly/quarterly campaigns. Config + body files live in campaigns/, version-controlled.
First time: copy the example as your starting point.
cp campaigns/example-florida-food.json campaigns/2026-04-florida-food.jsoncp campaigns/example-florida-food.html campaigns/2026-04-florida-food.htmlcp campaigns/example-florida-food-followup-1.html campaigns/2026-04-florida-food-followup-1.html# Open the .json + .html files in your editor, update name/subject/body/filterRun it:
make campaign-from FROM=campaigns/2026-04-florida-food.jsonThat creates the campaign in Smartlead DRAFT so you can review.
| Goal | Command |
|---|---|
| Launch immediately | make campaign-from FROM=campaigns/x.json START=1 |
| Preview only, no writes | make campaign-from FROM=campaigns/x.json DRY=1 |
| Cap leads to 20 (test run) | make campaign-from FROM=campaigns/x.json MAX=20 |
2. Wizard (interactive — good for one-offs)
Section titled “2. Wizard (interactive — good for one-offs)”make campaign-newWalks through prompts: name → markets → segments → beats → mailboxes → steps (initial + follow-ups) → max leads → start? → dry run? → save as config?
Hit ENTER at any prompt to accept the default in [brackets].
If you say “yes” to “Save as a reusable config”, it writes a .json to campaigns/ and you can re-run later with make campaign-from FROM=<path>.
3. Direct CLI flags (single-step only — if you really want to type)
Section titled “3. Direct CLI flags (single-step only — if you really want to type)”npm run campaign -- \ --name="Florida Food Pitch April 2026" \ --markets=Florida \ --segments=Editorial \ --beats=Food \ --subject="Pitching: New Miami Restaurant Concept" \ --body-file=./pitch.html \ --from=design@designingthedistrict.com,timur@designingthedistrict.com \ --max-leads=50 \ --dry-runAdd --start to launch immediately. Drop --dry-run to actually create. The --from flag accepts comma-separated mailboxes.
For follow-up sequences, use the JSON config or the wizard — direct flags only support a single step.
Config file shape
Section titled “Config file shape”Each campaign config is a small JSON file in campaigns/. Body content lives in adjacent .html files (one per step).
{ "name": "Florida Food Pitch April 2026", "from": ["design@designingthedistrict.com", "timur@designingthedistrict.com"], // 1+ mailboxes — Smartlead rotates "filter": { "markets": ["Florida"], // state names, country names, or city names "segments": ["Editorial"], // see "Allowed values" below "beats": ["Food"], "includeParents": false // true = also pull USA-tagged contacts }, "sequences": [ { "subject": "Pitching: New Miami Restaurant Concept", "bodyFile": "./florida-food.html", "delayDays": 0 // initial pitch }, { "subject": "Re: Pitching: New Miami Restaurant Concept", "bodyFile": "./florida-food-followup-1.html", "delayDays": 3 // 3 days after step 1, only if no reply }, { "subject": "One last try", "bodyFile": "./florida-food-followup-2.html", "delayDays": 7 // 7 days after step 2 } ], "maxLeads": null, // number to cap, or null for unlimited "start": false, // true = launch immediately "dryRun": false // true = preview only}Notes:
fromcan be a string (single mailbox) or array (multiple). When multiple, Smartlead rotates among them — improves deliverability and increases your daily ceiling.sequencescan have just one step (initial pitch) or many (follow-ups).delayDaysis days to wait after the previous step before sending. The first step should always be0.- Smartlead’s follow-up logic: a step only sends if the recipient hasn’t replied to a previous step.
Body files: .md or .html
Section titled “Body files: .md or .html”Body files can be Markdown (.md / .markdown) or HTML (.html / .htm). The system auto-detects from the file extension and converts Markdown → HTML at send time.
Recommended workflow: write pitches in Google Docs → File → Download → Markdown (.md) → save next to your config in campaigns/. Cleaner source than Google’s HTML export and renders identically once converted.
Markdown features that work:
- Hyperlinks:
[Designing the District](https://designingthedistrict.com) - Bold:
**outlet name**→<strong>outlet name</strong> - Italic:
*emphasis* - Lists, headings, blockquotes (basic Markdown)
- Inline raw HTML if you need a specific flourish
Preview what gets sent:
make preview-body BODY=campaigns/2026-04-florida-food.mdMail-merge variables in subject + body
Section titled “Mail-merge variables in subject + body”Smartlead Handlebars-style — works the same whether body is .md or .html. Tokens pass through the Markdown converter untouched.
{{first_name}},{{last_name}}{{company_name}}(mapped fromoutlet){{outlet}},{{role}},{{beat}},{{segment}}(custom fields)
Example: Hi {{first_name}}, we thought {{outlet}}'s readers would love this.
Conditionals: {{#if first_name}}{{first_name}}, {{else}}{{/if}} for graceful fallback.
Allowed values
Section titled “Allowed values”Segment (pick zero or more):
Editorial, TV, Radio, Online, Podcast, Influencer, Photography, Newsletter, Other
Beat:
Food, Wine, Travel, VIP, Entertainment, Lifestyle, Art, Interior Design, Real Estate, Business, Events, Health, Fashion, Photography, LGBT, Deals, Weddings, Other, plus any value matching Holidays: <Day> (e.g. Holidays: Mother's Day).
Markets — full names (Florida, Georgia, Canada) or cities (Miami, Toronto, London). Filtering Florida auto-includes Miami/Tampa/Orlando contacts. Filtering a city auto-includes its state too. With "includeParents": true, also includes USA-tagged catch-all contacts.
Sender mailboxes (must already be in Smartlead):
design@designingthedistrict.com— “Get Chic Done”timur@designingthedistrict.com— “Timur Tugberk”create@designingthedistrict.com— “Get Shit Done”
Other operations
Section titled “Other operations”Importing a CSV
Section titled “Importing a CSV”make import CSV=contact-lists/master-list-2026-NEW.csv DRY=1 # previewmake import CSV=contact-lists/master-list-2026-NEW.csv # for realPushing new contacts to Attio
Section titled “Pushing new contacts to Attio”make sync-initial # only contacts not yet linked (fast — usually <1 min)make sync-initial FULL=1 # re-push everything (slow — ~20-30 min)Deduping near-duplicates
Section titled “Deduping near-duplicates”make dedupe DRY=1 # preview SAFE + SUSPICIOUS pairsmake dedupe # merge SAFE onlymake dedupe SUSPICIOUS=1 # also merge SUSPICIOUS pairs# Excluding specific emails uses the underlying npm command:# npm run dedupe:contacts -- --include-suspicious --exclude=foo@bar.com,baz@bar.comCleaning up after a test campaign
Section titled “Cleaning up after a test campaign”make cleanup-campaign ID=3243472 # removes from Smartlead, Supabase, AttioListing campaigns (find a smartlead id)
Section titled “Listing campaigns (find a smartlead id)”make campaigns # 30 most recentmake campaigns LIMIT=50 # moremake campaigns CLIENT=Palace # filter by name substringShows: smartlead_id, status, sent/reply/bounce counts, created date, name. Drawn from Supabase (campaigns we created via this CRM). For Smartlead-only campaigns from before this CRM existed, look in the Smartlead UI.
Adding a follow-up to a campaign that’s already running
Section titled “Adding a follow-up to a campaign that’s already running”Two modes — pick whichever feels natural.
Mode A — by config (Recommended for campaigns you created via the CLI):
Edit the campaign’s JSON config to add a new step in the sequences[] array. Then:
make add-followup FROM=campaigns/palace-may-influencer-pitch.json DRY=1 # previewmake add-followup FROM=campaigns/palace-may-influencer-pitch.json # applyThe command looks up the Smartlead campaign id by matching the config’s name field in Supabase, fetches the existing Smartlead sequences, and appends only the new step(s) (anything in the config beyond what Smartlead already has). The original config is now the single source of truth — you can re-run any time to keep them in sync.
Mode B — by id + flags (for one-off follow-ups or campaigns not in Supabase):
make add-followup \ ID=3243626 \ SUBJECT="Re: original subject" \ BODY=campaigns/palace-may-influencer-pitch-followup-1.md \ DELAY=3 \ DRY=1 # preview the proposed sequence firstRun make campaigns to find the smartlead id, or copy from the Smartlead UI URL.
Behavior in either mode: existing steps stay untouched. Already-sent recipients won’t be re-pitched on prior steps. The new step queues and fires DELAY days after the previous step only if the recipient hasn’t replied yet — Smartlead handles that logic automatically.
Renaming campaigns to standard “Palace:” / “Planta:” prefix
Section titled “Renaming campaigns to standard “Palace:” / “Planta:” prefix”make standardize-campaigns DRY=1 # previewmake standardize-campaigns # applyTo support more clients, edit the PREFIXES array at the top of scripts/standardize-campaign-names.ts.
Triggering crons manually
Section titled “Triggering crons manually”make reconcile # nightly Attio→Supabase contact reconcile (auto at 08:00 UTC)make unsnooze # daily snoozed→active flip (auto at 07:00 UTC)Syncing campaign status + stats from Smartlead
Section titled “Syncing campaign status + stats from Smartlead”The Smartlead webhook updates contacts in real-time (last_contacted_at, bounce_count, etc.) but doesn’t update campaign-level state. When you click Start in the Smartlead UI, the campaign moves to “sending” there but Supabase / Attio still show “draft” until you sync.
make sync-campaigns DRY=1 # preview driftmake sync-campaigns # apply (status + sent/open/reply/bounce counts)make sync-campaigns ID=3243626 # just one campaignRun this any time you want fresh stats in Attio, or after starting/pausing/completing a campaign in the Smartlead UI. Orphaned Supabase campaigns (Smartlead campaign was deleted) get flagged with the cleanup command to run.
Recovering from a bad Attio merge
Section titled “Recovering from a bad Attio merge”If two unrelated humans got merged into one Attio Person:
make unmerge RECORD=<attio_record_id>make sync-initial # recreates them as separate PersonsRe-linking Campaign members in Attio
Section titled “Re-linking Campaign members in Attio”If an Attio Campaign record’s member list is empty (or got out of sync), repopulate from Supabase:
make backfill-members ID=<smartlead_campaign_id>For campaigns with >1k members, this falls back to per-Person PATCHes (slower but reliable — Attio’s bidirectional sync chokes on >1k refs in one PATCH).
Re-registering the Attio webhook (e.g. after URL changes)
Section titled “Re-registering the Attio webhook (e.g. after URL changes)”make register-webhookDeploying changes
Section titled “Deploying changes”git push # if GitHub is connected to Vercel, auto-deploysmake deploy # manual deploymake typecheck # tsc --noEmit (run before deploy)Inspecting state
Section titled “Inspecting state”| Question | Command |
|---|---|
| How many contacts? | supabase db query --linked "select count(*) from contacts;" |
| How many in a filter? | supabase db query --linked "select count(*) from filter_contacts(ARRAY['Florida'], false, ARRAY['Editorial'], ARRAY['Food'], true);" |
| Segment breakdown | supabase db query --linked "select segment, count(*) from contacts group by segment order by count(*) desc;" |
| Beat breakdown | supabase db query --linked "select beat, count(*) from contacts group by beat order by count(*) desc;" |
| Recent campaigns | supabase db query --linked "select smartlead_campaign_id, name, status, created_at from campaigns order by created_at desc limit 10;" |
| Inspect Attio Person | npx tsx scripts/probe-attio-record.ts <attio_record_id> |
| List Attio webhooks | make probe-webhook-status |
Smartlead schedule defaults
Section titled “Smartlead schedule defaults”Every new campaign uses:
- Timezone: America/New_York
- Days: Monday-Friday
- Hours: 08:00-22:00 (in the timezone above)
- Min spacing: 3 minutes between sends per mailbox
- Max new leads/day: 2,000
Change in src/lib/campaign.ts (setCampaignSchedule(...)).
Start time: campaigns don’t pass schedule_start_time, so Smartlead begins sending the moment you flip status to START (within the daily window). Click Start at 3pm and the first emails go out within minutes — no future-date scheduling.
Cron jobs (already running)
Section titled “Cron jobs (already running)”| Path | Schedule (UTC) | What it does |
|---|---|---|
/api/cron/unsnooze | 0 7 * * * (07:00) | Flips snoozed contacts back to active once snoozed_until has passed |
/api/cron/reconcile | 0 8 * * * (08:00) | Pages every Attio Person → writes through to Supabase (safety net) |
When something goes wrong
Section titled “When something goes wrong”- Webhook not firing:
make probe-webhook-statusto confirm it’s still active.make register-webhookto re-register the Attio one. - Vercel function failed: check logs at https://vercel.com/juanezamudios-projects/pr-crm
- Supabase out of sync with Attio:
make reconcile(manual trigger) - Need to wipe a test campaign:
make cleanup-campaign ID=<smartlead_id> - Bad Attio merge:
make unmerge RECORD=<attio_id>thenmake sync-initial