Run Code on a Schedule


The Events API is in beta. Event shapes, limits, and behavior may change between releases.
Schedules are available on puter.com. A self-hosted Puter server answers events_disabled.

Some work happens on a clock instead of in response to something the user did. For example, sending a summary every weekday morning, clearing out old data every night, or checking a feed every hour. On other platforms you'd use a cron job or a scheduled cloud function.

In Puter, you write a function, publish it once, and give each user a schedule that runs it. Puter runs it in your app's events worker, a serverless function that belongs to your app, at the times the schedule says. You don't need to run a server.

Run Code in the Background runs a function when a user's files change. This recipe runs one at set times instead, with puter.events.onSchedule(). The examples build a task list app that sends each user a notification at 8:00 every weekday morning, in their own time zone, with how many tasks they have open.

Publish the Handler

The function a schedule runs is called a handler. Publish it with puter.events.handlers.publish(). This is a developer step, like a deploy, and it only works for the account that owns the app. Run it from a deploy script or your browser console:

const app = await puter.apps.get('task-list');    // your app's name

async function sendSummary ({ user }) {
    const tasks = (await user.kv.get('tasks')) ?? [];
    const open = tasks.filter((task) => !task.done);
    if (open.length === 0) return;

    await user.notifications.notify({
        title: 'Good morning',
        text: `You have ${open.length} open tasks today.`,
        target: '/tasks',
        group: 'morning-summary',
    });
}

await puter.events.handlers.publish('sendSummary', sendSummary, { appUid: app.uid });

The handler gets one object as its argument. This one uses user, a puter object signed in as the user the schedule belongs to. It can do whatever your app can do for that user, like user.kv and user.notifications. It's valid for 15 minutes, so use it only during the run.

Puter stores the handler's code and runs it later in the events worker, so the handler can only use its parameters, its own variables, and standard globals like fetch, JSON and Math. It uses user instead of puter. The publish() method checks this, and fails with events_handler_free_variable if the handler uses a name it doesn't define.

The notification reaches every open page of your app, and goes out as a push when none is open and the user turned pushes on. Reach a User When Your App Is Closed shows how to set pushes up. The group makes each morning's summary replace the one before it.

Ask for Permission

A schedule runs code while the user isn't there, so it needs the user's permission. Ask from your app with puter.perms.request():

const allowed = await puter.perms.request('backgroundEvents');

It resolves to true or false. If the permission is already granted, the user doesn't see a prompt. Without it, onSchedule() fails with events_background_consent_required.

If the user revokes the permission later, every schedule your app holds for them ends. Granting it again doesn't bring them back, so your app has to create them again. Schedules also need a Puter account. For a guest account, onSchedule() fails with events_durable_requires_account.

Create the Schedule Once per User

The schedule says when to run the handler. Here it's a cron expression with the user's time zone:

const options = {
    cron: '0 8 * * 1-5',
    tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
    handlerName: 'sendSummary',
};

The five fields of 0 8 * * 1-5 are the minute, the hour, the day of the month, the month and the day of the week, so it means 8:00 on Monday to Friday. The tz option is the browser's time zone, such as 'America/New_York'. The schedule follows that zone's daylight saving time, so the summary arrives at 8:00 local time all year. The onSchedule() page lists the cron syntax it accepts.

Every onSchedule() call creates a new schedule, even with the same options. If you call it each time the app opens, the user gets one more summary each morning for every time they opened it. Check puter.events.listSchedules() first:

async function findSchedule () {
    let cursor = null;
    do {
        const page = await puter.events.listSchedules({ cursor });
        const found = page.items.find((schedule) =>
            schedule.handlerName === options.handlerName && schedule.state !== 'ended');
        if (found) return found;
        cursor = page.cursor ?? null;
    } while (cursor);
    return null;
}

async function startMorningSummary () {
    if (await findSchedule()) return;
    await puter.events.onSchedule(options);
}

Call startMorningSummary() when the app opens, after the user has given permission. A few details:

If two tabs open at the same moment, both might create a schedule. If listSchedules() finds more than one, keep one and remove the rest with puter.events.removeSchedule().

Skip Summaries Already Sent

Each run happens at least once. A run that times out, or fails with a network error, is retried, and a retry can repeat work the first attempt already did. Every attempt at the same run has the same event.id, so the handler saves it after sending and skips ids it has already sent. The marker key expires after a week, so old markers are removed from the user's key-value store on their own:

async function sendSummary ({ event, user }) {
    const sentKey = `summary-sent:${event.id}`;
    if (await user.kv.get(sentKey)) return;

    const tasks = (await user.kv.get('tasks')) ?? [];
    const open = tasks.filter((task) => !task.done);
    if (open.length > 0) {
        await user.notifications.notify({
            title: 'Good morning',
            text: `You have ${open.length} open tasks today.`,
            target: '/tasks',
            group: 'morning-summary',
        });
    }

    await user.kv.set(sentKey, true, Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60);
}

This skips a retry of a run that already finished. It doesn't rule out every repeat. Two attempts that overlap, such as a run still going past its 30 seconds when its retry starts, can both pass the check, and a run that stops after sending but before saving the key sends again when it's retried. Here a repeat carries the same group, so it replaces the summary that's showing instead of adding a second one. For work that must happen exactly once, such as a payment, make the call itself safe to repeat, for example by passing event.id as the idempotency key of the service you call.

Publish the handler again after the change. The schedules already created use the new code from their next run.

A deploy script runs in a new process each time, so its SDK hasn't seen what's published. Call handlers.list() before publishing changed code, or publish() rejects with events_handler_conflict:

await puter.events.handlers.list({ appUid: app.uid });
await puter.events.handlers.publish('sendSummary', sendSummary, { appUid: app.uid });

Handle Mornings That Were Missed

A schedule can fall behind. For example, if the handler failed 5 times in a row, Puter pauses the schedule until you publish a fix. When it resumes, it doesn't send every summary it missed. It runs the next one, and event.missed says how many runs were skipped. The summary can say so:

async function sendSummary ({ event, user }) {
    const sentKey = `summary-sent:${event.id}`;
    if (await user.kv.get(sentKey)) return;

    const tasks = (await user.kv.get('tasks')) ?? [];
    const open = tasks.filter((task) => !task.done);
    const skipped = event.missed > 0 ? ` ${event.missed} earlier summaries were skipped.` : '';
    if (open.length > 0) {
        await user.notifications.notify({
            title: 'Good morning',
            text: `You have ${open.length} open tasks today.${skipped}`,
            target: '/tasks',
            group: 'morning-summary',
        });
    }

    await user.kv.set(sentKey, true, Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60);
}

The run's event.scheduledFor is the time it was due, in milliseconds since the epoch. A run usually starts within a minute of it.

Let the User Change the Time

A schedule can't be edited. To move the summary to another time or time zone, remove the schedule and create a new one:

async function changeSummaryTime (hour, timeZone) {
    const existing = await findSchedule();
    if (existing) await puter.events.removeSchedule(existing.scheduleId);

    options.cron = `0 ${hour} * * 1-5`;
    options.tz = timeZone;
    await puter.events.onSchedule(options);
}

To turn the summary off, call puter.events.removeSchedule() with the scheduleId from findSchedule().

Check on Schedules

A suspended schedule still exists, and listSchedules() shows why it's paused in suspendedReason:

suspendedReason What happened What to do
handler_not_found The handler was removed. Publish it again. The schedule resumes.
failures Five runs in a row failed. Fix the handler and publish it again. The schedule resumes.
no_credit The user ran out of credit. Nothing. It resumes when they add credit.

Each run is billed to the user under the User-Pays Model, as one event delivery plus the handler's worker usage. See Rate Limits and Quotas for how many schedules a user can have, and how often they can run.

Full Code

The recipe has two programs. The publish script runs as the app's developer, from a deploy script or your browser console:

const app = await puter.apps.get('task-list');    // your app's name

async function sendSummary ({ event, user }) {
    const sentKey = `summary-sent:${event.id}`;
    if (await user.kv.get(sentKey)) return;

    const tasks = (await user.kv.get('tasks')) ?? [];
    const open = tasks.filter((task) => !task.done);
    const skipped = event.missed > 0 ? ` ${event.missed} earlier summaries were skipped.` : '';
    if (open.length > 0) {
        await user.notifications.notify({
            title: 'Good morning',
            text: `You have ${open.length} open tasks today.${skipped}`,
            target: '/tasks',
            group: 'morning-summary',
        });
    }

    await user.kv.set(sentKey, true, Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60);
}

await puter.events.handlers.list({ appUid: app.uid });
await puter.events.handlers.publish('sendSummary', sendSummary, { appUid: app.uid });

The app code runs in your app, signed in as the user:

const options = {
    cron: '0 8 * * 1-5',
    tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
    handlerName: 'sendSummary',
};

async function findSchedule () {
    let cursor = null;
    do {
        const page = await puter.events.listSchedules({ cursor });
        const found = page.items.find((schedule) =>
            schedule.handlerName === options.handlerName && schedule.state !== 'ended');
        if (found) return found;
        cursor = page.cursor ?? null;
    } while (cursor);
    return null;
}

async function startMorningSummary () {
    if (await findSchedule()) return;
    await puter.events.onSchedule(options);
}

async function changeSummaryTime (hour, timeZone) {
    const existing = await findSchedule();
    if (existing) await puter.events.removeSchedule(existing.scheduleId);

    options.cron = `0 ${hour} * * 1-5`;
    options.tz = timeZone;
    await puter.events.onSchedule(options);
}

const allowed = await puter.perms.request('backgroundEvents');
if (allowed) await startMorningSummary();
← All recipes