# AI Agents that live for 30 days: No Queues, No Workers, No Cron Jobs


Chances are you've built this system before. Maybe more than once. The names change, but the recipe is always the same: a queue to enqueue jobs, a worker to process them, a status table to track where each thing left off, hand-rolled retry logic, and a cron job sweeping up whatever got stuck. Five pieces of infrastructure to express a single idea: *"this is a multi-step process that absolutely has to finish."*

And you know what the real problem is? It's not that it's hard to build — we've all done it a thousand times. The problem is that **your business logic ends up buried under the plumbing**. That simple "generate the email, send it, wait for a reply, follow up" gets scattered across three services, two schedulers, and a `job_status` table nobody wants to touch with a ten-foot pole.

Vercel Workflows — GA as of a few months ago, after a beta with over 100 million runs — proposes something different: that durability should be a language-level concept, not an infrastructure one. Sounds like keynote talk, I know. But stay with me, because this time there's real substance behind it.

![](https://cdn.hashnode.com/uploads/covers/5f80a7ac5841661b519cfdf6/8b567b5f-0caf-4300-9be4-eece98672b09.png align="center")

## Two Directives, Zero YAML

The entire model boils down to two strings at the top of a function. Seriously, that's it:

```typescript
export async function onboardingWorkflow(userId: string) {
  'use workflow';

  const user = await getUser(userId);
  const email = await generateWelcomeEmail(user);
  await sendEmail(user.email, email);

  return { sent: true };
}
```

```typescript
async function generateWelcomeEmail(user: User) {
  'use step';

  // If this fails with a transient error, it retries on its own
  return await createPersonalizedEmail(user);
}
```

`'use workflow'` marks the **orchestrator** function: stateful, remembers its progress, and can pause and resume exactly where it left off — even after a deploy or a crash. `'use step'` marks the units of **actual work**: external API calls, database writes, LLM invocations. Each step comes with automatic retries and survives network or process failures.

And mind you, the separation isn't cosmetic. The workflow is a deterministic coordinator: it decides what to do, in what order, whether there are loops or branches. The steps are the ones getting their hands dirty. That division is precisely what makes the magic trick coming up next possible.

## Deterministic replay (or, Why this isn't a glorified `setTimeout`)

Here's the part that blew my mind. Every input, output, pause, and error in a workflow gets recorded in an **event log**. When the process dies — because you deployed, because it crashed, because Vercel decided to recycle the instance — the system doesn't "resume" in the sense of thawing frozen memory. It **re-executes the function from the beginning**, but every step that already ran doesn't run again: its result is read from the event log and returned instantly.

![](https://cdn.hashnode.com/uploads/covers/5f80a7ac5841661b519cfdf6/c4e0c523-a32f-4d4a-91e0-131b9d268a60.png align="center")

That's why the workflow has to be deterministic: inside it, [`Math.random()` and](https://vercel.com/academy/svelte-on-vercel/durable-tasks) [`Date.now`](http://Date.now)[`()` are intercepted](https://vercel.com/academy/svelte-on-vercel/durable-tasks) to always return the same values during a replay. And that's why steps exist: everything non-deterministic (network, LLMs, side effects) lives there, where the result gets recorded once and done.

If you've worked with durable execution engines before, this will sound familiar. It's the same model — but with no cluster to run, no worker SDK, no DSL. It's plain old TypeScript. The honest caveat: for heavy event sourcing or deeply complex retry trees, you may want to evaluate more specialized tooling. For the 80% case — a multi-step process that needs to survive production — this is more than enough.

## The other two pieces: `sleep` and hooks

A workflow can sleep without consuming compute. And when I say sleep, I mean *actually sleep*:

```typescript
import { sleep } from 'workflow';

await sleep('3 days'); // No server sitting there waiting. Costs nothing.
```

And it can wait for external events with **hooks** — the human-in-the-loop primitive:

```typescript
import { defineHook } from 'workflow';

const replyHook = defineHook<{ intent: 'interested' | 'not_now' | 'unsubscribe' }>();
```

When someone calls `replyHook.resume(token, data)` from any route handler, the workflow waiting on that token wakes up with the data. No polling, no queues, no status table. Magic — but the explainable kind.

With these four pieces — workflow, step, sleep, hook — we can build something genuinely interesting. Let's do that.

## The case: A sales agent that lives for 30 days

Meet **Nervo**, a sales follow-up agent for a B2B SaaS. When a lead downloads the whitepaper, Nervo kicks off a nurture campaign that can last a full month: it writes personalized emails with an LLM, waits for replies, interprets the intent behind each reply, and decides whether to persist, escalate to a human, or give up with dignity.

![](https://cdn.hashnode.com/uploads/covers/5f80a7ac5841661b519cfdf6/77cd9a33-4b8d-4d0e-8b97-20dccadc92b6.png align="center")

Nervo's full loop: every box is a step with automatic retries, the wait is a hook, and the long pauses are sleeps that consume zero compute.

In the world of queues and crons, this would be six pieces of infrastructure and several awkward meetings. Here, it's one file:

```typescript
import { sleep, defineHook, FatalError } from 'workflow';
import { generateText, generateObject } from 'ai';
import { z } from 'zod';

const replyHook = defineHook<{ body: string }>();

export async function nervoAgent(leadId: string) {
  'use workflow';

  const lead = await getLead(leadId);
  let context = `Lead: ${lead.name}, company: ${lead.company}, downloaded: ${lead.asset}`;

  for (let attempt = 1; attempt <= 4; attempt++) {
    // 1. The LLM drafts the email based on history
    const email = await draftEmail(context, attempt);
    await sendEmail(lead.email, email);

    // 2. Wait for a reply OR a 5-day timeout, whichever comes first
    const reply = await waitForReply(leadId, '5 days');

    if (!reply) {
      context += `\nAttempt ${attempt}: no response.`;
      continue; // The loop goes on: another email, another angle
    }

    // 3. The LLM classifies the reply's intent
    const { intent } = await classifyReply(reply.body);

    if (intent === 'interested') {
      await notifySalesTeam(lead, reply.body); // Escalate to a human 🎉
      return { outcome: 'escalated', attempts: attempt };
    }
    if (intent === 'unsubscribe') {
      await markDoNotContact(leadId);
      return { outcome: 'unsubscribed', attempts: attempt };
    }

    context += `\nAttempt ${attempt}: replied "not now": ${reply.body}`;
    await sleep('10 days'); // We respect the "not right now"
  }

  return { outcome: 'gave_up', attempts: 4 };
}
```

The steps do the dirty work, including the AI SDK calls:

```typescript
async function draftEmail(context: string, attempt: number) {
  'use step';

  const { text } = await generateText({
    model: 'anthropic/claude-sonnet-4.5',
    prompt: `You're a warm, non-pushy SDR. History:\n${context}\n
    Write follow-up email number ${attempt}.
    If it's attempt 3 or 4, offer something of value (a case study, a short demo).`,
  });

  return text;
}

async function classifyReply(body: string) {
  'use step';

  const { object } = await generateObject({
    model: 'anthropic/claude-sonnet-4.5',
    schema: z.object({
      intent: z.enum(['interested', 'not_now', 'unsubscribe']),
    }),
    prompt: `Classify the intent of this reply:\n${body}`,
  });

  return object;
}
```

And the webhook that wakes the agent up when the lead replies:

```typescript
// app/api/inbound-email/route.ts
import { replyHook } from '@/workflows/nervo';

export async function POST(req: Request) {
  const { leadId, body } = await req.json();
  await replyHook.resume(leadId, { body });
  return new Response('OK');
}
```

Now let's review what we **didn't** write, because that's where the fun is: no queue, no worker, no cron checking for "leads with no reply in 5 days," no `followup_state` table, no retry handler for when the LLM provider returns a 529 at 3 AM. And yet:

*   If the LLM fails on attempt 3, **only that step retries**. The two previous emails don't get sent again (your leads thank you).
    
*   If you deploy a new version mid-campaign, existing runs keep running on their original deployment (skew protection) — live agents don't break.
    
*   During those `sleep('10 days')` calls, you pay for zero compute. The agent literally doesn't exist until it's time to wake up.
    
*   In the Vercel dashboard, every run shows up as a timeline: each generated email, each classification, each pause. Debugging a 30-day process with the same friction as a `console.log`.
    

AI SDK v7 takes this one step further with `WorkflowAgent`: durable agents as a first-class primitive, with tools and state that survive any interruption. But even with bare `generateText` inside a step, the pattern is already complete.

## Wrapping Up

For years we treated the infrastructure behind long-running processes as an unavoidable toll: queues, workers, state tables, crons. We paid it without questioning it, because it was the price of things actually finishing. Workflows argues that toll was never essential — it was a historical accident. Durability can be a property of the function, not of the system wrapped around it.

And the timing is no coincidence. AI agents are exactly the workload that breaks the request/response model: processes that reason, wait, retry, and live for days. With them, the interesting question is no longer "how do I keep this process alive?" but "what would I build if keeping it alive were free?". Nervo is one possible answer; I'd bet you've had another one in your head since the second paragraph of this post.

That's the mark of a good abstraction: it doesn't just save you work — it changes the questions you get to ask.
