Guides.md

Pharos Automations

Multi-step email sequences fired by per-project triggers and timed by QStash. No cron, no daemon — when a contact enters an automation, each step's delay becomes a delayed QStash message that POSTs back to Pharos when its time comes.

Concepts

Automation — a per-project sequence of email steps with a single trigger that decides when a contact gets enrolled.

Step — one email send, with an optional skipIfTag condition. Steps run in order; each step's delayMinutes is the wait before it fires (relative to the previous step completing).

Run — one row per (contact, automation) enrollment. It tracks the current step pointer, when the next step fires, and a JSON history of every step event. Re-enrollment is only allowed once a previous run finishes (status sent or failed).

Triggers (v1)

Trigger Fires when
new_subscriber A contact lands on any list (/api/subscribe).
tag_added A tag is added to a contact (via PATCH or /api/contacts/event). Optional triggerConfig.tag filters to one tag; empty matches all.

schedule and no_open exist in the schema but aren't wired in v1.

Step shape

Field Notes
delayMinutes Wait before this step fires. 0 = immediate. Common: 0, 5, 60, 1440 (1d), 4320 (3d), 10080 (1w), 43200 (30d).
templateId One of welcome, newsletter, announcement. Picks the React Email shell.
subject / body English content. Body is plain text injected into the template.
subjectPt / bodyPt Optional Brazilian Portuguese variants. Contacts with language = pt-BR get this; others fall back to EN.
skipIfTag If the contact has this tag at processor time, skip the send (no email goes out) and move to the next step.

Lighting beacons: skipIfTag and /api/contacts/event

Pharos doesn't know what "paid" or "onboarded" means in your domain. It checks tags. External systems (Stripe webhooks, your app's backend, Linear, anything) light a beacon by tagging contacts via:

POST /api/contacts/event?secret=<WEBHOOK_SECRET>
{
  "projectId": "my-app",
  "email": "user@example.com",
  "tag": "paid",
  "action": "add"          // or "remove"; defaults to "add"
}

On add, Pharos also fires tag_added automations for that tag — so a single webhook call can both suppress an in-flight chain step (via skipIfTag) and enroll the contact in a different chain.

WEBHOOK_SECRET is shared with the SES webhook. If it is not set, the endpoint refuses every request with 503.

The secret belongs to whoever operates the deployment and only reaches that operator's own projects (the organizations in PHAROS_PLATFORM_SES_ORGS); any other workspace gets 403. From outside, light the same beacon with PATCH /api/contacts/:id and a project key — it fires tag_added the same way. See Automations API.

Editing while runs are in flight

  • Pharos stores currentStepId, not currentOrder. So if you reorder steps, in-flight contacts stay on whichever step they were waiting for.
  • If you delete the step a contact was waiting on, that run ends gracefully (no email, no error).
  • If you edit copy on a step that hasn't fired yet for some contacts, they get the new copy when their wait elapses. No snapshot.
  • Toggling Paused stops in-flight runs at the next processor tick; they get marked skipped.

Example: Welcome drip

Three-step linear drip after subscribe.

Trigger: new_subscriber

Step 1 — Immediately
  Template: welcome
  Subject: "Welcome to My App 👋"
  Body:    "Quick start: log your first meal..."

Step 2 — 1 day later
  Template: newsletter
  Subject: "Day 1 — what to track"
  Body:    "Three things that help on day one..."

Step 3 — 3 days later
  Template: announcement
  Subject: "Already love it? Tell a friend"

Bilingual: each step has subjectPt / bodyPt filled. Contacts with language=pt-BR get the Portuguese copy automatically.

Example: Payment nudge with skipIfTag

Subscribe → free trial. After 3 days, nudge if not paid. After 7 more, last call. As soon as they pay, both nudges stop firing.

Trigger: new_subscriber

Step 1 — Immediately
  welcome / "Welcome to your trial"

Step 2 — 3 days later
  newsletter / "How's it going so far?"
  skipIfTag: paid

Step 3 — 4 days later
  announcement / "Last chance to keep your data"
  skipIfTag: paid

Then on checkout success:

curl -X POST 'https://pharos.d2vsolutions.com/api/contacts/event?secret=...' \
  -H 'content-type: application/json' \
  -d '{"projectId":"my-app","email":"user@x.com","tag":"paid"}'

Step 2's processor (or step 3's, whenever next fires) sees the paid tag, marks itself skipped in the run history, schedules nothing. Done.

Example: Post-purchase onboarding

When someone is added with the paid tag (e.g. they bought right away), enroll them in a different sequence than the trial users.

Trigger: tag_added
Trigger config: { "tag": "paid" }

Step 1 — Immediately
  welcome / "Thanks for going pro!"

Step 2 — 1 day later
  newsletter / "Three pro features worth trying first"

Step 3 — 7 days later
  newsletter / "Save 20% on your annual upgrade"
  skipIfTag: pro_annual

Same /api/contacts/event call adds paid; Pharos sees the new tag and fires the matching automation. If they upgrade to annual, tag them pro_annual and step 3 quietly skips.

Example: Tag-based segment broadcast

Tag a contact vip from anywhere; Pharos sends them a one-step sequence that's effectively a personalized broadcast.

Trigger: tag_added
Trigger config: { "tag": "vip" }

Step 1 — Immediately
  announcement / "You're on the VIP list — early access opens Friday"

Run history

Every automation's edit page shows recent runs at the bottom: contact email, status, current step (for pending), expandable step trace with timestamps. Filter by status. Failed runs surface the error inline.

Operational notes

  • Failures: a step throws → the run is marked failed, the chain stops, no auto-retry. Check the run history to see which step blew up and why.
  • Disabled automation: when a step's processor runs and finds the automation disabled, it marks the run skipped and stops. Re-enabling doesn't resume in-flight runs (they stay skipped); new triggers create fresh enrollments.
  • Quotas: QStash free tier covers ~500 messages/day. Each step = one message. With 50 users entering a 3-step drip on the same day that's 150 messages. Plenty of headroom.
  • Contact deletion: deleting a contact does NOT cancel their in-flight runs. The processor will harmlessly fail to look them up next tick. Worth cleaning up later if it becomes noise.