GenieOSdocs

Sequences

Lifecycle email as a small, declarative state machine. Enroll once, transition by event, observe by webhook.

A sequence is a lifecycle email programme expressed as states + edges. You define it once in the dashboard (or via the API); your code enrols contacts and emits events; GenieOS walks the graph and sends the right template at the right time.

The model fits a wide range of programmes:

  • Onboarding (signup \u2192 verified \u2192 first_action \u2192 retained).
  • Trial (trial_started \u2192 day_3_nudge \u2192 day_10_offer \u2192 expired).
  • Re-engagement (gone_quiet \u2192 reach_out \u2192 we_miss_you \u2192 archived).
  • Course / cohort drip.

Sequences are explicitly not a marketing automation builder with 200 node types — that path leads to mystery boxes nobody can debug. Four primitives, all auditable from the API.

The four node types

NodeBehaviour
stateHolding pen. Contact sits here until an event or wait expires.
waitPure delay. After duration, transitions to then.
sendEnqueues a transactional send, then transitions on a webhook.
branchBoolean split. Evaluates condition against the contact + event.

Edges are triggered by events you emit (product.viewed, subscription.cancelled) or by GenieOS signals (message.opened).

Defining a sequence

// sequences/onboarding.json
{
  "name": "onboarding",
  "version": 2,
  "entry": "signup",
  "nodes": {
    "signup":    { "type": "send",  "template": "welcome",            "then": "wait_24h" },
    "wait_24h":  { "type": "wait",  "duration": "24h",                 "then": "first_action_check" },
    "first_action_check": {
      "type": "branch",
      "condition": "events.user_completed_first_action.exists",
      "true":  "retained",
      "false": "nudge"
    },
    "nudge":     { "type": "send",  "template": "first_action_nudge", "then": "wait_72h" },
    "wait_72h":  { "type": "wait",  "duration": "72h",                 "then": "give_up" },
    "give_up":   { "type": "state", "terminal": true },
    "retained":  { "type": "state", "terminal": true }
  }
}

Push it via the CLI or the dashboard:

genie sequences push ./sequences/onboarding.json

condition is a small, sandboxed expression language with three sources:

  • contact.* — fields on the enrolled contact.
  • events.<type>.* — most recent matching event for the contact.
  • enroll.* — variables passed at enrolment.

No I/O. No loops. No regex. If you need that, do the work in your service and emit a derived event.

Enrolment

Enrol a contact when something irreversible happens — they signed up, their trial started, they cancelled.

await mg.sequences.enroll(
  'onboarding',
  {
    contact: { email: 'ada@example.com', external_id: 'usr_123' },
    variables: { plan: 'pro' },
  },
  { idempotencyKey: 'usr_123:onboarding' },
);

Idempotency on enrolment is strongly recommended. Many real systems emit "user signed up" twice (once from the API, once from a webhook); the key keeps a single enrolment.

Transitions by event

Most edges are driven by events you emit. The same event also serves as the input to branch conditions.

await mg.events.emit({
  type: 'user_completed_first_action',
  contact: { external_id: 'usr_123' },
  metadata: { action: 'created_first_template' },
});

This event:

  • Wakes the first_action_check branch (if the contact is parked there).
  • Becomes available to subsequent branches as events.user_completed_first_action.*.
  • Lands in the audit log.

Observing transitions

Subscribe sequence.* events on a webhook to mirror state into your data warehouse, dashboards, or in-app UI.

{
  "id": "evt_01J...",
  "type": "sequence.transitioned",
  "data": {
    "sequence": "onboarding",
    "version": 2,
    "contact": { "external_id": "usr_123", "email": "ada@example.com" },
    "from": "first_action_check",
    "to": "retained",
    "trigger": {
      "kind": "branch",
      "condition_result": true,
      "matched_event": "evt_01JABC..."
    }
  }
}

Other shapes you\u2019ll see: sequence.entered, sequence.exited, sequence.send_skipped (suppression list), sequence.error.

Pausing, leaving, deleting

// Pause a contact in place. Wakes on resume.
await mg.sequences.pause('onboarding', { external_id: 'usr_123' });

// Resume from the same node.
await mg.sequences.resume('onboarding', { external_id: 'usr_123' });

// Yank them out entirely.
await mg.sequences.unenroll('onboarding', { external_id: 'usr_123' });

A contact can\u2019t be in the same sequence twice; re-enrolling restarts at entry.

Versioning

Editing a sequence increments version. Already-enrolled contacts keep their version; new enrolments use the latest. This means you can safely ship breaking sequence changes without disrupting in-flight contacts.

The sequence.versioned webhook fires on each publish; use it to automatically restart your CI tests of derived dashboards.

Why this is small on purpose

We resisted the urge to ship for-each, parallel, dynamic template rendering, sub-sequences, etc. They turn an auditable graph into a guessable script. If you need that level of orchestration, drive it from your service and treat sequences as a notification channel.

On this page