eve
Version:
Filesystem-first framework for durable backend AI agents that run anywhere.
258 lines (217 loc) • 8.88 kB
Markdown
---
title: "Dynamic scheduling"
description: "Compose one minute-level eve schedule, proactive channel handoff, and CRUD tools into application-managed schedules."
---
Authored eve schedules are static files discovered at build time. You can build dynamic scheduling today by putting schedule rows in your application store and using one authored schedule as a dispatcher:
1. CRUD tools let the agent create and manage rows for the current tenant;
2. `defineSchedule({ cron: "* * * * *" })` wakes once a minute;
3. the handler atomically claims due rows;
4. `receive(...)` starts a normal durable agent session for each row.
PostgreSQL or a durable KV store can back the adapter. The important storage capability is an atomic lease, not a particular schema.
```text
agent/
channels/slack.ts
lib/schedule-store.ts # your storage adapter
lib/tenant.ts
schedules/dynamic.ts
tools/create_schedule.ts
tools/delete_schedule.ts
tools/list_schedules.ts
tools/update_schedule.ts
```
## Dispatch due schedules every minute
This is the only authored schedule. It looks up due application-managed rows and hands each one to Slack as a proactive session:
```ts title="agent/schedules/dynamic.ts"
import { defineSchedule } from "eve/schedules";
import slack from "../channels/slack";
import { scheduleStore } from "../lib/schedule-store";
export default defineSchedule({
cron: "* * * * *",
run({ receive, waitUntil }) {
waitUntil(
(async () => {
const jobs = await scheduleStore.claimDue({
now: new Date(),
limit: 25,
leaseForMs: 5 * 60_000,
});
await Promise.all(
jobs.map(async (job) => {
try {
await receive(slack, {
message: [
`Run dynamic schedule ${job.id}.`,
"Complete this tenant-owned task:",
job.prompt,
].join("\n\n"),
target: { channelId: job.channelId },
auth: {
attributes: {
tenantId: job.tenantId,
role: job.ownerRole,
scheduleId: job.id,
},
authenticator: job.authenticator,
...(job.issuer ? { issuer: job.issuer } : {}),
principalId: job.ownerId,
principalType: "user",
},
});
await scheduleStore.complete(job);
} catch (error) {
await scheduleStore.release(job, { error, retryAt: new Date(Date.now() + 300_000) });
}
}),
);
})(),
);
},
});
```
`waitUntil` keeps the cron invocation alive until claiming and handoff settle. `receive` starts the same durable runtime used by inbound channel messages.
This example uses Slack because it has a proactive target of `{ channelId }`. Any channel that implements `receive` can replace it.
Configure Slack normally:
```ts title="agent/channels/slack.ts"
import { connectSlackCredentials } from "@vercel/connect/eve";
import { slackChannel } from "eve/channels/slack";
export default slackChannel({
credentials: connectSlackCredentials("slack/my-agent"),
});
```
## Give the agent CRUD tools
Tenant and owner identity come from `ctx.session`, never the model:
```ts title="agent/lib/tenant.ts"
import type { SessionAuthContext, SessionContext } from "eve/context";
export function requireScheduleOwner(ctx: SessionContext): {
tenantId: string;
userId: string;
auth: SessionAuthContext;
} {
const auth = ctx.session.auth.current;
const tenantId = auth?.attributes.tenantId;
if (auth?.principalType !== "user" || typeof tenantId !== "string") {
throw new Error("An authenticated tenant user is required.");
}
return { tenantId, userId: auth.principalId, auth };
}
```
Create a one-time schedule with `everyMinutes: null`, or a recurring one with an interval:
```ts title="agent/tools/create_schedule.ts"
import { defineTool } from "eve/tools";
import { z } from "zod";
import { scheduleStore } from "../lib/schedule-store";
import { requireScheduleOwner } from "../lib/tenant";
export default defineTool({
description: "Create a one-time or repeating scheduled agent run for this tenant.",
inputSchema: z.object({
prompt: z.string().min(1).max(8000),
channelId: z.string().min(1),
firstRunAt: z.string().datetime({ offset: true }),
everyMinutes: z.number().int().min(1).max(525600).nullable().default(null),
}),
async execute(input, ctx) {
return await scheduleStore.create(requireScheduleOwner(ctx), {
...input,
firstRunAt: new Date(input.firstRunAt),
});
},
});
```
```ts title="agent/tools/list_schedules.ts"
import { defineTool } from "eve/tools";
import { z } from "zod";
import { scheduleStore } from "../lib/schedule-store";
import { requireScheduleOwner } from "../lib/tenant";
export default defineTool({
description: "List this tenant's dynamic schedules and their latest status.",
inputSchema: z.object({}),
async execute(_input, ctx) {
return await scheduleStore.list(requireScheduleOwner(ctx));
},
});
```
```ts title="agent/tools/update_schedule.ts"
import { defineTool } from "eve/tools";
import { z } from "zod";
import { scheduleStore } from "../lib/schedule-store";
import { requireScheduleOwner } from "../lib/tenant";
export default defineTool({
description: "Change, pause, or resume one of this tenant's schedules.",
inputSchema: z.object({
id: z.string().uuid(),
prompt: z.string().min(1).max(8000).optional(),
channelId: z.string().min(1).optional(),
nextRunAt: z.string().datetime({ offset: true }).optional(),
everyMinutes: z.number().int().min(1).max(525600).nullable().optional(),
enabled: z.boolean().optional(),
}),
async execute({ id, nextRunAt, ...patch }, ctx) {
return await scheduleStore.update(requireScheduleOwner(ctx), id, {
...patch,
...(nextRunAt ? { nextRunAt: new Date(nextRunAt) } : {}),
});
},
});
```
```ts title="agent/tools/delete_schedule.ts"
import { defineTool } from "eve/tools";
import { always } from "eve/tools/approval";
import { z } from "zod";
import { scheduleStore } from "../lib/schedule-store";
import { requireScheduleOwner } from "../lib/tenant";
export default defineTool({
description: "Permanently delete one of this tenant's schedules.",
inputSchema: z.object({ id: z.string().uuid() }),
approval: always(),
async execute({ id }, ctx) {
return { deleted: await scheduleStore.delete(requireScheduleOwner(ctx), id) };
},
});
```
## Supply the schedule adapter
The eve-facing implementation depends on this shape, not a database schema:
```ts title="agent/lib/schedule-store.ts"
import type { SessionAuthContext } from "eve/context";
export interface ScheduleOwner {
tenantId: string;
userId: string;
auth: SessionAuthContext;
}
export interface ClaimedSchedule {
id: string;
leaseToken: string;
tenantId: string;
ownerId: string;
ownerRole: string;
authenticator: string;
issuer?: string;
prompt: string;
channelId: string;
everyMinutes: number | null;
}
export interface ScheduleStore {
create(owner: ScheduleOwner, input: unknown): Promise<unknown>;
list(owner: ScheduleOwner): Promise<unknown[]>;
update(owner: ScheduleOwner, id: string, patch: unknown): Promise<unknown>;
delete(owner: ScheduleOwner, id: string): Promise<boolean>;
claimDue(options: { now: Date; limit: number; leaseForMs: number }): Promise<ClaimedSchedule[]>;
complete(job: ClaimedSchedule): Promise<void>;
release(job: ClaimedSchedule, failure: { error: unknown; retryAt: Date }): Promise<void>;
}
export { scheduleStore } from "../../lib/schedule-store";
```
Implement that adapter with whichever durable store already belongs to your application. It must preserve a few semantics:
- user-facing CRUD is always tenant-scoped;
- `claimDue` atomically leases rows so overlapping minute ticks do not claim the same work;
- dispatch revalidates the owner and destination before returning a job;
- `complete` disables one-time rows or computes the next recurring run;
- expired leases are recoverable.
Delivery is at least once. A crash after `receive` succeeds but before `complete` can dispatch again, so side-effecting tasks need application-level idempotency.
## Scheduling instructions
```md title="agent/instructions.md"
Before creating a schedule, confirm the user's time zone and destination.
Convert the first run to ISO 8601 with an explicit offset. Use everyMinutes only
for repeating work and null for a one-time run. List schedules before changing
an ambiguous one.
```
The eve-specific core is small: four tools, one one-minute `defineSchedule`, and proactive `receive`. Storage and recurrence policy stay behind the application's adapter.