---
title: Use Vercel Daemons with eve agents
description: Connect eve agents to Vercel Daemons to process queued jobs, offload heavy work, and react to events. Includes code, authentication, and shutdown handling.
url: "https://vercel.com/kb/guide/vercel-daemons-eve-agents"
published: 2026-11-02
last_updated: 2026-11-02
authors: Ben Sabic
install_vercel_plugin: npx plugins add vercel/vercel-plugin
---

Connect [Vercel Daemons](https://vercel.com/kb/guide/vercel-daemons) to [eve agents](https://vercel.com/docs/frameworks/agents/eve) so background workers can start agent sessions for jobs and agents can send work back to workers.

The daemon handles queue polling, open connections, and compute-heavy jobs. eve manages durable sessions that preserve the agent's state between turns, including while a worker prepares a result. These examples run both in one Vercel project and authenticate with the project's OpenID Connect (OIDC) identity.

## How do you run eve agents with Vercel Daemons?

Run the daemon in the same Vercel project as your eve agent. For queue-based triage, the daemon pulls a job and starts an eve session with `POST /eve/v1/session`. It reads the stream until `turn.settled`, then acknowledges a successful job or records a failed turn for review.

In the other direction, an eve tool publishes a job to [Vercel Queues](https://vercel.com/docs/queues). The session waits without holding compute until the daemon sends the result back through eve's session API. Both directions authenticate with the project's Vercel OIDC token.

## When should an agent use a daemon?

Use a Vercel Daemon when the work requires a process to stay running, such as a queue consumer, held-open connection, or CPU-heavy job.

eve and Vercel have a better fit for other triggers:

| Workload                                                                                                                         | Use                                                                                                                 | Why                                                         |
| -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| The agent runs on a fixed cadence, whatever the data says                                                                        | [eve schedules](https://eve.dev/docs/schedules)                                                                     | Each cron fire starts a session, with no worker to maintain |
| The agent reacts to messages that Vercel delivers to a function                                                                  | [Vercel Queues](https://vercel.com/docs/queues) in push mode                                                        | Vercel invokes and scales the consumer for you              |
| The agent reacts to an external queue such as Amazon SQS, Kafka, or BullMQ, or you need to control how quickly workers take jobs | Vercel Daemons                                                                                                      | The daemon pulls at the pace you set                        |
| The agent needs events from a source without webhooks, such as IMAP, MQTT, or Postgres `LISTEN`                                  | Vercel Daemons                                                                                                      | Only a running process can hold the connection open         |
| The tool needs system binaries, more CPU, or minutes of compute                                                                  | Vercel Daemons                                                                                                      | The work runs in your container while the session waits     |
| The tool needs durable timers, waits, or retries in your own code                                                                | [eve workflow tools](https://eve.dev/docs/tools/workflows) on [Vercel Workflows](https://vercel.com/docs/workflows) | `defineWorkflowTool` suspends without holding compute       |
| The tool runs model-chosen commands or untrusted code                                                                            | The [eve sandbox](https://eve.dev/docs/sandbox) on [Vercel Sandbox](https://vercel.com/docs/sandbox)                | The sandbox isolates code from your app's credentials       |
| The agent answers HTTP requests or webhooks                                                                                      | [eve channels](https://eve.dev/docs/channels) on [Vercel Functions](https://vercel.com/docs/functions)              | The request itself starts the session                       |

## Prerequisites

You'll need a [Vercel account](https://vercel.com/signup). The rendering example also requires a Pro or Enterprise plan for its 4 GiB instances.

For local development, install Node.js 24 or later, [Docker](https://docs.docker.com/get-docker/), and the [Vercel CLI](https://vercel.com/docs/cli).

Deploy your eve agent to production and set the project's [root directory](https://vercel.com/docs/builds/configure-a-build#root-directory) to the repository root. During beta, daemons don't support subfolder roots or non-production environments.

Configure your agent's instructions and tools for the use case you choose. The ticket triage, fraud review, and monitoring examples assume working integrations with your helpdesk, order database, or logging and incident systems.

## How do daemons and eve agents talk to each other?

Daemons call an eve agent through the agent's HTTP session routes, and eve agents reach daemons through a queue, because daemons don't accept inbound traffic.

The daemon uses four of eve's session routes:

| Route                                   | What the daemon uses it for                                                                                                                                                               |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /eve/v1/session`                  | Start a session with the job as its first message. Pass `operationId` to deduplicate creation. Once the session is active, retries return that session without sending the message again. |
| `GET /eve/v1/session/:sessionId/stream` | Read newline-delimited JSON (NDJSON) events until `turn.settled` reports the turn's `outcome`                                                                                             |
| `POST /eve/v1/session/:sessionId`       | Send a result to the session. With the queue policy configured below, the message waits for any running turn to finish. Pass `idempotencyKey` so a retried send is accepted once.         |
| `POST /eve/v1/session/:sessionId/end`   | Retire a one-shot session after you've saved its result                                                                                                                                   |

The [eve channel reference](https://eve.dev/docs/channels/eve) documents every field these routes accept.

### How daemons authenticate to an eve agent

Each daemon instance receives a Vercel OIDC token for its project, which Vercel writes to the file in `VERCEL_OIDC_TOKEN_FILE` and refreshes periodically. eve's `vercelOidc()` verifier always accepts tokens minted for the agent's own project, so a daemon in the same project needs no extra auth configuration.

Keep `vercelOidc()` in your eve channel's auth list:

```typescript
import { localDev, vercelOidc } from 'eve/channels/auth';
import { eveChannel } from 'eve/channels/eve';

export default eveChannel({
  auth: [vercelOidc(), localDev()],
  turnPolicy: 'queue',
});
```

`turnPolicy: 'queue'` makes follow-up messages wait for a running turn to finish. This lets a render result start the next turn even if it arrives before the agent finishes its initial reply.

The daemon needs `@vercel/oidc` 4.0 or later to read its token file. Send the token as a bearer token to the agent's production URL, as eve's [Discord relay daemon](https://eve.dev/docs/channels/discord/reference#run-the-relay-as-a-vercel-daemon) does.

To call an agent in another Vercel project, set `EVE_URL` in the worker project's production environment to the agent's production URL, including `https://`.

On the agent, admit the worker project with `subjects`:

```typescript
import { localDev, vercelOidc, vercelSubject } from 'eve/channels/auth';
import { eveChannel } from 'eve/channels/eve';

export default eveChannel({
  auth: [
    vercelOidc({
      // Matches production tokens from the workers project.
      subjects: [vercelSubject({ teamSlug: 'acme', projectName: 'workers' })],
    }),
    localDev(),
  ],
  turnPolicy: 'queue',
});
```

Requests that no entry accepts receive a `401` response.

## How do you set up a shared worker image?

Package all worker code in one container image, then run each use case as its own daemon by setting the daemon's `command` field. Every daemon shares the same eve client code and dependencies, and you scale each one separately.

Add a `worker/` directory next to your agent:

```text
my-agent/
├── agent/
│   ├── agent.ts
│   ├── instructions.md
│   ├── channels/
│   │   └── eve.ts
│   └── tools/
│       └── render_clip.ts
├── worker/
│   ├── Dockerfile
│   ├── package.json
│   ├── package-lock.json
│   ├── lib/
│   │   ├── eve.ts
│   │   ├── render-clip.ts
│   │   └── shutdown.ts
│   ├── triage.ts
│   ├── collect-triage-failures.ts
│   ├── render.ts
│   ├── listen.ts
│   ├── backfill.ts
│   └── monitor.ts
├── package.json
└── vercel.json
```

Node.js 24 runs TypeScript files directly by stripping their types, so the worker needs no build step. Import local files with their `.ts` extension, and avoid TypeScript features that need compilation, such as enums.

### 1\. Install the worker dependencies

Enable top-level `await` in `worker/package.json`:

```json
{
  "name": "worker",
  "private": true,
  "type": "module",
  "overrides": {
    "@vercel/queue": {
      "@vercel/oidc": "$@vercel/oidc"
    }
  }
}
```

The override makes the [Queues SDK](https://vercel.com/docs/queues/sdk) use the same OIDC helper as the worker, so queue requests can read the daemon's refreshed token file.

From `worker/`, install the dependencies. Keep this directory on npm so its generated `package-lock.json` matches the Dockerfile's `npm ci` command:

```bash
npm install @vercel/oidc @vercel/queue @vercel/blob pg
```

Return to the repository root before continuing. If you already installed the worker dependencies without the override, regenerate its lockfile and reinstall so no older nested OIDC helper remains.

### 2\. Add the Dockerfile

This image installs `ffmpeg` for the rendering use case. If you only need the other use cases, remove the `apt-get` lines to keep the image smaller:

```dockerfile
FROM node:24-slim
RUN apt-get update \
    && apt-get install -y --no-install-recommends ffmpeg \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
USER node
CMD ["node", "triage.ts"]
```

### 3\. Add the eve client helpers

`worker/lib/eve.ts` wraps the four session routes. `waitForFirstTurn` reads the stream from line `0`, so a redelivered job also finds a turn that finished before the daemon restarted:

```typescript
import { setTimeout as sleep } from 'node:timers/promises';
import { getVercelOidcToken } from '@vercel/oidc';

const EVE_URL =
  process.env.EVE_URL ??
  `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`;

export interface TurnResult {
  outcome: 'completed' | 'failed' | 'cancelled';
  reply: string;
}

export class SessionNotActiveError extends Error {}

async function call(
  path: string,
  init: RequestInit = {},
): Promise<Response> {
  return fetch(new URL(path, EVE_URL), {
    ...init,
    headers: {
      authorization: `Bearer ${await getVercelOidcToken()}`,
      'content-type': 'application/json',
    },
  });
}

// Starts a session with message as its first input. Once active, retries
// with the same operationId return it without resending the message.
// Concurrent creates can return different IDs until startup resolves
// the winner.
export async function startSession(
  message: string,
  operationId: string,
): Promise<string> {
  const res = await call('/eve/v1/session', {
    method: 'POST',
    body: JSON.stringify({ message, operationId }),
  });
  if (!res.ok)
    throw new Error(
      `Create session failed: ${res.status} ${await res.text()}`,
    );
  const { sessionId } = (await res.json()) as { sessionId: string };
  return sessionId;
}

// Follows the session stream from the start until the first turn settles.
export async function waitForFirstTurn(
  sessionId: string,
  signal: AbortSignal,
): Promise<TurnResult> {
  const reply: string[] = [];
  let next = 0; // the next stored line to read

  while (true) {
    const res = await call(
      `/eve/v1/session/${sessionId}/stream?startIndex=${next}`,
      { signal },
    );
    if (!res.ok || !res.body)
      throw new Error(`Stream failed: ${res.status} ${await res.text()}`);

    for await (const line of readLines(res.body)) {
      const record = JSON.parse(line);
      if (record.$eve === 'stream.ended')
        throw new SessionNotActiveError(sessionId);
      // Heartbeats and lease ends aren't stored lines.
      if (record.$eve) continue;
      next += 1;

      for (const fact of record.facts ?? []) {
        if (
          fact.type === 'content.completed' &&
          fact.data.phase === 'reply' &&
          fact.data.kind === 'text'
        ) {
          reply.push(fact.data.value);
        }
        if (fact.type === 'turn.settled') {
          return { outcome: fact.data.outcome, reply: reply.join('\n') };
        }
      }
    }
    // The stream lease ended. Reconnect from the cursor.
  }
}

// Sends a follow-up message. With turnPolicy: 'queue' on the eve channel,
// it waits for the running turn to finish before starting the next one.
// eve ignores a repeat that uses the same idempotencyKey.
export async function sendToSession(
  sessionId: string,
  message: string,
  idempotencyKey: string,
  signal?: AbortSignal,
): Promise<void> {
  for (let attempt = 1; ; attempt += 1) {
    const res = await call(`/eve/v1/session/${sessionId}`, {
      method: 'POST',
      body: JSON.stringify({ message, idempotencyKey }),
      signal,
    });
    if (res.ok) return;

    const body = (await res.json().catch(() => ({}))) as { code?: string };
    if (body.code === 'session_not_ready' && attempt < 10) {
      await sleep(1000 * attempt, undefined, { signal });
      continue;
    }
    if (body.code === 'session_not_active')
      throw new SessionNotActiveError(sessionId);
    throw new Error(`Send failed: ${res.status} ${body.code ?? ''}`);
  }
}

// Retires a session whose result you've already saved.
export async function endSession(
  sessionId: string,
  reason: string,
): Promise<void> {
  const res = await call(`/eve/v1/session/${sessionId}/end`, {
    method: 'POST',
    body: JSON.stringify({ reason }),
  });
  if (!res.ok && res.status !== 409)
    throw new Error(`End session failed: ${res.status}`);
}

async function* readLines(
  body: ReadableStream<Uint8Array>,
): AsyncGenerator<string> {
  const decoder = new TextDecoder();
  let buffer = '';
  for await (const chunk of body) {
    buffer += decoder.decode(chunk, { stream: true });
    let newline = buffer.indexOf('\n');
    while (newline >= 0) {
      const line = buffer.slice(0, newline).trim();
      buffer = buffer.slice(newline + 1);
      if (line) yield line;
      newline = buffer.indexOf('\n');
    }
  }
}
```

The [eve stream protocol](https://eve.dev/docs/concepts/sessions-runs-and-streaming#stream-a-session) defines the event lines, progress records, and transport records that `readLines` parses. If you'd rather not handle the stream yourself, the typed [`eve/client`](https://eve.dev/docs/guides/client) SDK wraps the same routes.

### 4\. Add the shutdown helper

Vercel sends `SIGTERM` before it stops or replaces a daemon instance. `worker/lib/shutdown.ts` turns that signal into an `AbortSignal` that every loop checks:

```typescript
import { setTimeout as sleep } from 'node:timers/promises';

export const shutdown = new AbortController();

process.on('SIGTERM', () => shutdown.abort());

// Waits for `ms`, or returns early when Vercel sends SIGTERM.
export async function idle(ms: number): Promise<void> {
  await sleep(ms, undefined, { signal: shutdown.signal }).catch(() => {});
}
```

### 5\. Share queues across deployments

By default, Vercel Queues pins each message to the deployment that published it. Tools in an older eve session can therefore publish to a partition that a newer daemon never reads.

Set `deploymentId: null` on every queue client in these examples so the agent and all daemon versions share the queue. Both sides must also use the same fixed region because messages can only be received in the region where they were sent.

```typescript
import { PollingQueueClient } from '@vercel/queue';

const queue = new PollingQueueClient({ region: 'iad1', deploymentId: null });
```

Because every deployment now reads the same messages, keep message payloads backward compatible, or add a version field and handle each version in the daemon.

## Use cases

Choose a use case and add its script to `worker/`. Merge the corresponding entry into the existing `daemons` configuration in `vercel.json`, then deploy to production. Vercel builds the same worker source separately for each configured daemon and runs its configured command.

### How do you run an agent for every message in a queue?

Your support app publishes each new ticket to a queue. The daemon starts an eve session to read and label the ticket and draft a reply through the agent's tools, then acknowledges the message after a successful turn.

Your app publishes tickets from wherever they arrive, such as a helpdesk webhook. The `idempotencyKey` drops a duplicate publish of the same ticket:

```typescript
import { QueueClient } from '@vercel/queue';

const queue = new QueueClient({ region: 'iad1', deploymentId: null });

export async function POST(request: Request) {
  const ticket = await request.json();
  await queue.send('support-tickets', ticket, {
    idempotencyKey: `ticket-created-${ticket.id}`,
  });
  return Response.json({ ok: true });
}
```

The triage daemon runs four consumer loops per instance. When a session request or stream fails, the handler throws so redelivery can reconnect to the same attempt. Failed or cancelled turns go to a separate topic before the original message is acknowledged:

```typescript
import { PollingQueueClient } from '@vercel/queue';
import { startSession, waitForFirstTurn } from './lib/eve.ts';
import { idle, shutdown } from './lib/shutdown.ts';

interface Ticket {
  id: string;
  subject: string;
  body: string;
  customerEmail: string;
}

const TOPIC = process.env.TICKETS_TOPIC ?? 'support-tickets';
const FAILURE_TOPIC = `${TOPIC}-failures`;
const CONCURRENCY = 4; // tickets in flight per instance

const queue = new PollingQueueClient({
  region: 'iad1',
  deploymentId: null,
});

async function handleTicket(
  ticket: Ticket,
  metadata: { messageId: string },
): Promise<void> {
  const sessionId = await startSession(
    [
      `Triage support ticket ${ticket.id} from ${ticket.customerEmail}.`,
      `Subject: ${ticket.subject}`,
      '',
      ticket.body,
    ].join('\n'),
    `ticket-${metadata.messageId}`,
  );

  const turn = await waitForFirstTurn(sessionId, shutdown.signal);
  if (turn.outcome !== 'completed') {
    await queue.send(
      FAILURE_TOPIC,
      {
        ticket,
        messageId: metadata.messageId,
        sessionId,
        outcome: turn.outcome,
      },
      {
        idempotencyKey: `triage-failed-${metadata.messageId}`,
        retentionSeconds: 604800,
      },
    );
    console.error(
      `Triage ${turn.outcome} for ticket ${ticket.id}; queued for review`,
    );
    return;
  }
  console.log(`Triaged ticket ${ticket.id} in session ${sessionId}`);
}

async function consume(): Promise<void> {
  while (!shutdown.signal.aborted) {
    try {
      const result = await queue.receive<Ticket>(
        TOPIC,
        'triage-agent',
        handleTicket,
      );
      if (!result.ok) await idle(2000); // the queue is empty
    } catch (error) {
      if (!shutdown.signal.aborted) console.error(error);
      await idle(2000);
    }
  }
}

await Promise.all(Array.from({ length: CONCURRENCY }, consume));
process.exit(0);
```

The failure topic keeps each failed or cancelled result for seven days. Before deploying triage, connect a consumer that saves those records for operator review. The collector below writes them to Postgres before acknowledging them, so they remain available after queue retention expires.

Apply this migration to the database the collector uses:

```sql
CREATE TABLE triage_failures (
  message_id text PRIMARY KEY,
  ticket_id text NOT NULL,
  session_id text NOT NULL,
  outcome text NOT NULL,
  ticket jsonb NOT NULL,
  recorded_at timestamptz NOT NULL DEFAULT now()
);
```

Save this as `worker/collect-triage-failures.ts`. It uses the worker dependencies installed earlier:

```typescript
import pg from 'pg';
import { PollingQueueClient } from '@vercel/queue';

const topic = `${process.env.TICKETS_TOPIC ?? 'support-tickets'}-failures`;
const queue = new PollingQueueClient({
  region: 'iad1',
  deploymentId: null,
});
const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });

interface TriageFailure {
  messageId: string;
  ticket: { id: string; [key: string]: unknown };
  sessionId: string;
  outcome: string;
}

async function recordFailure(failure: TriageFailure): Promise<void> {
  await db.query(
    `INSERT INTO triage_failures (
       message_id, ticket_id, session_id, outcome, ticket
     )
     VALUES ($1, $2, $3, $4, $5::jsonb)
     ON CONFLICT (message_id) DO NOTHING`,
    [
      failure.messageId,
      failure.ticket.id,
      failure.sessionId,
      failure.outcome,
      JSON.stringify(failure.ticket),
    ],
  );
}

// Drain currently available failures. The SDK acknowledges only
// after the insert.
try {
  while (true) {
    const result = await queue.receive<TriageFailure>(
      topic,
      'triage-review',
      recordFailure,
    );
    if (!result.ok) break;
  }
} finally {
  await db.end();
}
```

Run the collector from an authenticated worker environment at least daily, with `DATABASE_URL` set and the same `TICKETS_TOPIC` as triage. It drains the currently available failures and exits. Include it in your existing operations scheduler before enabling the triage daemon, and alert on collector failures.

Review the `triage_failures` table to diagnose failed tickets. After fixing the cause, republish the stored ticket with a new publish idempotency key for that deliberate attempt. Reuse that key if the publish itself needs retrying, and keep tool side effects deduplicated by ticket ID across attempts.

Declare the daemon in `vercel.json`. The triage worker spends most of its time waiting on HTTP, so 1 GiB of memory is enough:

```json
{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "daemons": {
    "triage": {
      "root": "worker/",
      "entrypoint": "Dockerfile",
      "command": ["node", "triage.ts"],
      "instances": { "iad1": 2 },
      "resources": { "requests": { "memory": "1Gi" } }
    }
  }
}
```

### How does an agent hand heavy work to a daemon?

An agent can enqueue a video edit and finish its turn while a daemon runs FFmpeg. The renderer looks up an upload registered to that session and posts the finished clip's URL back to start the next turn.

#### Prepare uploads and storage

This example turns private source videos into public clips. Create a private [Vercel Blob](https://vercel.com/docs/vercel-blob/private-storage) store for uploads and a separate public store for clips.

Set `SOURCE_BLOB_READ_WRITE_TOKEN` and `CLIPS_BLOB_READ_WRITE_TOKEN` to their respective store tokens in the project's production environment, and set `DATABASE_URL` to your Postgres connection string.

Create these tables in that database:

```sql
CREATE TABLE video_uploads (
  video_id uuid PRIMARY KEY,
  session_id text NOT NULL,
  blob_pathname text NOT NULL UNIQUE,
  public_clips_allowed boolean NOT NULL DEFAULT false,
  CHECK (blob_pathname = 'uploads/' || video_id::text || '.mp4')
);

CREATE TABLE render_results (
  job_id text PRIMARY KEY,
  session_id text NOT NULL,
  message text NOT NULL
);
```

Your app's authenticated upload route must verify that the signed-in user owns the eve session and has agreed to publish clips from the video.

Generate the video ID on the server, upload the MP4 to `uploads/${videoId}.mp4` with `access: 'private'`, `addRandomSuffix: false`, and `allowOverwrite: false`, then insert its pathname and the verified session ID into `video_uploads` with `public_clips_allowed = true`. Give the resulting video ID to that session.

Registration belongs to your existing app's upload flow. The agent cannot create or edit these rows. Keep source uploads unchanged while jobs can use them, and retain result rows for as long as your app can replay a job ID.

#### 1\. Add a tool that publishes the job

Install the Queues SDK in the agent's project:

**pnpm**

```bash
pnpm i @vercel/queue
```

**npm**

```bash
npm i @vercel/queue
```

**yarn**

```bash
yarn add @vercel/queue
```

**bun**

```bash
bun add @vercel/queue
```

The tool reads eve's runtime session ID so the worker can verify upload access. Queues uses the combined session and call IDs to deduplicate publish requests within the message's retention window, up to 24 hours:

```typescript
import { QueueClient } from '@vercel/queue';
import { defineTool } from 'eve/tools';
import { z } from 'zod';

const queue = new QueueClient({ region: 'iad1', deploymentId: null });

export default defineTool({
  description:
    'Render a public clip from a registered MP4 upload. ' +
    'Use the video ID supplied by the app. ' +
    'Rendering runs in the background; ' +
    'a later message contains the result.',
  inputSchema: z.object({
    videoId: z.string().uuid(),
    startSeconds: z.number().min(0),
    endSeconds: z.number().positive(),
  }),
  async execute(input, ctx) {
    const duration = input.endSeconds - input.startSeconds;
    if (duration <= 0 || duration > 300) {
      throw new Error(
        'Choose a clip longer than 0 seconds and no longer than 300 seconds.',
      );
    }
    const jobId = `${ctx.session.id}:${ctx.callId}`;
    await queue.send(
      'render-jobs',
      { jobId, sessionId: ctx.session.id, ...input },
      { idempotencyKey: jobId },
    );
    return { jobId, status: 'queued' };
  },
});
```

#### 2\. Tell the agent how results arrive

The daemon's result reaches the agent as a new message in the same session. Add instructions so the agent recognizes it:

```plaintext
When you start a render with render_clip, use the video ID supplied by
the app. Tell the user the public clip is rendering and that you'll
share its URL when ready. Don't wait or poll for it.

Messages starting with "Render finished" contain the public clip URL for
a job you started. Share that URL with a one-line summary. If a message
starts with "Render failed", explain that the render couldn't complete
and ask the user to check the video or range.
```

#### 3\. Write the renderer daemon

`worker/lib/render-clip.ts` downloads the private Blob to a temporary file and runs FFmpeg against that file. It accepts MP4 sources up to 250 MiB and clip ranges up to 300 seconds, rejects outputs of 100 MiB or more, and sets an eight-minute deadline for downloading, encoding, and uploading.

These are application limits chosen for this example:

```typescript
import { execFile } from 'node:child_process';
import { createHash } from 'node:crypto';
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdtemp, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import type { ReadableStream } from 'node:stream/web';
import { promisify } from 'node:util';
import { get, put } from '@vercel/blob';

const run = promisify(execFile);
const MAX_INPUT_BYTES = 250 * 1024 ** 2;
const MAX_OUTPUT_BYTES = 100 * 1024 ** 2;

export interface RenderJob {
  jobId: string;
  sessionId: string;
  videoId: string;
  startSeconds: number;
  endSeconds: number;
}

export async function renderClip(
  job: RenderJob,
  pathname: string,
): Promise<string> {
  const duration = job.endSeconds - job.startSeconds;
  if (
    !Number.isFinite(job.startSeconds) ||
    !Number.isFinite(job.endSeconds) ||
    job.startSeconds < 0 ||
    duration <= 0 ||
    duration > 300
  ) {
    throw new Error('Invalid clip range');
  }
  if (
    !/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(job.videoId) ||
    pathname !== `uploads/${job.videoId.toLowerCase()}.mp4`
  ) {
    throw new Error('Invalid upload pathname');
  }

  const dir = await mkdtemp(join(tmpdir(), 'render-'));
  const controller = new AbortController();
  const deadline = setTimeout(() => controller.abort(), 8 * 60_000);
  try {
    const input = join(dir, 'source.mp4');
    const output = join(dir, 'clip.mp4');
    const source = await get(pathname, {
      access: 'private',
      token: process.env.SOURCE_BLOB_READ_WRITE_TOKEN,
      abortSignal: controller.signal,
    });
    if (source?.statusCode !== 200)
      throw new Error('Source video is unavailable');
    let bytes = 0;
    const limit = new Transform({
      transform(chunk, _encoding, callback) {
        bytes += chunk.length;
        callback(
          bytes > MAX_INPUT_BYTES
            ? new Error('Source exceeds 250 MiB')
            : null,
          chunk,
        );
      },
    });
    await pipeline(
      Readable.fromWeb(source.stream as ReadableStream),
      limit,
      createWriteStream(input),
      {
        signal: controller.signal,
      },
    );

    await run(
      'ffmpeg',
      [
        '-nostdin',
        '-loglevel',
        'error',
        '-y',
        '-f',
        'mov',
        '-protocol_whitelist',
        'file',
        '-enable_drefs',
        '0',
        '-use_absolute_path',
        '0',
        '-ss',
        String(job.startSeconds),
        '-i',
        input,
        '-t',
        String(duration),
        '-map',
        '0:v:0',
        '-map',
        '0:a:0?',
        '-vf',
        'scale=1280:720:force_original_aspect_ratio=decrease:' +
          'force_divisible_by=2',
        '-r',
        '30',
        '-c:v',
        'libx264',
        '-preset',
        'veryfast',
        '-threads',
        '2',
        '-c:a',
        'aac',
        '-b:a',
        '128k',
        '-movflags',
        '+faststart',
        '-fs',
        String(MAX_OUTPUT_BYTES),
        output,
      ],
      {
        signal: controller.signal,
        killSignal: 'SIGKILL',
        cwd: dir,
        env: { PATH: process.env.PATH },
      },
    );
    const { size } = await stat(output);
    if (size === 0 || size >= MAX_OUTPUT_BYTES)
      throw new Error('Clip exceeds the output limit');

    const key = createHash('sha256').update(job.jobId).digest('hex');
    const file = createReadStream(output);
    try {
      const clip = await put(`clips/${key}.mp4`, file, {
        access: 'public',
        token: process.env.CLIPS_BLOB_READ_WRITE_TOKEN,
        contentType: 'video/mp4',
        addRandomSuffix: false,
        allowOverwrite: true,
        abortSignal: controller.signal,
      });
      return clip.url;
    } finally {
      file.destroy();
    }
  } finally {
    clearTimeout(deadline);
    await rm(dir, { recursive: true, force: true });
  }
}
```

Create `worker/render.ts` to look up the upload by video ID and session ID. It saves an outcome before sending the callback, which has a separate 30-second deadline. Once an outcome is saved, later deliveries resend it without rendering again.

If rendering fails on the third or a later delivery, the worker saves a failure message. Delivery counts can include interrupted handlers, so they do not measure completed rendering attempts. Temporary files are removed on success and failure:

```typescript
import pg from 'pg';
import { PollingQueueClient } from '@vercel/queue';
import { SessionNotActiveError, sendToSession } from './lib/eve.ts';
import { renderClip, type RenderJob } from './lib/render-clip.ts';
import { idle, shutdown } from './lib/shutdown.ts';

for (const name of [
  'DATABASE_URL',
  'SOURCE_BLOB_READ_WRITE_TOKEN',
  'CLIPS_BLOB_READ_WRITE_TOKEN',
]) {
  if (!process.env[name])
    throw new Error(`Set ${name} before starting the renderer`);
}
const db = new pg.Pool({
  connectionString: process.env.DATABASE_URL,
  max: 1,
  connectionTimeoutMillis: 10_000,
  statement_timeout: 10_000,
  query_timeout: 15_000,
});
const queue = new PollingQueueClient({
  region: 'iad1',
  deploymentId: null,
});
const MAX_DELIVERIES = 3;

async function handleJob(
  job: RenderJob,
  metadata: { deliveryCount: number },
): Promise<void> {
  if (shutdown.signal.aborted) throw new Error('Worker is shutting down');
  const existing = await db.query(
    'SELECT message FROM render_results ' +
      'WHERE job_id = $1 AND session_id = $2',
    [job.jobId, job.sessionId],
  );
  let message: string | undefined = existing.rows[0]?.message;

  if (!message) {
    try {
      const upload = await db.query(
        `SELECT blob_pathname FROM video_uploads
         WHERE video_id = $1 AND session_id = $2
           AND public_clips_allowed = true`,
        [job.videoId, job.sessionId],
      );
      if (!upload.rows[0])
        throw new Error(
          'Video is unavailable or not approved for public clips',
        );
      const url = await renderClip(job, upload.rows[0].blob_pathname);
      message =
        `Render finished for job ${job.jobId}. ` +
        `Public clip URL: ${url}`;
    } catch (error) {
      console.error(error);
      if (metadata.deliveryCount < MAX_DELIVERIES) throw error;
      message =
        `Render failed for job ${job.jobId}. ` +
        'Check the uploaded video and clip range.';
    }

    // Preserve the first saved outcome if two deliveries overlap.
    const saved = await db.query(
      `INSERT INTO render_results (job_id, session_id, message)
       VALUES ($1, $2, $3)
       ON CONFLICT (job_id) DO UPDATE SET message = render_results.message
       WHERE render_results.session_id = EXCLUDED.session_id
       RETURNING message`,
      [job.jobId, job.sessionId, message],
    );
    if (!saved.rows[0])
      throw new Error('Render job belongs to another session');
    message = saved.rows[0].message;
  }

  try {
    await sendToSession(
      job.sessionId,
      message!,
      `render:${job.jobId}`,
      AbortSignal.timeout(30_000),
    );
  } catch (error) {
    if (!(error instanceof SessionNotActiveError)) throw error;
    console.warn(
      `Session ${job.sessionId} is no longer active for job ${job.jobId}`,
    );
  }
}

// Finish the current handler after SIGTERM, then stop receiving.
// Vercel can force-stop unfinished work when the grace period expires.
while (!shutdown.signal.aborted) {
  try {
    const result = await queue.receive<RenderJob>(
      'render-jobs',
      'renderer',
      handleJob,
    );
    if (!result.ok) await idle(2000);
  } catch (error) {
    if (!shutdown.signal.aborted) console.error(error);
    await idle(2000);
  }
}
await db.end();
process.exit(0);
```

On SIGTERM, the renderer stops accepting new jobs and lets its current handler continue. Vercel can force-stop it when the grace period expires. If it stops before saving an outcome, a later delivery may render again; the deterministic Blob path replaces that job's earlier upload. The same callback idempotency key prevents duplicate result messages while eve retains that key.

#### 4\. Configure the renderer

The renderer requires a Pro or Enterprise plan. Request 4 GiB of memory for 2 vCPUs per instance, and set a 600-second shutdown grace period to give the current handler time to finish during a deploy:

```json
{
  "daemons": {
    "renderer": {
      "root": "worker/",
      "entrypoint": "Dockerfile",
      "command": ["node", "render.ts"],
      "instances": { "iad1": 2 },
      "resources": { "requests": { "memory": "4Gi" } },
      "terminationGracePeriodSeconds": 600
    }
  }
}
```

#### How the rendered clip reaches the user

1. The app registers an MP4 upload for the session after its owner approves public clips.
   
2. The user asks the agent to cut a clip from 65s to 100s. The model calls `render_clip` with the video ID, and the tool queues a job with the session ID.
   
3. The agent tells the user the clip is rendering and finishes its turn.
   
4. The renderer checks the upload record, downloads the private source, runs FFmpeg, and uploads the public clip. It saves the outcome in Postgres.
   
5. The daemon posts `Render finished ...` to the session, starting a turn in which the agent shares the clip URL.
   
6. `handleJob` returns, and the SDK acknowledges the queue message. If the callback fails, a later delivery reads the saved outcome and retries the callback.
   

### How do you forward events from a source without webhooks?

Some sources only push events over a long-lived connection, such as IMAP `IDLE` for email, MQTT for devices, or Postgres `LISTEN`. The daemon holds that connection open and starts an agent session for each event. In this example, a Postgres trigger fires when an order is flagged for fraud review, and the agent reviews the order.

This example assumes an `orders` table with `id`, `flagged_at`, and `reviewed_at`. Set `DATABASE_URL_UNPOOLED` to a direct Postgres connection and configure the agent's tools to review an order and set `reviewed_at` when finished.

Add a trigger that sends the order ID on the `order_flagged` channel:

```sql
CREATE OR REPLACE FUNCTION notify_order_flagged() RETURNS trigger AS $$
BEGIN
  PERFORM pg_notify('order_flagged', NEW.id::text);
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER order_flagged
AFTER UPDATE OF flagged_at ON orders
FOR EACH ROW WHEN (OLD.flagged_at IS NULL AND NEW.flagged_at IS NOT NULL)
EXECUTE FUNCTION notify_order_flagged();
```

Postgres doesn't store notifications for disconnected listeners, so the daemon scans unreviewed orders each time it starts. If handing off a live notification fails, the listener exits so Vercel restarts it and reruns the scan.

The agent's tool sets `reviewed_at` when the review finishes, while `operationId` deduplicates overlapping handoffs for that order:

```typescript
import pg from 'pg';
import { startSession } from './lib/eve.ts';
import { shutdown } from './lib/shutdown.ts';

const db = new pg.Client({ connectionString: process.env.DATABASE_URL_UNPOOLED });

async function review(orderId: string): Promise<void> {
  const sessionId = await startSession(
    `Review flagged order ${orderId} and decide whether to hold it.`,
    `flagged-order-${orderId}`,
  );
  console.log(`Order ${orderId} is under review in session ${sessionId}`);
}

function fail(error: unknown): void {
  if (shutdown.signal.aborted) return;
  console.error(error);
  process.exit(1); // the next startup scan retries unreviewed orders
}

// Exit after a failed handoff as well as a lost database connection.
db.on('notification', (notification) => {
  if (!notification.payload || shutdown.signal.aborted) return;
  void review(notification.payload).catch(fail);
});
db.on('error', fail);

shutdown.signal.addEventListener('abort', async () => {
  await db.end();
  process.exit(0);
});

try {
  await db.connect();
  await db.query('LISTEN order_flagged');

  // LISTEN before scanning so notifications cover changes during catch-up.
  const { rows } = await db.query(
    'SELECT id FROM orders WHERE flagged_at IS NOT NULL AND reviewed_at IS NULL',
  );
  for (const row of rows) {
    if (shutdown.signal.aborted) break;
    await review(String(row.id));
  }
} catch (error) {
  fail(error);
}
```

`LISTEN` needs a direct database connection, because transaction-mode connection poolers don't deliver notifications.

Run one instance, since every listener receives every notification:

```json
{
  "daemons": {
    "listener": {
      "root": "worker/",
      "entrypoint": "Dockerfile",
      "command": ["node", "listen.ts"],
      "instances": { "iad1": 1 },
      "resources": { "requests": { "memory": "1Gi" } }
    }
  }
}
```

During a deploy, old and new listeners can receive the same order notification. Use the same `operationId` to deduplicate their create requests and handle startup retries as described in the idempotency section.

### How do you run an agent across thousands of records?

To rewrite the descriptions of 12,000 products in a consistent voice, the backfill daemon claims one product at a time. It runs an agent session for that product and saves the reply before claiming the next one.

With four loops and an average of 15 seconds per turn, the catalog would take about 12.5 hours before retries and other overhead. The loops control how quickly this worker takes products; they do not enforce a global session limit across restarts and deployments.

Set `DATABASE_URL` to a connection that can read and update products.

The example assumes a `products` table with `id`, `name`, and `description`. Apply this migration before running the backfill. If the two timestamp columns already exist from an earlier version of the example, omit those two additions:

```sql
ALTER TABLE products
  ADD COLUMN rewrite_claimed_at timestamptz,
  ADD COLUMN description_rewritten_at timestamptz,
  ADD COLUMN rewrite_attempt integer NOT NULL DEFAULT 1,
  ADD COLUMN rewrite_session_id text,
  ADD COLUMN rewrite_failed_at timestamptz,
  ADD COLUMN rewrite_failure text;
```

The claim query excludes completed and failed products, and reclaims work after 30 minutes if a worker stopped before recording a result. Transport recovery keeps the same stored attempt number and `operationId`, so it can follow the existing session. The worker records failed or cancelled turns and empty completed replies for review instead of reclaiming those products:

```typescript
import pg from 'pg';
import { endSession, startSession, waitForFirstTurn } from './lib/eve.ts';
import { idle, shutdown } from './lib/shutdown.ts';

const CONCURRENCY = 4;
const db = new pg.Pool({
  connectionString: process.env.DATABASE_URL,
  max: CONCURRENCY,
});

const CLAIM_PRODUCT = `
  UPDATE products SET rewrite_claimed_at = now()
  WHERE id = (
    SELECT id FROM products
    WHERE description_rewritten_at IS NULL
      AND rewrite_failed_at IS NULL
      AND (
        rewrite_claimed_at IS NULL
        OR rewrite_claimed_at < now() - interval '30 minutes'
      )
    ORDER BY id
    LIMIT 1
    FOR UPDATE SKIP LOCKED
  )
  RETURNING id, name, description, rewrite_attempt`;

interface Product {
  id: string | number;
  name: string;
  description: string;
  rewrite_attempt: number;
}

async function rewrite(product: Product): Promise<void> {
  const sessionId = await startSession(
    [
      `Rewrite the description for product ${product.id} ` +
        `("${product.name}").`,
      'Reply with only the new description.',
      '',
      `Current description:\n${product.description}`,
    ].join('\n'),
    `rewrite-product-${product.id}-attempt-${product.rewrite_attempt}`,
  );

  await db.query(
    `UPDATE products SET rewrite_session_id = $1
     WHERE id = $2 AND rewrite_attempt = $3`,
    [sessionId, product.id, product.rewrite_attempt],
  );
  const turn = await waitForFirstTurn(sessionId, shutdown.signal);
  const failure =
    turn.outcome !== 'completed'
      ? `Turn ${turn.outcome}`
      : !turn.reply.trim()
        ? 'Empty reply'
        : null;

  // Save a terminal result before requesting session cleanup.
  if (failure) {
    await db.query(
      `UPDATE products SET rewrite_failed_at = now(), rewrite_failure = $1,
         rewrite_claimed_at = NULL
       WHERE id = $2 AND rewrite_attempt = $3
         AND description_rewritten_at IS NULL`,
      [failure, product.id, product.rewrite_attempt],
    );
  } else {
    await db.query(
      `UPDATE products SET description = $1, description_rewritten_at = now(),
         rewrite_claimed_at = NULL
       WHERE id = $2 AND rewrite_attempt = $3
         AND description_rewritten_at IS NULL
         AND rewrite_failed_at IS NULL`,
      [turn.reply, product.id, product.rewrite_attempt],
    );
  }

  try {
    await endSession(
      sessionId,
      failure ? 'Rewrite failed; recorded for review' : 'Description saved',
    );
  } catch (error) {
    // The database result is saved. Do not repeat the rewrite for cleanup.
    console.error(`Could not end session ${sessionId}`, error);
  }
}

async function work(): Promise<void> {
  while (!shutdown.signal.aborted) {
    try {
      const { rows } = await db.query<Product>(CLAIM_PRODUCT);
      const product = rows[0];
      if (!product) {
        // No eligible rows. Inspect failures before stopping the daemon.
        await idle(60_000);
        continue;
      }
      await rewrite(product);
    } catch (error) {
      if (!shutdown.signal.aborted) console.error(error);
      await idle(2000);
    }
  }
}

await Promise.all(Array.from({ length: CONCURRENCY }, work));
await db.end();
process.exit(0);
```

The script saves the description or failure in Postgres before requesting session cleanup. If cleanup fails, the saved result still excludes that product from automatic claims, and `rewrite_session_id` identifies the session for inspection. Sessions that remain open expire under eve's configured idle timeout, which defaults to 30 days.

Inspect failures with:

```sql
SELECT id, name, rewrite_attempt, rewrite_session_id, rewrite_failure
FROM products
WHERE rewrite_failed_at IS NOT NULL
ORDER BY id;
```

After resolving a failure, run this parameterized query with the product ID as `$1` to schedule another attempt:

```sql
UPDATE products
SET rewrite_attempt = rewrite_attempt + 1,
    rewrite_failed_at = NULL,
    rewrite_failure = NULL,
    rewrite_claimed_at = NULL,
    rewrite_session_id = NULL
WHERE id = $1
  AND rewrite_failed_at IS NOT NULL
  AND description_rewritten_at IS NULL
RETURNING id, rewrite_attempt;
```

The query increments the stored attempt before clearing the failure. Its new `operationId` starts fresh even if the previous session's end request failed or the worker stopped before cleanup.

Clearing only the failure fields would reuse the old attempt and read its failed turn again. Record the previous session ID if you need to retry its cleanup.

```json
{
  "daemons": {
    "backfill": {
      "root": "worker/",
      "entrypoint": "Dockerfile",
      "command": ["node", "backfill.ts"],
      "instances": { "iad1": 1 },
      "resources": { "requests": { "memory": "1Gi" } }
    }
  }
}
```

The script idles whenever no products are eligible for claiming, because Vercel restarts an instance when its process exits. Inspect failed products and any outstanding claims before deciding the backfill is complete. Once all products have been updated, [scale the backfill daemon to zero](https://vercel.com/kb/guide/vercel-daemons#how-to-stop-or-delete-a-daemon).

### How do you start an agent only when a metric looks wrong?

The monitoring daemon checks a metrics endpoint and waits 15 seconds between checks. It starts an investigating agent only when the checkout error rate crosses a threshold outside the cooldown period. The agent uses its tools to read logs and post findings for the on-call engineer.

Set `METRICS_URL` to your metrics endpoint and `METRICS_TOKEN` to its bearer token. The endpoint must return JSON with an `errorRate` between 0 and 1 and a `windowStart` string that identifies the measurement window.

```typescript
import { startSession } from './lib/eve.ts';
import { idle, shutdown } from './lib/shutdown.ts';

const THRESHOLD = 0.05; // 5% error rate
const COOLDOWN_MS = 30 * 60_000;
let lastIncidentAt = 0;

async function check(): Promise<void> {
  const res = await fetch(process.env.METRICS_URL!, {
    headers: { authorization: `Bearer ${process.env.METRICS_TOKEN}` },
  });
  if (!res.ok) throw new Error(`Metrics request failed: ${res.status}`);

  const { errorRate, windowStart } = (await res.json()) as {
    errorRate: number;
    windowStart: string;
  };
  if (errorRate < THRESHOLD || Date.now() - lastIncidentAt < COOLDOWN_MS)
    return;

  await startSession(
    `The checkout error rate is ${(errorRate * 100).toFixed(1)}% ` +
      `for the window starting ${windowStart}. ` +
      'Investigate the likely cause and post your findings ' +
      'for the on-call engineer.',
    `incident-checkout-${windowStart}`,
  );
  lastIncidentAt = Date.now();
}

while (!shutdown.signal.aborted) {
  await check().catch((error) => console.error(error));
  await idle(15_000);
}
process.exit(0);
```

The cooldown starts after session creation succeeds, so a failed request is retried on the next poll. It lives in memory and resets when the instance restarts. The metrics window also forms the `operationId`, which deduplicates retries for that window while its session remains resumable.

```json
{
  "daemons": {
    "monitor": {
      "root": "worker/",
      "entrypoint": "Dockerfile",
      "command": ["node", "monitor.ts"],
      "instances": { "iad1": 1 },
      "resources": { "requests": { "memory": "1Gi" } }
    }
  }
}
```

eve schedules have minute granularity. This daemon waits 15 seconds between checks and starts an agent only when the threshold is crossed. If checking once a minute is enough, an eve schedule with a `run` handler can check the metric before sending to the agent.

## How do you avoid duplicate agent runs?

Vercel Queues delivers messages at least once, and eve's crash recovery doesn't make external effects exactly once. Give each boundary its own idempotency key. The triage handler returns after a successful first turn or a saved failure record; the renderer returns after delivering its saved outcome or logging that the session is no longer active.

| Boundary           | Key                                                   | What it prevents                                                                                        |
| ------------------ | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| Publishing a job   | `send(topic, payload, { idempotencyKey })`            | Drops duplicate publishes within `min(retention, 24 hours)`                                             |
| Starting a session | `operationId` on `POST /eve/v1/session`               | Deduplicates creation. Once the session is active, retries return it without sending the message again. |
| Sending a result   | `idempotencyKey` on `POST /eve/v1/session/:sessionId` | Accepts a message once per key. Each session remembers its last 1,024 keys.                             |
| Tool side effects  | Stable key passed to the upstream API                 | Prevents duplicate effects when the upstream API enforces the key                                       |

`operationId` follows three rules:

- **The caller must authenticate.** Anonymous callers can't use `operationId`. Every daemon instance in the project presents the same OIDC identity, so a redelivery to a different instance still matches.
  
- **It works on creation only.** Follow-up requests reject `operationId` with `400 Bad Request`, so use `idempotencyKey` there.
  
- **It frees up when the session can't resume.** After a session ends or times out, the same `operationId` starts a new session. Only end sessions early, as the backfill does, after your own data records the outcome and controls whether another attempt can start.
  

Concurrent create requests can return different session IDs during startup, but only one session runs the first turn. Retry the create request with the same `operationId` after startup to retrieve that session. The queue worker retries through redelivery if following a provisional session fails.

eve recovers an interrupted step by running it again, and a tool call that started before the interruption may already have taken effect.

Pass a stable key to any upstream API that changes data:

```typescript
import { defineTool } from 'eve/tools';
import { z } from 'zod';

export default defineTool({
  description: 'Post a reply to the customer on a support ticket.',
  inputSchema: z.object({ ticketId: z.string(), body: z.string().min(1) }),
  async execute({ ticketId, body }, ctx) {
    const res = await fetch(
      `https://helpdesk.example.com/api/tickets/${ticketId}/replies`,
      {
        method: 'POST',
        headers: {
          authorization: `Bearer ${process.env.HELPDESK_TOKEN}`,
          'content-type': 'application/json',
          // The same call retried after a crash sends the same key.
          'idempotency-key': `${ctx.session.id}:${ctx.callId}`,
        },
        body: JSON.stringify({ body }),
      },
    );
    if (!res.ok) throw new Error(`Helpdesk returned ${res.status}`);
    return { posted: true };
  },
});
```

Using the call ID as the key protects against a retried step. When the effect must happen once per job, such as one reply per ticket, key on the job instead and check whether it already happened. The [eve tools reference](https://eve.dev/docs/tools#when-a-tool-throws) covers these options.

## How do you control concurrency and cost?

Two triage instances with four loops each normally wait on up to eight first turns at a time. Vercel doesn't autoscale daemons, so queued work waits when those loops are occupied. Active sessions can exceed eight during a rollout or after a worker loses its connection because sessions continue independently of the worker.

Adjust throughput by changing the worker's loop count or the `instances` configuration in `vercel.json`. You can also [scale instances from the dashboard](https://vercel.com/kb/guide/vercel-daemons#how-to-scale-a-daemon) without a deploy.

Strict limits across deployments need shared permits that remain assigned until the corresponding sessions settle. Apply model and API rate limits separately, and configure [per-session usage limits](https://eve.dev/docs/agent-config#limits) to control model spend.

Costs come from two sides:

- **Daemon side:** Time an instance spends waiting on a stream or a queue doesn't count as Active CPU, but Provisioned Memory counts every hour an instance runs. Choose `"1Gi"` for workers that mostly wait on HTTP, and reserve `"4Gi"` or `"8Gi"` for CPU-heavy work like rendering.
  
- **Agent side:** eve sessions use the billing of the resources they consume, including [Vercel Functions](https://vercel.com/docs/functions/usage-and-pricing), [Vercel Workflows](https://vercel.com/docs/workflows/pricing), [AI Gateway](https://vercel.com/docs/ai-gateway/pricing), and [Vercel Sandbox](https://vercel.com/docs/sandbox/pricing) when tools use it.
  

The Hobby plan gives daemons across all of a team's projects 5 Active CPU-hours and 420 GB-hours of Provisioned Memory per month. One 1 GiB instance that runs without stopping uses that memory quota in about 18 days, and Vercel stops all of the team's daemons when either quota runs out.

Use Hobby to try the 1 GiB workers and the [Pro plan](https://vercel.com/docs/plans/pro-plan) for workers that need to run all month. The 4 GiB renderer requires Pro or Enterprise.

On Pro, configure [Spend Management](https://vercel.com/docs/spend-management) with **Pause Production Deployments** to stop the project's daemons when the budget action triggers. Checks can lag by several minutes. Set model budgets separately because pausing the project does not stop AI Gateway API-key usage or direct provider charges.

## What happens on deploy or shutdown?

Deployments replace daemon instances, but eve sessions keep running on Vercel Workflows, independent of any daemon connection. Design each daemon so stopping it at any point leaves its work recoverable.

### The daemon stops in the middle of a turn

When Vercel sends `SIGTERM`, the triage daemon aborts `waitForFirstTurn` and throws, so it doesn't acknowledge the message. The agent's turn keeps running.

After the message's visibility timeout expires, another instance receives it and calls `POST /eve/v1/session` with the same `operationId`. Once startup has resolved, the request returns the existing session. Reading its stream from line `0` recovers the first turn's result, including one that settled before the worker reconnected.

The renderer stops taking new jobs on `SIGTERM` and has up to 600 seconds to finish its current handler. If Vercel stops it before the handler returns, the queue can redeliver the message.

The worker reads a saved outcome before attempting another render. If it stopped before saving that outcome, it may render again and replace the upload at the same Blob path. Retrying the callback uses the same `idempotencyKey`.

### Old and new daemon versions overlap

Instances from the previous deployment keep processing during their shutdown grace period while new instances start. With `deploymentId: null`, both versions read the same queue and must accept the same message format. Add new payload fields as optional and retain old fields until every publisher uses the updated format.

### Sessions move between agent deployments

Sessions with a running turn, pending input, or active tasks stay on their current deployment. In the rendering example, the turn ends after the tool queues the job. When the result arrives, it can move that idle session to the current deployment, where the next turn uses the new system instructions, model, and tools.

If the new deployment cannot read the session's checkpoint, the session stays on the previous deployment and processes the result there. Keep tools that older sessions may call, such as `render_clip`, compatible across deploys.

Sessions don't become stranded on Vercel, because each run executes on the deployment that started it. Stranding happens in local development and self-hosted setups when the eve version changes.

## How do you test locally?

Run the agent with `eve dev` and the worker scripts with Node.js on the same machine. Daemons run only in production during the beta, and `vercel dev` doesn't start them.

### 1\. Start the agent

Pull the project's development environment variables, including a Vercel OIDC token, then start the eve development server:

```bash
vercel env pull .env.local
pnpm exec eve dev
```

The server listens on `http://localhost:2000`. `localDev()` in the channel's auth list accepts requests from the same machine.

### 2\. Run a worker against it

Point the worker at the local agent with `EVE_URL`. Use a separate topic so the local worker doesn't take messages from the production daemon:

```bash
cd worker
EVE_URL=http://localhost:2000 TICKETS_TOPIC=support-tickets-dev \
  node --env-file=../.env.local triage.ts
```

### 3\. Publish a test job

Save the following code as `worker/send-test-ticket.ts` to publish a ticket to the development topic:

```typescript
import { QueueClient } from '@vercel/queue';

const queue = new QueueClient({ region: 'iad1', deploymentId: null });

await queue.send('support-tickets-dev', {
  id: 'test-1',
  subject: 'Card declined at checkout',
  body: 'My card was declined twice, but the bank says the charge went through.',
  customerEmail: 'customer@example.com',
});
```

From another terminal at the repository root, run the test publisher:

```bash
cd worker
node --env-file=../.env.local send-test-ticket.ts
```

The worker logs a line like this after the agent completes the first turn successfully:

```plaintext
Triaged ticket test-1 in session wrun_01J9...
```

### 4\. Test the image and shutdown handling

Build the image and run it with Docker to check the `Dockerfile` and `SIGTERM` handling. Copy the values the worker needs into `.env.docker` as unquoted `KEY=value` lines, and keep the file out of version control. With `--network host`, the container reaches `eve dev` on `localhost`. Docker Desktop on macOS and Windows supports host networking only when you turn it on in its settings.

```bash
docker build -t agent-worker worker/
docker run --rm --name triage --network host --env-file .env.docker \
  -e EVE_URL=http://localhost:2000 -e TICKETS_TOPIC=support-tickets-dev \
  agent-worker node triage.ts
```

Run `docker stop --timeout 30 triage` from another terminal. Docker sends `SIGTERM` and waits up to 30 seconds before force-stopping the container, matching Vercel's default grace period.

## Troubleshooting

### Session routes return 401

The eve channel rejected the daemon's token. Check that the daemon sends `Authorization: Bearer <token>` with a token from `@vercel/oidc` 4.0 or later, since earlier versions can't read the token file daemons receive.

For workers in another project, add the worker project to `vercelOidc({ subjects })` and set `EVE_URL` to the agent's production origin. The helper's default URL belongs to the project running the daemon.

If the production domain uses [Deployment Protection](https://vercel.com/docs/deployment-protection), the protection layer can reject the request before eve sees it.

### Follow-up requests return 409 session\_not\_ready

The session was accepted, but its inbox isn't ready for input yet. This happens when a message follows session creation closely. Retry with bounded backoff, as `sendToSession` does. eve's TypeScript client retries this code for up to 20s.

### Follow-up requests return 409 session\_not\_active

The session ended or hit its idle timeout, which is 30 days by default, so no one is waiting for the result. Acknowledge the job and log it, or start a new session if the result still needs an agent. Change the timeout with `limits.sessionTimeoutMs` in `agent/agent.ts`.

### Requests return `409 session_stranded` during local development

After an eve upgrade, `eve dev` can encounter a session built with a different version. Stranded sessions can't run, so end the session and create a new one. On Vercel, each run keeps executing on the deployment that started it, so production sessions don't strand.

### The create request returns 400 for operationId

`operationId` is accepted only on `POST /eve/v1/session`. Follow-up requests reject unknown fields with `400 Bad Request`, so use `idempotencyKey` on `POST /eve/v1/session/:sessionId`.

### The daemon never receives messages the agent publishes

Check that both sides create their queue clients with `deploymentId: null` and the same region. With the default pinning, a tool running in an older deployment's session publishes to a partition the current daemon doesn't read. Also confirm both sides use the same topic name.

### The daemon keeps restarting

Vercel restarts any instance whose process exits, so a script that returns when its work runs out restarts in a loop. Keep the process alive with an idle wait, as the backfill does. Other common causes include an unhandled promise rejection, a missing environment variable such as `DATABASE_URL`, and a `Dockerfile` that skips `npm ci`. Filter your project's [Logs](https://vercel.com/docs/logs/runtime) by the source `daemon` and the daemon's name to see the exit reason.

## Next steps

- Configure, scale, and stop daemons with the [Vercel Daemons guide](https://vercel.com/kb/guide/vercel-daemons).
  
- Deploy and authenticate your agent with [eve on Vercel](https://vercel.com/docs/frameworks/agents/eve).
  
- Look up every session route and field in the [eve channel reference](https://eve.dev/docs/channels/eve).
  
- Learn how sessions checkpoint, wait, and recover in [eve's execution model](https://eve.dev/docs/concepts/execution-model-and-durability).
  
- Consume queues from a daemon with [Vercel Queues poll mode](https://vercel.com/docs/queues/poll-mode).
  
- Add durable waits to your own tools with [Vercel Workflows](https://vercel.com/docs/workflows) and [eve workflow tools](https://eve.dev/docs/tools/workflows).

## Related Documentation

- [Vercel Daemons](https://vercel.com/docs/daemons)
- [eve](https://eve.dev/docs)

## More Vercel Daemons guides

- [Running background workers on Vercel](/kb/guide/background-workers-on-vercel): Run background jobs on Vercel with waitUntil, Cron Jobs, Queues, Workflows, Daemons, or Sandbox. Compare the options and pick the right one for your workload.
- [Migrate Fly.io worker processes to Vercel Daemons](/kb/guide/migrate-fly-io-workers-to-vercel-daemons): Migrate Fly.io workers to Vercel Daemons with your existing Dockerfile. Map process settings, preserve queue state, and plan cutover and rollback.
- [Migrate Render background workers to Vercel Daemons](/kb/guide/migrate-render-workers-to-vercel-daemons): Migrate Render workers to Vercel Daemons. Translate render.yaml, package Celery in Docker, prepare the broker, and verify the queue handoff.