Skip to content
Docs

Scheduling Work with the AI SDK

With the AI SDK, add a tool that creates a schedule, and run the scheduled prompt with generateText when it fires.

Bind the tool to the user who is chatting, so the model can only schedule for them. The toolCallId gives each schedule a unique name:

lib/tools.ts
import { tool } from 'ai';
import { z } from 'zod';
import { Schedules } from '@vercel/schedules';
 
export const scheduleTask = (userId: string, timezone: string) =>
  tool({
    description: 'Schedule work to run later, once or on a recurring basis',
    inputSchema: z.object({
      prompt: z.string().describe('Standalone instruction to run when the task fires'),
      cron: z.string().optional().describe('Cron expression, for recurring tasks'),
      at: z.string().optional().describe('Local time YYYY-MM-DDTHH:mm, for one-time tasks'),
    }),
    execute: async ({ prompt, cron, at }, { toolCallId }) => {
      await Schedules.create({
        name: `task-${toolCallId}`,
        namespace: userId,
        expression: cron ? { type: 'cron', cron } : { type: 'single', at: at! },
        timezone,
        target: { topic: 'agent-tasks' },
        payload: { prompt },
      });
      return 'Scheduled.';
    },
  });

Pass it to streamText in your chat route:

app/api/chat/route.ts
import { convertToModelMessages, stepCountIs, streamText, type UIMessage } from 'ai';
import { scheduleTask } from '@/lib/tools';
 
export async function POST(request: Request) {
  const { messages, userId, timezone } = (await request.json()) as {
    messages: UIMessage[];
    userId: string;
    timezone: string;
  };
 
  const result = streamText({
    model: 'anthropic/claude-sonnet-5.5',
    system: `The user's time zone is ${timezone}. It is now ${new Date().toISOString()}.`,
    messages: await convertToModelMessages(messages),
    tools: { schedule_task: scheduleTask(userId, timezone) },
    stopWhen: stepCountIs(5),
  });
 
  return result.toUIMessageStreamResponse();
}

In production, take the user's ID from your authentication, not the request body.

The consumer runs the stored prompt as a new turn. The scheduled turn has no chat history, so the prompt has to stand on its own. Deliver the result however your app reaches users, such as email, Slack, or a saved message. A message can be delivered more than once, so pass executionId along to skip a run you've already delivered:

app/api/agent-tasks/route.ts
import { handleCallback } from '@vercel/queue';
import type { ScheduleQueueMessage } from '@vercel/schedules';
import { generateText } from 'ai';
import { notifyUser } from '@/lib/notify';
 
type TaskMessage = ScheduleQueueMessage<{ prompt: string }>;
 
export const POST = handleCallback<TaskMessage>(async ({ executionId, namespace, payload }) => {
  if (!payload) return;
  const { text } = await generateText({
    model: 'anthropic/claude-sonnet-5.5',
    prompt: payload.prompt,
  });
  await notifyUser(namespace, text, { id: executionId });
});
vercel.json
{
  "functions": {
    "app/api/agent-tasks/route.ts": {
      "experimentalTriggers": [{ "type": "queue/v2beta", "topic": "agent-tasks" }]
    }
  }
}
Last updated November 4, 2026

Was this helpful?

supported.