Email sequence workflow
Envoy durable outreach for one or many people, with fresh context, approvals, reply waits, follow-ups and Nylas transport.
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
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.
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.
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:
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.
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.
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
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.
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.
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.
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
| Failure | Handling |
|---|---|
| Nylas webhook delivered twice | dedupe on stable provider id |
email.send retried | shared Idempotency Service + vendor idempotency when available |
| Reply arrives before timeout | event wins the durable wait |
| Approval expires | never treat silence as consent |
| Draft/target changes while approval waits | hash/state check invalidates stale approval |
| One person fails in batch | fail that independent child Run; others continue |
| Mailbox disconnects | block sequence and surface reconnect action |
| Reply cannot be matched safely | persist it and route through normal inbound handling; do not guess |
| Worker restarts during approval | persisted 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_refmay 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