A schedule runs the agent on a cron expression, with nobody asking: a
morning summary, a nightly cleanup. defineSchedule() declares one, an
agent directory picks them up from schedules/, and
startSchedules() (or the node server) runs them in process. Schedules replaced
CronTriggerAdapter (see Migrating from triggers).
Give a cron expression and exactly one of prompt (text sent to the agent as a
new turn) or run (your own function).
import { createAgent, createMockProvider, defineSchedule, startSchedules } from 'lousho';
const agent = createAgent({ instructions: 'You write reports.', provider: createMockProvider() });
const morning = defineSchedule({ cron: '0 9 * * MON-FRI', timezone: 'Europe/Paris', prompt: 'Summarise yesterday.' });
const cleanup = defineSchedule({
name: 'cleanup',
cron: '@daily',
run: async ({ agent, firedAt }) => {
await agent.send(`Clean up, as of ${firedAt.toISOString()}.`);
},
});
const running = startSchedules(agent, [morning, cleanup]);
// later, on shutdown (resolves once the runs in flight have finished):
await running.stop();cron: five fields (minute hour day-of-month month day-of-week) or@hourly,@daily,@weekly,@monthly; evaluated intimezone(an IANA name, default the machine's zone). An invalid expression, or both/neither ofpromptandrun, throwsLOUSHO_SCHEDULE_INVALIDfromdefineSchedule(), not at the first fire. See Errors.runreceives{ agent, firedAt, name }.
startSchedules(agent, schedules, { now?, setTimer?, onError?, keepAlive? }) keeps one
timer per schedule. A run that throws goes to onError (default: console.error)
and never stops the other schedules. A prompt schedule's turn that ends with any
finishReason other than 'stop' is a failed run too: onError receives a
LOUSHO_SCHEDULE_RUN_INCOMPLETE error naming the reason, for example
'awaiting-approval' (with the pending approval's id, which stays pending until
you resolve it with agent.approvals.resolve()), 'output-invalid' or
'max-steps'. A schedule does not overlap itself: if the
previous fire is still running, the next one is skipped and reported to
onError. Fires missed while the process was suspended are skipped, not
replayed. now and setTimer are injectable so tests never sleep.
stop() clears the timers, so nothing fires again, and returns a promise that
resolves once the runs already in flight have finished. You can call it without
await.
The timers are unref'd: they never keep a process alive on their own, so a
server or a test exits normally. A script that does nothing but run schedules
would therefore exit straight away; pass keepAlive: true for that:
import { createAgent, createMockProvider, defineSchedule, startSchedules } from 'lousho';
const agent = createAgent({ instructions: 'You write reports.', provider: createMockProvider() });
const running = startSchedules(agent, [defineSchedule({ cron: '@hourly', prompt: 'Check the queue.' })], { keepAlive: true });
process.once('SIGTERM', () => void running.stop().then(() => agent.close()));fireSchedule(agent, schedule, { name?, firedAt?, sessionId? }) fires a schedule
once, immediately, exactly as a tick would: it calls run, or sends prompt
as a turn (in sessionId when given, which needs an agent with a store; by
default each fire runs in its own session, schedule-<name>-<fire time>, when the agent has
a store, and schedule-<name> when the schedule sets sharedSession: true). It
rejects when the run fails, including with LOUSHO_SCHEDULE_RUN_INCOMPLETE, so
it suits an ops "run now" and tests.
With an agent store (file, SQLite, KV or memoryStore()), each fire first
claims its schedule name and fire time (to the second) in store.checkpoints,
so several replicas running startSchedules() on one store fire each tick once:
the replicas that lose the claim resolve without running anything. Two manual
fires of one schedule within the same second count as one tick too. On KV the
claim is best effort across edge locations (see
Durable execution).
import { createAgent, createMockProvider, defineSchedule, fireSchedule } from 'lousho';
const agent = createAgent({ instructions: 'You write reports.', provider: createMockProvider() });
const report = defineSchedule({ name: 'report', cron: '0 9 * * *', prompt: 'Run the daily report.' });
await fireSchedule(agent, report);A prompt schedule's fire is an agent.send(prompt, { sessionId }) run, not an
agent.session({ id }) turn (see
Two kinds of conversation id). Its
id is schedule-<name>-<fire time> (schedule-<name> with
sharedSession: true), and its conversation is kept in the agent's
store.checkpoints, as a finished checkpoint, when the agent has one; without
a checkpoint store the fire runs without an id and keeps nothing.
GET /chat/<id> and agent.session({ id }).load() read the session store, so
they show an empty transcript for a schedule id. Read a fire's report from the
checkpoint store instead, or continue it with agent.fork(id):
import { createAgent, createMockProvider, defineSchedule, fireSchedule, memoryStore } from 'lousho';
const store = memoryStore();
const agent = createAgent({ instructions: 'You write reports.', provider: createMockProvider(), store });
const firedAt = new Date('2026-10-05T09:00:00Z');
await fireSchedule(agent, defineSchedule({ name: 'weekly-report', cron: '0 9 * * MON', prompt: 'Summarise last week.' }), { firedAt });
const fire = await store.checkpoints.load('schedule-weekly-report-2026-10-05T090000Z');
console.log(fire?.status, fire?.messages.at(-1)?.content); // 'finished', the reportmy-agent/
instructions.md
schedules/
daily-report.ts # export default defineSchedule({ cron: '0 9 * * *', prompt: '...' })
nightly.ts # name defaults to the file name ("nightly") unless the schedule sets `name`
resolveAgentDir() returns them as schedules (and their names in
manifest.schedules); loadAgentDir() does not start them. A directory without
schedules/ loads exactly as before.
import { createAgent, resolveAgentDir, startSchedules } from 'lousho';
const { config, schedules } = await resolveAgentDir('./my-agent');
const agent = createAgent(config);
const running = startSchedules(agent, schedules);createDeployedServer(agent, { schedules }) (the internal helper behind the
server that lousho build emits for the node-server
and docker targets) starts the schedules when it listens and stops them when it
closes. lousho build ./my-agent --target=node-server (or docker) builds an
agent directory into such a server, so its schedules/ run in the deployed
process (Deployment). A spec's cron
triggers ({ type: 'cron', cron, input, name?, timezone? }) run on these
targets too: lousho build spec.yaml --target=node-server converts them with the
same rules as the Worker and the built server starts them when it listens
(timezone is supported; an invalid trigger fails the build with
LOUSHO_SCHEDULE_INVALID). The model calls run in the server process, so the
provider's package must be installed where it runs. On Cloudflare Workers see
below.
lousho dev ./my-agent starts the directory's schedules, so a cron fires while
you develop, and a hot reload stops the old schedules before starting the new
ones. Starting is the default so that dev behaves like the deployed server;
because firing crons (and spending model calls) while you edit is often
unwanted, --no-schedules mounts the channels but starts no schedule.
A spec file's cron triggers start the same way (a v2 spec shown; v1's
{ type: 'cron', cron, input, name? } works too), and --no-schedules starts
none of them either:
triggers:
- id: daily-report # the schedule's name; v1 uses name: and input:
type: cron
cron: "0 9 * * *"
prompt: Write the daily report.Trigger kinds lousho dev does not run — webhook, channel and agent —
are logged once per load (Agent Forge runs them).
A Worker has no long-lived process, so no timers: Cloudflare calls the Worker's
scheduled() handler once per cron expression listed under [triggers] crons
in wrangler.toml. The cloudflare-worker target generates both from the
triggers of the agent spec — and, for an agent directory, from its
schedules/ files, which follow the same rules below:
triggers:
- type: cron
name: weekly-report # optional, default cron-1, cron-2, ...
cron: "0 9 * * MON" # five fields, UTC
input: Summarise last week.lousho build writes the deduplicated expressions to [triggers] crons and the
Worker's scheduled() runs every trigger whose cron equals the invoked one as
an agent turn inside ctx.waitUntil(). Each fire has its own session,
schedule-<name>-<fire time> (for example schedule-weekly-report-2026-10-05T090000Z),
so a daily job does not re-send every earlier report, and its run can be read
back from the checkpoints in KV when AGENT_CHECKPOINTS is bound (see
Prompt schedules and sessions; not
GET /chat/<id>). Set sharedSession: true on a defineSchedule() prompt
schedule to keep one schedule-<name> session instead; its conversation then
grows with every fire. The run
happens inside ctx.waitUntil(), past the request's lifetime, so give the
agent a store (KVStore on AGENT_CHECKPOINTS) — an in-memory one keeps the
schedule sessions only until the isolate is recycled. A failing
trigger is logged with console.error (name and error code) and never
stops the others, and scheduled() never throws. A turn that ends with a
finishReason other than 'stop' is logged the same way, as
LOUSHO_SCHEDULE_RUN_INCOMPLETE.
Cloudflare's cron triggers differ from the in-process ones, so the build fails
with LOUSHO_SCHEDULE_INVALID naming the trigger instead of emitting a config
that deploys and never fires: expressions are UTC (no timezone), exactly five
fields (no seconds, no @daily), and the day-of-week must be * or names
(MON-FRI), because Cloudflare numbers days 1-7 from Sunday. The finest
granularity is one minute, and Cloudflare may start a run a few seconds late.
To wire your own Worker entry, see Cloudflare Workers.