Email sequence workflow

Envoy durable outreach for one or many people, with fresh context, approvals, reply waits, follow-ups and Nylas transport.

1 min read Updated Sep 2, 2026

Email sequence workflow

Envoy runs durable outreach for one person or a bounded list of people. It uses the common Agentic Platform and adds email-domain state, not new execution infrastructure.

Email sequence execution is Phase 8. Phase 7 stops at Company, People and Signals capability results. A selected person enters a sequence through a separate explicit action; Search or Enrich does not send email.

Goals

  • create a sequence for one person or a bounded people set
  • build fresh context before each draft
  • draft initial messages, replies and follow-ups
  • gate sends through Policy/approval when required
  • wait durably for a reply or follow-up deadline
  • resume from Nylas inbound mail
  • stream product progress without exposing Inngest internals

End-to-end flow

TEXT
people selected / Signals Search handoff
  -> Email Sequence Workflow
  -> create person sequence Run(s)
  -> resolve current person/company state
  -> build fresh context
  -> draft message
  -> Tool Invoker + Policy on email.send
       deny | allow | require_approval
  -> send with Nylas when allowed
  -> durable wait: reply event OR follow-up timeout
       reply   -> rebuild context -> draft reply
       timeout -> rebuild context -> draft follow-up
  -> policy / approval / send
  -> repeat until complete / cancelled / failed

Run model

A Run is generic execution state. Email sequence state is product/domain state.

TEXT
Batch Sequence Run (only when needed)
  |- Person A Sequence Run
  |- Person B Sequence Run
  `- Person N Sequence Run

Use a person-level child Run when that person can wait, fail or be cancelled independently. Internal work stays as spans.

TEXT
Person Sequence Run
  |- context.build          span
  |- email.draft            agent span
  |- approval               approval span
  |- email.send             tool span
  |- wait                   wait span + Inngest wait
  `- follow_up.draft        agent span

For one person, that person Run can be the top-level Run.

Generic Run state stays:

TEXT
queued | running | waiting | succeeded | failed | cancelled

Email-domain stage is separate: preparing, drafting, awaiting approval, sent, waiting for reply, drafting follow-up or reply, complete.

The overlapping values are derived, never written twice. awaiting approval and waiting for reply are the same fact the Run already records as status = waiting with waiting_on = approval or event. Storing that fact in two places is the failure the platform rejected a phase enum to avoid: two writers, and a UI that must decide which one is lying.

TEXT
written   preparing · drafting · sent · drafting follow_up · complete    email domain facts
derived   awaiting approval · waiting for reply                          read from the Run

So email_sequences.status holds the outreach fact, and the two waiting values are computed from the Run when the list renders. One writer for each fact.

Waiting for reply

A sequence is bounded at 30 days, because a Run is. Validation caps max_run_duration at 30 days, since the tool journal is retained for exactly that long and a Run that outlived its journal would repeat an effect on the next resume. A sequence that reaches the ceiling succeeds with partial_reason=limit_reached: the messages already sent stay sent, and the ones still queued never go. Nothing fails and nothing raises, so a six week nurture cannot be one Run. Split it, or shorten the waits.

After send, model the race as one durable wait with timeout, not a sleep that later needs cancellation.

That race is one wait node, and runtime definitions owns its shape.

TEXT
mode        event
event_type  email.reply_received
match       thread_id: steps.send.output.thread_id
timeout_s   the configured follow-up delay, in seconds
on_timeout  continue

If the reply event arrives first, the same workflow Run resumes. If the timeout wins, the workflow drafts the next follow-up.

The correlation lives on the node, so nothing indexes which Runs wait for which thread.

⚠️ A reply that lands before the wait registers is dropped, and the follow-up goes out. The wait registers after the send step commits, and an event that arrives in that gap reaches no waiter. on_timeout = continue is what keeps that cheap: the sequence sends a follow-up to a person who already answered, which is a poor message and not a stalled Run. The reply itself is not lost — it is persisted by the Nylas boundary below, and the next node reads the thread through a tool.

One person, one Run. A batch is a parallel of person-level child Runs, and each child owns its own wait. That is not only a failure-isolation choice: a parallel node holds a wait in at most one child subtree, because two concurrent waits in one Run wake it early. Runtime definitions owns that rule.

Nylas inbound boundary

TEXT
Nylas webhook
  -> verify provider transport
  -> dedupe stable provider event/message id
  -> persist message/thread in email domain
  -> emit normalized email.reply_received / email.received event
  -> EventRouter
       |- platform/email.reply_received -> the waiting sequence Run resumes
       `- TriggerMatcher -> a configured Trigger may start new work

EventRouter is what makes the resume real. An Inngest wait resolves only when an Inngest event arrives, so one component sends it and calls the matcher. See triggers.

This does not make email a Channel Gateway channel. Nylas is the email integration/producer.

Context

Build context when each message is drafted. Do not generate an entire sequence from one stale brief.

Initial context can include:

  • person/company CRM state
  • relevant intelligence/signals
  • relationship/activity history
  • sequence goal/offer
  • account writing preferences
  • published organization/product Knowledge

Reply/follow-up context additionally includes:

  • messages already sent
  • replies received
  • current thread state
  • relevant CRM/signal changes since the previous message

The deterministic Context Builder owns packing/precedence.

Approval

Approval is a decision inside the same Run, not a Run type.

TEXT
email.send proposed
  -> PolicyEngine
  -> require_approval
  -> agent.approvals row
  -> RunManager.mark_waiting(run_id, waiting_on=approval, ref_id=approval_id)
  -> Inngest wait
  -> human decision
  -> verify TTL + argument hash + authority
  -> signal wait
  -> same Run resumes

One Run may have multiple approvals over the life of a sequence.

Approval authorizes an effect; Idempotency prevents an authorized effect from happening twice.

Domain state

Keep email state outside the generic Run table.

TEXT
email_sequences
  id
  org_id
  person_ref        a ResourceRef: crm.person, or prospect.person before promotion
  mailbox_id
  run_id
  thread_id
  status
  current_step
  next_action_at
  started_at
  completed_at

email_sequence_messages
  id
  sequence_id
  type              initial | follow_up | reply
  status            draft | awaiting_approval | scheduled | sent | failed
  subject
  body
  nylas_message_id
  sent_at

The Run says how execution is progressing. The email rows say what is happening in the outreach relationship.

person_ref is a ResourceRef, not a CRM id. Signals Search hands selected people to outreach before promotion, so a sequence can target public.prospect_people through prospect.person. A plain person_id column would force a premature CRM write to satisfy a foreign key, which the discovery boundary prevents.

When that person is later promoted, the ref is repointed to the canonical crm.person. The sequence does not restart, and its sent messages keep their history.

Nylas boundary

Nylas owns:

  • mailbox connection/provider OAuth
  • send/reply transport
  • provider webhooks
  • provider message/thread identifiers

AgencyCore owns:

  • CRM/context/intelligence
  • drafting and reasoning
  • sequence/domain state
  • Run/workflow state
  • policy/approvals
  • reply/follow-up decisions

Agents reach Nylas only through shared email Tools such as email.send / email.reply.

UI and realtime

The product reads domain state + Runs, not Inngest internals.

TEXT
Alice Smith   Waiting for reply   Follow-up Aug 17
Bob Jones     Needs approval      Review draft
Cara Lee      Drafting reply      Reply received

Transient semantic events may include sequence started, draft ready, approval requested, email sent, waiting for reply, reply received and sequence completed. Durable truth stays in Runs, spans, approvals and email-domain rows.

Main failure cases

FailureHandling
Nylas webhook delivered twicededupe on stable provider id
email.send retriedshared Idempotency Service + vendor idempotency when available
Reply arrives before timeoutevent wins the durable wait
Approval expiresnever treat silence as consent
Draft/target changes while approval waitshash/state check invalidates stale approval
One person fails in batchfail that independent child Run; others continue
Mailbox disconnectsblock sequence and surface reconnect action
Reply cannot be matched safelypersist it and route through normal inbound handling; do not guess
Worker restarts during approvalpersisted continuation + Inngest wait resumes correctly

V1 rules

  • one generic Run model
  • person child Run only for an independent lifecycle
  • internal work is spans by default
  • draft the next message when needed, not the entire sequence in advance
  • one reply wait with timeout, declared on the wait node
  • the waiting stages are derived from the Run, never written twice
  • person_ref may point at an unpromoted person; outreach forces no CRM write
  • Policy controls sends; approval uses agent.approvals
  • shared Idempotency protects send/reply effects
  • auto-draft replies are acceptable; do not auto-send replies in V1
  • Nylas is transport, not agent/workflow state
  • email does not pass through Channel Gateway