Skip to content

tutorials

AI schedule and snooze email: build send-later and snooze as agent tools

June 6, 2026

#ai-agent#email-tools#send-later#snooze#cloudflare
AI schedule and snooze email: build send-later and snooze as agent tools
Visual: Jean-Paul Cosmique on okslop

You type "send this to the investors Tuesday at 9am their time, and snooze the legal thread until Monday." Gmail can do the first with a menu and the second with a hover button. You want both to happen by talking to the assistant that already lives in your inbox, on infrastructure you own, without bolting on a third app.

AI schedule and snooze email means an agent turns a natural-language request into durable state that a clock later acts on: a row that says "send this draft at timestamp T" or "hide this thread until timestamp W." The agent does not stay awake until then. It parses the time, writes the row, and returns. A scheduled job does the timed part. Get that division right and send-later and snooze are about 150 lines of infrastructure plus two small tool definitions.

This tutorial builds the whole thing on Cloudflare the manual way (two tables, a Cron Trigger, a compare-and-swap), with the failure modes that bite, then wraps each operation as an agent tool so the assistant can fire it from plain English. If you want the shortcut, jump to the Tinbox section.

How can an AI agent schedule email sends and snooze threads for me?

Give the agent two tools that write durable state instead of doing the work inline. A schedule_send tool stores the draft plus a send_at timestamp and returns immediately; a snooze_thread tool stores a thread id plus a wake_at timestamp and hides the thread from the inbox. A Cloudflare Cron Trigger runs every minute, finds due rows, claims each one, and either dispatches the mail or re-surfaces the thread.

The shape of the system is two writes and one clock:

"send Tuesday 9am"  → agent resolves time → schedule_send → scheduled_sends row (send_at)
"snooze until Mon"  → agent resolves time → snooze_thread  → snoozed_threads row (wake_at)
                                                                      │
                          every minute: Cron Trigger ───────────────┘
                          claims due rows → send mail / un-hide thread

The agent's only job is the fuzzy part: turning "Tuesday at 9am their time" into an absolute timestamp. It already has the current time and the conversation in context, so it is good at this. The durable, timed, concurrency-sensitive part lives entirely in the cron. Keep that boundary clean and everything else follows.

Why not just sleep inside the Worker until send time?

Because Workers are stateless and short-lived, there is no process to keep alive until 9am Tuesday. A request handler returns in seconds; nothing survives to wake up later. You could give every scheduled message its own Durable Object alarm, but that is a lot of objects to provision, bill, and lose track of on redeploy, and it scatters your "what is pending?" answer across thousands of objects instead of one table you can query.

The pattern that survives restarts, redeploys, and overlapping runners is the opposite: write the intent as a durable row, and poll for due rows on a schedule. The row is the source of truth; the cron is just a clock that asks "anything due yet?" once a minute. This is the same shape Cloudflare's own scheduled-campaign dispatch uses, and it is the one this tutorial builds.

How do I store a scheduled send and a snooze?

Create two D1 tables, each indexed on the timestamp and status the cron will scan. The scheduled-send table holds the full draft so the cron can dispatch it without re-resolving anything; the snooze table holds just a thread pointer and a wake time.

-- scheduled sends: the draft, plus when to fire and where it is in its lifecycle
CREATE TABLE scheduled_sends (
  id           TEXT PRIMARY KEY,
  inbox_id     TEXT NOT NULL,
  to_json      TEXT NOT NULL,              -- JSON array of recipients
  subject      TEXT NOT NULL,
  body_text    TEXT,
  body_html    TEXT,
  send_at      INTEGER NOT NULL,           -- epoch millis
  status       TEXT NOT NULL DEFAULT 'scheduled', -- scheduled | sending | sent | canceled
  created_at   INTEGER NOT NULL
);
-- the cron scans (status, send_at): index it so the scan stays cheap
CREATE INDEX scheduled_sends_due_idx ON scheduled_sends (status, send_at);

-- snoozed threads: hide until wake_at, then re-surface
CREATE TABLE snoozed_threads (
  id           TEXT PRIMARY KEY,
  inbox_id     TEXT NOT NULL,
  thread_id    TEXT NOT NULL,
  wake_at      INTEGER NOT NULL,           -- epoch millis
  status       TEXT NOT NULL DEFAULT 'snoozed',   -- snoozed | woken | canceled
  created_at   INTEGER NOT NULL
);
CREATE INDEX snoozed_threads_due_idx ON snoozed_threads (status, wake_at);
CREATE UNIQUE INDEX snoozed_threads_one_per_thread ON snoozed_threads (thread_id, status);

That last unique index matters: a thread should not be snoozed twice at once. If the agent snoozes an already-snoozed thread, you want the second write to update the existing row, not create a parallel one that wakes the thread at the wrong time.

How do I write the send-later and snooze operations?

Write two functions that validate the timestamp and insert a row. They do no timed work and send no mail; they only record intent. The send-later operation refuses a send_at in the past (a model that fat-fingers the year should fail loudly, not fire instantly):

// schedule/operations.ts
import { newId } from "@tinbox/core";

type ScheduleInput = {
  inboxId: string;
  to: string[];
  subject: string;
  bodyText?: string;
  bodyHtml?: string;
  sendAt: number; // epoch millis, resolved by the agent
};

export async function scheduleSend(env: Env, input: ScheduleInput) {
  const now = Date.now();
  if (input.sendAt <= now + 30_000) {
    // 30s floor: "schedule for now" should just be a normal send, not a cron round-trip
    throw new Error("send_at must be at least 30 seconds in the future");
  }
  if (!input.bodyText?.trim() && !input.bodyHtml?.trim()) {
    throw new Error("refusing to schedule an empty message");
  }
  const id = newId("sch");
  await env.DB.prepare(
    `INSERT INTO scheduled_sends
       (id, inbox_id, to_json, subject, body_text, body_html, send_at, status, created_at)
     VALUES (?, ?, ?, ?, ?, ?, ?, 'scheduled', ?)`,
  )
    .bind(id, input.inboxId, JSON.stringify(input.to), input.subject,
          input.bodyText ?? null, input.bodyHtml ?? null, input.sendAt, now)
    .run();
  return { id, sendAt: input.sendAt };
}

export async function snoozeThread(
  env: Env,
  input: { inboxId: string; threadId: string; wakeAt: number },
) {
  const now = Date.now();
  if (input.wakeAt <= now) throw new Error("wake_at must be in the future");
  const id = newId("snz");
  // Upsert on the unique (thread_id, status) so re-snoozing reschedules instead of duplicating.
  await env.DB.prepare(
    `INSERT INTO snoozed_threads (id, inbox_id, thread_id, wake_at, status, created_at)
       VALUES (?, ?, ?, ?, 'snoozed', ?)
     ON CONFLICT (thread_id, status) DO UPDATE SET wake_at = excluded.wake_at`,
  )
    .bind(id, input.inboxId, input.threadId, input.wakeAt, now)
    .run();
  // Hide the thread from the default inbox view (your own flag/folder column).
  await env.DB.prepare(
    `UPDATE threads SET snoozed = 1 WHERE id = ? AND inbox_id = ?`,
  )
    .bind(input.threadId, input.inboxId)
    .run();
  return { id, wakeAt: input.wakeAt };
}

Note what is missing: no setTimeout, no await fetch to a send API, no model call. These functions are fast, synchronous-feeling writes. All the latency and all the risk live in the cron.

How do I make the Cron Trigger fire due sends without double-sending?

Add a one-minute Cron Trigger and have it claim each due row with a compare-and-swap before doing any work, so two overlapping ticks can never both send the same message. Only the writer whose UPDATE actually changed a row proceeds.

In wrangler.jsonc:

{
  "triggers": {
    "crons": ["* * * * *"], // every minute
  },
}

The scheduled handler claims, then dispatches through your normal idempotent send path:

// the Worker's scheduled() handler
export default {
  async scheduled(_event: ScheduledController, env: Env): Promise<void> {
    const now = Date.now();
    await fireDueSends(env, now);
    await wakeDueThreads(env, now);
  },
};

async function fireDueSends(env: Env, now: number) {
  const due = await env.DB.prepare(
    `SELECT * FROM scheduled_sends WHERE status = 'scheduled' AND send_at <= ? LIMIT 100`,
  ).bind(now).all<ScheduledRow>();

  for (const row of due.results) {
    // CLAIM with a compare-and-swap: only one runner wins this row.
    const claim = await env.DB.prepare(
      `UPDATE scheduled_sends SET status = 'sending'
         WHERE id = ? AND status = 'scheduled'`,
    ).bind(row.id).run();
    if (claim.meta.changes !== 1) continue; // another tick already claimed it

    // Send through your IDEMPOTENT path, keyed on the row id so a retry never re-sends.
    await sendMail(env, {
      inboxId: row.inbox_id,
      to: JSON.parse(row.to_json),
      subject: row.subject,
      bodyText: row.body_text ?? undefined,
      bodyHtml: row.body_html ?? undefined,
      idempotencyKey: `scheduled-send:${row.id}`,
    });

    await env.DB.prepare(`UPDATE scheduled_sends SET status = 'sent' WHERE id = ?`)
      .bind(row.id).run();
  }
}

async function wakeDueThreads(env: Env, now: number) {
  const due = await env.DB.prepare(
    `SELECT id, inbox_id, thread_id FROM snoozed_threads
       WHERE status = 'snoozed' AND wake_at <= ? LIMIT 100`,
  ).bind(now).all<{ id: string; inbox_id: string; thread_id: string }>();

  for (const row of due.results) {
    await env.DB.prepare(`UPDATE threads SET snoozed = 0 WHERE id = ? AND inbox_id = ?`)
      .bind(row.thread_id, row.inbox_id).run();
    await env.DB.prepare(`UPDATE snoozed_threads SET status = 'woken' WHERE id = ?`)
      .bind(row.id).run();
  }
}

The compare-and-swap is the dispatch-side dedupe; the idempotency key on sendMail is the authoritative one. Even if a manual "send now" raced the cron between the claim and the send, the send ledger writes the message once. This is the same belt-and-suspenders pattern Cloudflare's scheduled-campaign dispatch uses, and it is covered for the bulk case in scheduling recurring email with Cron Triggers.

Wire this up and tail it, and the first failure mode shows up fast:

$ wrangler tail
scheduled handler   ok   fired 1 send, woke 0 threads
scheduled handler   ok   fired 0 sends, woke 1 thread
scheduled handler   error   sendMail: idempotencyKey reused, message already in ledger

That last line is the system working: a retried tick re-entered sendMail, the ledger recognized the key, and it refused to double-send. If you see it as an error rather than a no-op, soften it to a skip; a re-claimed-and-resent row is expected, not a bug.

How do I expose schedule-send and snooze as agent tools?

Declare each operation as a tool with a Zod input schema and a one-line trimmer for what the model sees, and mark the send-later tool destructive so it requires a human approval step. The agent resolves the human time phrase to an epoch-millis timestamp and passes it in; the tool validates and writes the row. Here is the send-later tool using the same manifest pattern Tinbox uses for send_email:

// tools/schedule-send-tool.ts
import { z } from "zod";
import { defineManifestTool } from "./manifest";
import { toolError, type ToolResult } from "./result";
import { scheduleSend } from "../schedule/operations";

export const scheduleSendTool = defineManifestTool({
  name: "schedule_send",
  title: "Schedule an email to send later",
  description:
    "Queue an email to be SENT at a future time (this actually dispatches mail when the time " +
    "arrives; it is not a draft). Resolve the user's phrase ('next Tuesday at 9am') to an " +
    "absolute epoch-millis send_at yourself and pass it. Requires a human approval step.",
  inputSchema: z.object({
    inboxId: z.string().describe("The inbox to send from."),
    to: z.array(z.string()).min(1).describe("Recipient address(es)."),
    subject: z.string(),
    bodyText: z.string().optional(),
    bodyHtml: z.string().optional(),
    sendAt: z.number().int().describe("Absolute send time, epoch milliseconds, in the future."),
  }),
  // destructiveHint → the chat UI shows approve/deny; MCP clients confirm.
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
  execute: async (input, ctx): Promise<ToolResult> => {
    try {
      const { id, sendAt } = await scheduleSend(ctx.env, input);
      return {
        kind: "scheduled_send",
        id,
        inboxId: input.inboxId,
        to: input.to,
        subject: input.subject,
        sendAt,
      };
    } catch (err) {
      return toolError(err instanceof Error ? err.message : String(err));
    }
  },
  toModelText: (r) =>
    r.kind === "scheduled_send"
      ? `Scheduled "${r.subject}" to ${r.to.join(", ")} for ${new Date(r.sendAt).toISOString()}.`
      : "",
});

Snooze is the same skeleton with one important difference: it is reversible, so it does not need the destructive gate.

// tools/snooze-thread-tool.ts
import { z } from "zod";
import { defineManifestTool } from "./manifest";
import { toolError, type ToolResult } from "./result";
import { snoozeThread } from "../schedule/operations";

export const snoozeThreadTool = defineManifestTool({
  name: "snooze_thread",
  title: "Snooze a thread until later",
  description:
    "Hide a thread from the inbox until a future time, then re-surface it. Reversible. " +
    "Resolve the user's phrase to an epoch-millis wake_at and pass it. Use the threadId from " +
    "search_emails or list_threads.",
  inputSchema: z.object({
    inboxId: z.string(),
    threadId: z.string().describe("The thread to snooze (e.g. from search_emails)."),
    wakeAt: z.number().int().describe("When to re-surface, epoch milliseconds, in the future."),
  }),
  // readOnly=false (it writes) but NOT destructive: hiding/un-hiding is reversible.
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
  execute: async (input, ctx): Promise<ToolResult> => {
    try {
      const { id, wakeAt } = await snoozeThread(ctx.env, input);
      return { kind: "snoozed_thread", id, threadId: input.threadId, wakeAt };
    } catch (err) {
      return toolError(err instanceof Error ? err.message : String(err));
    }
  },
  toModelText: (r) =>
    r.kind === "snoozed_thread"
      ? `Snoozed thread ${r.threadId} until ${new Date(r.wakeAt).toISOString()}.`
      : "",
});

Because both tools share one declaration shape, registering them in the manifest makes the same verbs available to the in-app chat assistant, to external MCP clients, and to the generated CLI, all authorized through the same caller. The model never touches the database or the send path directly; it calls a tool, the tool validates, the row gets written. The pattern for declaring tools once and fanning them out to every surface is the subject of building an MCP email server.

Should the agent send scheduled email without confirmation?

No, gate it, because scheduling a send is still a send. The destructiveHint: true on schedule_send makes the chat UI render an approve/deny step before the row is written, and MCP clients confirm. A misparsed timestamp that fires at the wrong hour is bad; a misparsed recipient that sends the wrong thing is worse, and the approval step is where a human catches both. Snooze carries no such risk: it only hides a thread you already own and re-surfaces it later, so destructiveHint: false lets it run without friction.

Keep the privileges separate on purpose. Writing future mail is irreversible the moment the cron fires; hiding a thread is not. If you ever let a single tool both schedule a send and act on the thread, a misclassification becomes an email to a real person. The broader version of this argument (which agent actions are safe to automate and which need a gate) is in drafting replies with an AI agent.

How do I do this without hand-rolling the cron and the tools?

Skip the tables, the Cron Trigger, the compare-and-swap, and the approval plumbing, and run the assistant against a Tinbox inbox deployed on your own Cloudflare account. Tinbox already ships the agent over your inbox (Vercel AI SDK) with a shared tool manifest, the idempotent ledgered send path, and the scheduled-dispatch primitive this tutorial rebuilt by hand. The send_email tool is already destructive-gated; scheduled sends reuse the same send_at plus Cron Trigger plus compare-and-swap machinery that powers scheduled newsletter campaigns.

npx tinbox init scaffolds the inbox, the agent, and the tool registry, provisions D1, R2, and Queues, and prints the DNS records:

npx tinbox init yourdomain.com

Send-later and snooze are agent tools in the same manifest as everything else, so you add them the way you would add any tool: one defineManifestTool declaration with a Zod schema, registered once, available to chat, MCP, and the CLI. The destructive flag drives the approval step for free, and the scheduled-dispatch cron is already running for campaigns, so a scheduled message rides the same claim-and-send loop with the same idempotency guarantees.

Two honest caveats. The agent does the natural-language time parsing, which means it can get a timezone or a "next Friday" wrong; the approval gate on the destructive send is your catch, not the model's correctness. And the tool you add still runs on your own deployment under your own keys: Tinbox gives you the manifest, the gate, and the cron, but the rule and its instructions are yours to write and yours to evaluate. That is the trade for owning the whole stack instead of renting send-later from someone else's app, with no per-email pricing on top.

For the wider picture of what an agent can do once it is wired into an inbox you control, see the email stack built for AI agents, the broader menu of email tasks worth automating with an agent, and the first agent tools worth building for your inbox. And if you want assistants outside the app (Claude, your own tooling) driving these same scheduled-send and snooze verbs, connect them through the MCP email server over the same authorized tool registry.

Summary

AI schedule and snooze email is a clean division of labor: the agent parses the human time phrase and writes a durable row, and a one-minute Cloudflare Cron Trigger does the timed work. Build it the manual way and you write two tables, two write functions, a scheduled handler that claims due rows with a compare-and-swap, and two manifest tools (the send-later one destructive-gated, the snooze one not). Every hard part (statelessness, double-sends, the irreversible-send risk) has a known fix, and now you know each one. Or you declare the two tools against a Tinbox inbox and let the existing ledger, cron, and approval gate carry the weight.

Deploy this yourself: npx tinbox init <domain>, then add schedule_send and snooze_thread to app/agent.config.ts and try them in the in-app chat. The tool-author guide ships next to the code with the full manifest reference and the destructive-gate recipe.

Frequently asked questions

How can an AI agent schedule email sends and snooze threads for me?

Give the agent two tools that write durable state instead of doing the work inline. A schedule_send tool stores the draft plus a send_at timestamp and returns; a snooze_thread tool stores a thread id plus a wake_at timestamp and hides the thread. A Cloudflare Cron Trigger runs every minute, claims due rows with a compare-and-swap, and then either dispatches the mail through your normal idempotent send path or moves the thread back to the inbox. The agent only ever parses the natural-language time and writes a row; the cron does the timed work.

Why not just sleep inside the Worker until send time?

Because Workers are stateless and short-lived: there is no process to keep alive until 9am Tuesday, and a Durable Object alarm per scheduled message is overkill and easy to lose on redeploy. Durable state plus a polling Cron Trigger is the pattern that survives restarts, redeploys, and concurrent runners. The row is the source of truth; the cron is just the clock.

How do I stop a scheduled email from sending twice?

Claim each due row with a compare-and-swap (UPDATE ... SET status='sending' WHERE id=? AND status='scheduled') and only proceed if exactly one row changed, so overlapping cron ticks cannot both win. Then send through an idempotent path keyed on a stable id (the scheduled row id), so even if a claim races a manual send, the ledger writes the message once. The CAS dedupes the dispatch; the send ledger is the authoritative guard.

Should the agent be allowed to send scheduled email without confirmation?

Scheduling a send is still a send, so gate it. Mark the schedule_send tool destructive so the chat UI shows an approve step and MCP clients confirm before the row is written. Snooze is reversible (it only hides and re-surfaces a thread you own), so it is safe to run without a gate. Keep the two on separate privileges: writing future mail is irreversible once the cron fires, hiding a thread is not.

Does natural-language time parsing belong in the agent or the tool?

Let the agent resolve the human phrase to an absolute timestamp and pass an epoch-millis send_at to the tool, because the model already has the current time and timezone in context and is good at 'next Tuesday at 9am'. The tool validates that the timestamp is in the future and stores it verbatim. Keep the tool dumb and deterministic; keep the fuzzy parsing in the layer that has the conversation.