Skip to content

PR CRM — Operator Runbook

Day-to-day cheatsheet. Copy-paste recipes for everything you’ll do regularly.

Where things live

Discoverability: just run make (or make help) for the full list of targets with descriptions.


Three flavors — pick whichever feels right in the moment.

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.

Terminal window
cp campaigns/example-florida-food.json campaigns/2026-04-florida-food.json
cp campaigns/example-florida-food.html campaigns/2026-04-florida-food.html
cp 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/filter

Run it:

Terminal window
make campaign-from FROM=campaigns/2026-04-florida-food.json

That creates the campaign in Smartlead DRAFT so you can review.

GoalCommand
Launch immediatelymake campaign-from FROM=campaigns/x.json START=1
Preview only, no writesmake 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)”
Terminal window
make campaign-new

Walks 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)”
Terminal window
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-run

Add --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.


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:

  • from can be a string (single mailbox) or array (multiple). When multiple, Smartlead rotates among them — improves deliverability and increases your daily ceiling.
  • sequences can have just one step (initial pitch) or many (follow-ups). delayDays is days to wait after the previous step before sending. The first step should always be 0.
  • Smartlead’s follow-up logic: a step only sends if the recipient hasn’t replied to a previous step.

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:

Terminal window
make preview-body BODY=campaigns/2026-04-florida-food.md

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 from outlet)
  • {{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.

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”

Terminal window
make import CSV=contact-lists/master-list-2026-NEW.csv DRY=1 # preview
make import CSV=contact-lists/master-list-2026-NEW.csv # for real
Terminal window
make sync-initial # only contacts not yet linked (fast — usually <1 min)
make sync-initial FULL=1 # re-push everything (slow — ~20-30 min)
Terminal window
make dedupe DRY=1 # preview SAFE + SUSPICIOUS pairs
make dedupe # merge SAFE only
make 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.com
Terminal window
make cleanup-campaign ID=3243472 # removes from Smartlead, Supabase, Attio
Terminal window
make campaigns # 30 most recent
make campaigns LIMIT=50 # more
make campaigns CLIENT=Palace # filter by name substring

Shows: 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:

Terminal window
make add-followup FROM=campaigns/palace-may-influencer-pitch.json DRY=1 # preview
make add-followup FROM=campaigns/palace-may-influencer-pitch.json # apply

The 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):

Terminal window
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 first

Run 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”
Terminal window
make standardize-campaigns DRY=1 # preview
make standardize-campaigns # apply

To support more clients, edit the PREFIXES array at the top of scripts/standardize-campaign-names.ts.

Terminal window
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.

Terminal window
make sync-campaigns DRY=1 # preview drift
make sync-campaigns # apply (status + sent/open/reply/bounce counts)
make sync-campaigns ID=3243626 # just one campaign

Run 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.

If two unrelated humans got merged into one Attio Person:

Terminal window
make unmerge RECORD=<attio_record_id>
make sync-initial # recreates them as separate Persons

If an Attio Campaign record’s member list is empty (or got out of sync), repopulate from Supabase:

Terminal window
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)”
Terminal window
make register-webhook
Terminal window
git push # if GitHub is connected to Vercel, auto-deploys
make deploy # manual deploy
make typecheck # tsc --noEmit (run before deploy)

QuestionCommand
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 breakdownsupabase db query --linked "select segment, count(*) from contacts group by segment order by count(*) desc;"
Beat breakdownsupabase db query --linked "select beat, count(*) from contacts group by beat order by count(*) desc;"
Recent campaignssupabase db query --linked "select smartlead_campaign_id, name, status, created_at from campaigns order by created_at desc limit 10;"
Inspect Attio Personnpx tsx scripts/probe-attio-record.ts <attio_record_id>
List Attio webhooksmake probe-webhook-status

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.


PathSchedule (UTC)What it does
/api/cron/unsnooze0 7 * * * (07:00)Flips snoozed contacts back to active once snoozed_until has passed
/api/cron/reconcile0 8 * * * (08:00)Pages every Attio Person → writes through to Supabase (safety net)

  • Webhook not firing: make probe-webhook-status to confirm it’s still active. make register-webhook to 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> then make sync-initial