Operator guide
One console, multiple ventures. Campaigns are the unit of everything: each row carries its own sequence, reply framing, and channel config (email / calls / postcards / mail templates). Shared code stays vertical-neutral.
Where things run
outreach.castellan.so = the daily console (this app on Vercel): queues, conversations, campaigns, recovery. r.castellan.so serves click-tracking redirects.
Scheduling: a launchd agent on the Mac Mini fires the two 15-minute crons (send-approved, poll-replies) — GitHub Actions is only a backup tick (its 15-min schedule really fires every 1-2h) plus the daily jobs (listing refresh, sent-folder import, call sheet).
localhost:3000= the same app plus vault-backed extras (Dossiers, PMCs, Coverage, pipeline buttons) that need the discovery-vault filesystem on disk. Those sections simply don't appear on the hosted deploy.
Core concepts
- Campaign — one motion in one vertical (e.g.
property-tax-owner-gwinnett-ga). Owns its sequence steps (T1…Tn with delays), sender mailbox, reply framing (reply_context), deadline copy (deadline_at→{{days_left}}), andchannel_config— which channels are on and how (postcard template + county copy, call queue, call-sheet digest, fulfillment panel). - Prospect — a person/entity inside one campaign. Statuses:
queued → active → replied / bounced / paused / completed / excluded. Terminal statuses stop the sequence. - Send — one email at one step:
queued → approved → sending → sent, orfailed/skipped. T1 is reviewed by hand; T2+ auto-schedule after each send with jitter. - Suppression list — global, cross-campaign do-not-email. Bounces, unsubscribe replies, and manual suppressions land here; every send path checks it.
The daily loop
- Home— the "Next" card ranks what needs you (failed sends → unclassified replies → stale drafts → queue review). The send agenda shows what fires per day.
- Email queue (/queue) — review drafts: Approve (next business morning 8:27 PT), Schedule…, Edit, Skip, Send now (two-click confirm), or Send without verifying after a separate recipient warning. Follow-up drafts show prior touches inline ("T1 sent Jul 2 · 1 click · no reply yet"). Tabs: Queued / Needs review (deliverability or buying-committee reach) / Approved / Failed (re-queue or dismiss). Actions update the list instantly.
- Conversations (/conversations) — every prospect with real activity, threaded. Classify replies (the dropdown cascades to prospect status), send drafted responses, and use the header actions: Resume a paused sequence, Suppress / Unsuppress globally, recovery for bounces.
- Channel queues — Calls, Postcards, LinkedIn, for campaigns that enable them (see §3).
⚠ Drafts queued >48h show a stale banner — re-verify details before approving. Launch review groups multiple contacts at the same company: use varied 10–30 minute gaps, send them at the same time, choose who goes first, or edit individual wording. The choice carries into follow-ups, and a company reply can pause the remaining contacts.
Channels are campaign config, not code
campaigns.channel_config (JSONB) decides what each campaign can do. Adding a county or vertical means inserting a campaign row with the right config — no code changes.
postcard— enables mail; picks the template (lb_owner,property_tax,property_tax_county+ county name/deadline copy). Test mode returns a preview URL; live mail is gated behind typed confirmation, one card at a time.call_queue— campaign appears in Calls alongside its email cadence.call_digest— prospects with a T1 ~2 days old and no reply get compiled into the weekday call-sheet email, with the campaign's angle line + micro-script.fulfillment— shows the appeals panel on the campaign page (property-tax signing/filing pipeline).prerendered_templates— protects hand-curated send bodies from bulk template refresh.
Safety rails (what stops a bad send)
- Archived campaign = the off-switch. Nothing sends from an archived campaign, ever.
- DeBounce verification gates every approval and normal Send now. If you independently know an address is real, Send without verifying skips the paid check after a separate warning. Suppression and reply protections still apply.
- Global suppression list — checked at approve, at claim, and again right before the Gmail call.
- Daily limit— visible and editable from the campaign's email queue. It applies per sender mailbox; excess approved emails wait for the next PT day automatically. Until a mailbox limit is saved,
DAILY_SEND_CAP(default 40) is the fallback. - Reply race guard — a reply landing between claim and send cancels the outgoing follow-up.
- Company reply pause — when enabled in launch review, a reply from one selected contact skips pending emails and follow-ups to the other contacts at that company.
- CAN-SPAM footer — opt-in (
OUTREACH_FOOTER=on+ postal address); appends an address line and reply-to-opt-out sentence to every sequence send.
When something breaks
- Failed sends land in the Failed tab with the error; Re-queue or Dismiss. Transient Gmail errors already retried twice automatically. Errors starting with
stale:mean the function died mid-send — check Gmail Sent before re-queueing. - Telegram alerts fire on send failures, a revoked Gmail token (re-run
npm run gmail:auth), and reaped stale rows. - Cron health:
/api/health?details=1shows last success per job; tick logs live on the Mac Mini (~/outreach-cron/tick.stdout.log). - Everything else — deploys, env vars, secret rotation, recovery SQL — lives in
web/RUNBOOK.md.
Vertical status + per-vertical notes
- Property tax (GA) — the active motion. Gwinnett live (email + calls + postcards); Fulton, DeKalb, Cobb archived. Appeals fulfillment on the campaign page; deadlines at /tax-deadlines.
- PM / leasing (Castellan) — paused 2026-07-06. Its top-of-funnel lives in the discovery-vault pipeline (see appendix); the
lb-ownercampaign and vault pages stay ready for revival. - Medicaid LTC — kill test concluded; campaigns archived. The call-digest machinery it used is now campaign config any vertical can enable.
Appendix: the PM vault pipeline (paused)
The PM motion generates prospects from the discovery-vault: scrape Zillow listings per PMC (scrape:zillow), detect tech platforms, skip-trace owners (BatchData), then build:dossiers → draft:owner-emails to queue T1s. All local-only (vault on disk); the Dossiers / PMCs / Coverage pages and home pipeline buttons appear only in local dev. When a reply becomes a real conversation, graduate the lead to discovery-vault/companies/ and update pipeline.md — the vault, not this DB, is the CRM.
Known gaps + things to watch
- Listing freshness drift (PM motion). T1 drafts snapshot listing details at draft time; the stale-draft banner + manual re-check is the safety net. No auto re-check pre-send.
- Reply classification is two-stage. The cron only keyword-classifies (bounces, obvious unsubscribes); the rest wait for the local LLM classifier or manual triage in Conversations.
- Domain reputation.
castellan.sois still young; SPF/DKIM/DMARC configured, DMARC atp=none. The daily cap is the main throttle — keep an eye on inbox placement when scaling a new vertical. - US holidays not skipped. Scheduling skips weekends only — push out sends that land on a holiday.
External tools (rare use)
- Vercel deploy logs / env vars: Vercel dashboard. Push to
mainauto-deploys. - Cron run history: Mac Mini tick logs (
ssh clawdiabot 'tail -5 ~/outreach-cron/tick.stdout.log') + GitHub Actions for the daily jobs and backup tick. - DNS / DMARC / SPF / DKIM: Namecheap DNS; check
dmarc@castellan.sofor aggregate reports. - Gmail send/auth:
stephen@castellan.sovia Google Workspace. Refresh token inweb/.env.local+ Vercel env; per-user sender mailboxes vianpm run gmail:store-token. - Rotate
CRON_SECRET: FOUR places — Vercel env, GitHub Actions secret, localweb/.env.local, and the Mac Mini's~/outreach-cron/.env. Ask Claude Code; it knows the sequence.