ChanlChanl
Operations

Build a Nurture Agent That Decides Not to Send

Most nurture sequences are 14 emails on a calendar. The fix is an event-triggered agent whose most valuable action is wait. Here's the worker.

DGDean GroverCo-founderFollow
April 30, 2026
11 min read read
Quiet Morning Kitchen With a Phone Face Up on a Wooden Counter Showing a Single Calm Notification Next to a Coffee Cup in Soft Terra Cotta Light

A growth team we were talking to had a "nurture sequence" that was, in practice, fourteen emails sent to every lead on the same fourteen days. Open rate fell off a cliff after email three. Conversion was around 1.4%. The fix the team proposed was to add three more emails. That is the wrong fix. The right fix is to send fewer emails on better days, and to let an agent decide which lead gets which message and when.

The piece nobody builds is the part where the agent decides not to send anything.

Drip cadences fire on the calendar. Nurture should fire on behavior. The 2026 benchmarks show why: automated, behavior-triggered messages are roughly 2% of ecommerce sends but generate 37% of the revenue. The conversion delta between drip and nurture is driven entirely by the agent's willingness to wait, skip, and pick a channel. If you are going to build one of these, suppression has to be a feature of the loop, not a checkbox at the end.

The split between the two patterns is sharper than it looks on a slide.

DimensionDrip CampaignEvent-Triggered Nurture Agent
TriggerCalendar (day +1, +3, +7)Behavioral event from CDP
Decision unitPre-written sequenceOne tool call per event
"Wait" optionImplicit gap between sendsFirst-class action with reason
Channel choiceHard-coded per stepRubric over recency, intent, consent
SuppressionLast-mile filterCompliance check inside the loop
Default failure modeSends too manySends too few

That last row is the one teams miss. A drip campaign's failure mode is over-messaging; a nurture agent's failure mode is messaging too rarely. You design for the opposite hazard.

Here is the worker. We will wire a CDP webhook, give a model a menu of actions, make wait first-class, add a channel rubric, and gate every send behind a real compliance check. Then we will look at what Chanl handles that the general stack still leaves on you.

Step 1: Wire the Event Source

Most teams already have Segment, RudderStack, or PostHog firing events. The agent does not need new tracking. It needs a single subscriber that turns behavioral events into "evaluate this lead now."

A pricing-page bounce should land in a queue, not an email scheduler. The worker will pull the rest of the context and decide what to do.

webhook.ts·typescript
// Segment HTTP webhook destination -> our /events endpoint
import { Queue } from 'bullmq';
 
const nurtureQueue = new Queue('nurture-decisions', {
  connection: {
    host: process.env.REDIS_HOST!,
    port: Number(process.env.REDIS_PORT ?? 6379),
  },
});
 
export async function POST(req: Request) {
  const evt = await req.json();
  if (!['pricing_page_bounce', 'demo_request', 'docs_pageview'].includes(evt.event)) {
    return new Response('ignored', { status: 202 });
  }
  await nurtureQueue.add('decide', {
    leadId: evt.userId,
    signal: evt.event,
    seenAt: evt.timestamp,
  }, { jobId: `${evt.userId}:${evt.event}:${evt.timestamp}` });
  return new Response('queued', { status: 202 });
}

The jobId is the dedupe key. If Segment retries the same event, we do not enqueue twice. The worker is the next file.

Step 2: Give the Model a Menu, Not a Script

Most "AI nurture" tutorials hard-code a rule like "if pricing_page_bounce then email at +24h." That is not an agent. That is an if statement with a model writing the body. The point of using an LLM is to let the model see the lead's full timeline and pick from a menu of actions, including the option to do nothing.

The menu is a tool list with send_email, send_sms, send_chat, wait, skip, and escalate_to_human. The model can call exactly one. Whichever it calls is the entire output of this decision pass.

decide.ts·typescript
import { generateText, tool } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
 
export async function decide(lead: LeadContext) {
  const result = await generateText({
    model: openai('gpt-4.1'),
    system: nurturePolicy,
    prompt: formatTimeline(lead),
    toolChoice: 'required',
    tools: {
      send_email: tool({
        description: 'Send a branded email. Use for >24h-old signals or low-urgency follow-ups.',
        inputSchema: z.object({ angle: z.string(), priority: z.enum(['low', 'normal']) }),
      }),
      send_sms: tool({
        description: 'Send an SMS. Only for time-sensitive signals <2h old, only with prior consent.',
        inputSchema: z.object({ angle: z.string() }),
      }),
      send_chat: tool({
        description: 'Send an in-app chat message. Only when the lead has an active session.',
        inputSchema: z.object({ angle: z.string() }),
      }),
      wait: tool({
        description: 'Defer this lead. Use when no message would help right now.',
        inputSchema: z.object({ untilHours: z.number().min(1).max(168), reason: z.string() }),
      }),
      skip: tool({
        description: 'Decide that this lead does not need any follow-up for this signal.',
        inputSchema: z.object({ reason: z.string() }),
      }),
      escalate_to_human: tool({
        description: 'Hand to a rep. Use for high-value, ambiguous, or churn-risk leads.',
        inputSchema: z.object({ reason: z.string(), priority: z.enum(['low', 'high']) }),
      }),
    },
  });
  return result.toolCalls[0];
}

Notice what is missing. There is no "send a message" default. If the model is uncertain, it has wait and skip. The nurturePolicy system prompt is where you encode "we send no more than one message in 48 hours" and "VIP leads always escalate." The model picks one tool. The worker dispatches it.

Step 3: Why Wait Has to Be a First-Class Action

Most tutorials skip this. If wait is going to actually mean "do not message right now and reconsider in N hours," the worker has to put the lead back in the queue with a delay and a marker so the next pass has more context than this one.

dispatch.ts·typescript
async function dispatch(lead: LeadContext, action: ToolCall) {
  if (action.toolName === 'wait') {
    await nurtureQueue.add('decide', {
      leadId: lead.id,
      signal: 'reconsider',
      previousReason: action.input.reason,
    }, { delay: action.input.untilHours * 60 * 60 * 1000 });
    return;
  }
  if (action.toolName === 'skip') {
    await markSignalConsumed(lead.id, lead.lastSignal, action.input.reason);
    return;
  }
  // send_email / send_sms / send_chat / escalate_to_human follow below.
}

wait is durable because BullMQ persists the delayed job. If the worker crashes, the job survives. The lead does not get lost in memory and silently dropped. This is the smallest version of a real agent scheduler. We will note where the SDK could replace this in a minute.

Step 4: How Should the Agent Pick a Channel?

The model can technically pick send_sms whenever it wants, but you want guardrails the model cannot accidentally violate. The cleanest place to put them is as a pre-flight check on the tool inputs. The model says "send_sms," the dispatcher checks the rubric, and if the rubric says no, it downgrades to email or to wait.

rubric.ts·typescript
function pickChannel(lead: LeadContext, modelChoice: ToolName): ToolName {
  const ageHours = hoursSince(lead.lastSignal.seenAt);
  const isHighIntent = ['demo_request', 'pricing_page_bounce'].includes(lead.lastSignal.event);
  const inSession = lead.activeSessionEndsAt && lead.activeSessionEndsAt > Date.now();
 
  if (modelChoice === 'send_chat' && !inSession) return 'send_email';
  if (modelChoice === 'send_sms' && (ageHours > 2 || !isHighIntent || !lead.smsConsent)) {
    return 'send_email';
  }
  return modelChoice;
}

This is two-stage decisioning, not redundant logic. The model picks the intent of the message ("high-urgency, time-sensitive nudge"). The rubric enforces the channel rules ("you cannot SMS someone who is not opted in"). Same separation of concerns that keeps the model from arguing with TCPA.

Step 5: Suppression Is the Compliance Check

Now the part that quietly kills nurture programs. CAN-SPAM has been the law since 2003 and got more expensive last year. The FTC raised the maximum civil penalty for CAN-SPAM violations to $53,088 per email effective January 17, 2025. TCPA is per-message: $500 to $1,500 for SMS violations, with class actions stacking the count. GDPR right to erasure means a deleted contact must stay deleted, but the only way to enforce that is to keep them on a suppression list you check before every send.

So the dispatcher's last step before any send is suppression.

suppression.ts·typescript
async function isSuppressed(leadId: string, channel: 'email' | 'sms' | 'chat'): Promise<boolean> {
  const lead = await db.leads.findOne({ id: leadId });
  if (!lead) return true; // no record = do not send
  if (channel === 'sms' && !lead.smsConsent?.expressWritten) return true;
  if (channel === 'email' && lead.unsubscribedAt) return true;
  if (lead.gdprErasureAt) {
    const hash = await sha256(lead.email ?? lead.phone ?? '');
    if (await db.suppression.has(hash)) return true;
  }
  if (await db.complaints.recentBounce(leadId, channel)) return true;
  return false;
}

The Fifth Circuit narrowed the TCPA "prior express written consent" rule in Bradford v. Sovereign Pest Control in February 2026, but only inside Texas, Louisiana, and Mississippi. Federally, the FCC rule still applies, and most ESPs still require the written consent record. Treat the stricter rule as the default unless your legal team explicitly carves out the Fifth Circuit.

The other thing the suppression layer protects you from is GDPR. The Irish DPC's guidance on Articles 17 and 19 is unambiguous: when a contact requests erasure, you delete their data, but you may retain a minimal hashed record on a suppression list specifically to prevent re-contact. The suppression list is itself an erasure mechanism, not a violation. We walked through this trade-off in GDPR delete, EU AI Act keep.

A nurture agent that does not check suppression is one bug away from a class action. This file is the most important file in the worker. Treat it the way you would treat any other production guardrail: defense in depth, not a single check.

Step 6: Split the Writer From the Decider

The decisioning model picks send_email and an angle. It does not write the email. A second, cheaper model does, with the lead's transcript history, the brand-voice prompt, and the angle as inputs.

write.ts·typescript
import { generateText } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
 
export async function writeEmail(lead: LeadContext, angle: string) {
  const { text, finishReason } = await generateText({
    model: anthropic('claude-haiku-4.5'),
    system: brandVoicePrompt,
    prompt: `Lead just ${lead.lastSignal.event}.
Last 5 conversations: ${lead.transcript}
Angle the agent wants: ${angle}
Write a 2-paragraph email. Subject line on its own first line.`,
  });
  if (finishReason !== 'stop') throw new Error('writer cut off');
  const [subject, ...body] = text.split('\n\n');
  return { subject: subject.replace(/^Subject:\s*/i, ''), body: body.join('\n\n') };
}

Two models, two jobs. The decider is GPT-4.1-class because its job is reasoning over a timeline and a policy. The writer is Haiku-class because its job is one short message in a known voice. You will run the writer ten times for every decider call, so the cost split matters.

Deliverability Is the Layer Underneath

A perfectly timed message lands in the spam folder if the sender is not authenticated. Only about 35% of Fortune 500 domains are at DMARC p=reject, the level Google and Yahoo require for full brand-indicator eligibility. If half your decider's well-timed sends go to spam, the model is doing free work. DMARC, BIMI, and Apple Business Connect are not blockers for shipping the worker, but they decide whether anyone sees what it sends.

Where General Tools Leave You

If you assemble exactly the worker above, you will get the decisioning loop, the wait primitive, the channel rubric, and a suppression check. You will not get a few things that matter at scale.

The lead's "transcript history" the writer model needs is scattered across your CRM, your chat tool, your call recording vendor, and a dozen Segment events. You either build a memory layer or you accept that the writer is operating on whatever five rows you happened to query.

You will also have to test this loop. You can write 30 unit tests for the dispatcher, but the only test that matters is "does the agent send 5 emails in 2 hours when it should send 1?" That is a behavioral test against a synthetic lead, the kind we covered in scenario testing as QA strategy.

Where Chanl Plugs In

The rest of the worker is plumbing you should not have to write. AI agents that remember each customer is roughly the entire pitch of the platform, and a nurture loop is the canonical use case. Four hooks worth showing.

The lead's timeline is already extracted from voice, chat, and email interactions. The decider does not need to query four systems. It calls one method.

chanl-memory.ts·typescript
import { Chanl } from '@chanl/sdk';
const sdk = new Chanl({ apiKey: process.env.CHANL_API_KEY! });
 
const { data } = await sdk.memory.search({
  entityType: 'customer',
  entityId: lead.id,
  query: 'last 30 days of behavior, intent signals, and any complaints',
  limit: 50,
});
const timeline = data.memories.map((m) => `${m.createdAt}: ${m.content}`).join('\n');

Channel actions become tools the agent calls, not endpoints you import. Swap Postmark for Resend tomorrow without redeploying the worker.

chanl-tool.ts·typescript
await sdk.tools.create({
  name: 'send_branded_email',
  description: 'Send a branded email via Postmark',
  type: 'http',
  inputSchema: {
    type: 'object',
    properties: {
      to: { type: 'string' },
      subject: { type: 'string' },
      htmlBody: { type: 'string' },
    },
    required: ['to', 'subject', 'htmlBody'],
  },
  configuration: {
    http: {
      method: 'POST',
      url: 'https://api.postmark.io/email',
      headers: { 'X-Postmark-Server-Token': '{{secrets.POSTMARK}}' },
    },
  },
});

When the agent decides "in-app chat," the message lands inside the lead's existing chat session so the conversation stays threaded instead of starting fresh.

chanl-chat.ts·typescript
await sdk.chat.sendMessage(lead.chatSessionId, writtenBody);

And testing the loop. Run a synthetic churn-risk persona through the worker and assert the agent does not over-message.

chanl-test.ts·typescript
const run = await sdk.scenarios.run('nurture-over-messaging-guard', {
  agentId: 'agent_nurture',
  personaId: 'churn-risk-customer',
});
// Wait for the run to complete (poll sdk.scenarios.getExecution), then score it.
const score = await sdk.scorecards.evaluate(run.data.executionId);
// Asserts: messages_sent <= 2 in 24h, no SMS without consent, no skip after escalate.

What the SDK Does Not Yet Do

Three product gaps surface when you build this loop end-to-end. The biggest is a shared suppression registry. Every team rebuilds db.suppression from scratch with their own hash, their own staleness rules, their own GDPR-erasure pipeline. A sdk.suppressions.check({ entityId, channel }) shared across all agents and channels would mean one place to keep TCPA, CAN-SPAM, and GDPR honest, instead of nine.

Second, a wait primitive that survives worker restarts. Today the BullMQ delayed job is fine, but a sdk.scheduler.delay({ runAt, payload }) call would put the durable scheduler inside the SDK alongside everything else.

Third, brand-voice prompt versioning bound directly to a channel-specific tool, so the writer model always pulls the right voice for the right channel and the right experiment. sdk.prompts.createVersion exists; the binding does not.

If you are shipping a nurture loop and any of those would save you a sprint, that is the feedback we listen to closely.

Progress0/12
  • Segment/RudderStack/PostHog webhook deduped by jobId
  • Decision tools include wait, skip, and escalate_to_human
  • wait deferral is durable across worker restarts
  • Channel rubric runs after model choice, before send
  • Suppression check runs before every send across email/SMS/chat
  • TCPA: prior express written consent recorded for SMS leads
  • CAN-SPAM: unsubscribe link, postal address, accurate subject
  • GDPR erasure: hashed suppression record retained, profile data deleted
  • DMARC at p=reject; SPF and DKIM aligned
  • BIMI VMC or CMC published; Apple Business Connect configured
  • Decider and writer models are different (cost + responsibility split)
  • Synthetic over-messaging scenario in CI

What to Build Next Week

Strip your existing drip down to the welcome email and the unsubscribe footer. Stand up the worker from Step 1. Ship wait and skip before you ship send. Watch what the model picks for a week. The first thing you will notice is how often the right answer was nothing.

Remember the team that wanted to add three more emails to fix their 1.4% conversion. The agent that decides not to send is the one doing the hardest part of the job. Everything else is plumbing.

Nurture loops with memory built in

Chanl gives your agents the timeline, the suppression layer, and the scenario harness to test pacing before customers see it. AI agents that remember each customer, across voice, chat, and email.

See Chanl in action
DG

Co-founder

Building the platform for AI agents at Chanl — tools, testing, and observability for customer experience.

The Signal Briefing

Un email por semana. Cómo los equipos líderes de CS, ingresos e IA están convirtiendo conversaciones en decisiones. Benchmarks, playbooks y lo que funciona en producción.

500+ líderes de CS e ingresos suscritos

Frequently Asked Questions