on this page

PulseOverview

Pulse: Scheduled Cell Invocations

Pulse lets a Cell run on a recurring schedule without any incoming HTTP request. Declare one or more cron schedules in ribo.toml and export a pulse handler from your Cell. The platform fires the handler at each scheduled time.

The name fits the tissue metaphor: a pulse is the periodic signal that keeps a system alive between external stimuli.


Declaring a schedule

Add one or more [[pulse]] blocks to ribo.toml:

[cell]
name = "my-cell"
js   = "./cell.js"

[[pulse]]
schedule = "0 * * * *"     # every hour at :00

[[pulse]]
schedule = "*/15 * * * *"  # every 15 minutes

Multiple [[pulse]] blocks are allowed. Each fires independently on its own schedule.

Cron syntax is standard 5-field POSIX (min hour dom month dow). All times are UTC.

ribo deploy checks every schedule's shape before uploading, and the platform re-checks it on the way in: an expression the scheduler cannot parse — an hour of 25, a field the wrong shape, a day-of-month the month field never reaches — fails the deploy with a 400 naming the expression, and no part of the Cell is published. A Cell may declare at most 25 schedules.

One field departs from crontab(5): day-of-week counts from 1 = Sunday through 7 = Saturday, so 0 is not a day and Monday is 2.


The pulse handler

Export a pulse function from your Cell module alongside fetch:

export default {
  async fetch(request, env) {
    return new Response("ok");
  },

  async pulse(event, env) {
    // event.scheduledTime = Date the pulse was dispatched (within ~30s after the cron slot)
    // event.cron          = the cron expression that triggered this pulse
    console.log(`pulse fired at ${event.scheduledTime.toISOString()} (${event.cron})`);
    await doPeriodicWork(env);
  },
};

The pulse export is optional. A Cell without it silently ignores any [[pulse]] schedules declared in ribo.toml.

The env argument is identical to the one passed to fetch; all declared bindings (c3, g7, text) are fully available.


Cron schedule reference

5-field POSIX cron: min hour dom month dow

Field Range Examples
min 0–59 0 (top of hour), */15 (every 15 min), 5,35 (at :05 and :35)
hour 0–23 9 (9am UTC), 0,12 (midnight and noon)
dom 1–31 1 (1st of month), */7 (every 7 days)
month 1–12 * (every month), 1,7 (January and July)
dow 1–7 (Sun=1) 2-6 (Monday to Friday), 2 (Mondays), 1,7 (weekends)

A 0 in the day-of-week field is refused at deploy. The count starts at Sunday, so 1-5 is Sunday through Thursday and Monday to Friday is written 2-6.

The month and day-of-week fields also take names, case-insensitively: jandec or januarydecember, and sunsat or sundaysaturday (tue and thu also accept tues and thurs). A name stands where a number would — 0 0 * * mon-fri and 0 0 * jan,jul * — but a bare name takes no step, so write mon-fri/2 rather than mon/2, and both ends of a range must be written the same way. In the day-of-month and day-of-week fields, ? means the same as *. Everything else is refused at deploy with a message naming the field: no @hourly-style shorthand, no L, no 1#2.

Common schedule examples

* * * * *         every minute
*/5 * * * *       every 5 minutes
0 * * * *         every hour
0 0 * * *         daily at midnight UTC
0 9 * * 2-6       9am UTC Monday through Friday
0 0 1 * *         1st of every month at midnight
30 6 * * *        6:30am UTC daily

How delivery works

The tissue scheduler polls for due pulse schedules every 30 seconds and dispatches them as internal HTTP events to the Cell runtime. The platform coordinates across servers so each slot fires exactly once.

Pulses are best-effort: if the Cell is unavailable or throws an unhandled exception when a pulse fires, the event is not retried. The next scheduled tick fires normally.

Granularity: The minimum cron interval is 1 minute. Pulses fire within ~30 seconds of the scheduled time.

All schedules are UTC. There is no timezone support in cron expressions.


Handling multiple schedules

When a Cell has more than one [[pulse]] block, use event.cron to distinguish between them:

export default {
  async pulse(event, env) {
    switch (event.cron) {
      case "* * * * *":
        await runMinuteTasks(env);
        break;
      case "0 * * * *":
        await runHourlyReport(env);
        break;
      case "0 0 * * *":
        await runDailyCleanup(env);
        break;
    }
  },
};

Deploy output

When you deploy a Cell with pulse schedules, ribo deploy confirms the registered schedules:

deployed  my-cell
address   ab1cd2
kind      js
url       https://my-cell.strand-9c.tissue.dev
pulse     0 * * * *, */15 * * * *

Pulse with c3 for reliable work tracking

Because pulses are best-effort and not retried on failure, use c3 to record work items and detect missed ticks if your use case requires reliability:

const SCHEMA = `
  CREATE TABLE IF NOT EXISTS pulse_log (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    scheduled_at TEXT NOT NULL,
    cron         TEXT NOT NULL,
    completed_at TEXT,
    error        TEXT
  )
`;

export default {
  async pulse(event, env) {
    await env.DB.exec(SCHEMA);

    // Record that this tick started
    const { meta } = await env.DB.prepare(
      "INSERT INTO pulse_log (scheduled_at, cron) VALUES (?, ?)"
    ).bind(event.scheduledTime.toISOString(), event.cron).run();

    const logId = meta.last_insert_rowid;

    try {
      await doWork(env);
      await env.DB.prepare(
        "UPDATE pulse_log SET completed_at = datetime('now') WHERE id = ?"
      ).bind(logId).run();
    } catch (err) {
      await env.DB.prepare(
        "UPDATE pulse_log SET error = ? WHERE id = ?"
      ).bind(err.message, logId).run();
    }
  },
};

Example: heartbeat log

A Cell that records every pulse tick to a c3 database and renders the log as JSON.

ribo.toml

[cell]
name = "heartbeat"
js   = "./cell.js"

[[pulse]]
schedule = "* * * * *"   # every minute

[[bindings]]
type     = "c3"
binding  = "DB"
database = "heartbeats"

cell.js

const SCHEMA = `
  CREATE TABLE IF NOT EXISTS heartbeats (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    scheduled_at TEXT NOT NULL,
    cron         TEXT NOT NULL,
    recorded_at  TEXT NOT NULL
  )
`;

export default {
  async pulse(event, env) {
    await env.DB.exec(SCHEMA);
    await env.DB.prepare(
      "INSERT INTO heartbeats (scheduled_at, cron, recorded_at) VALUES (?, ?, ?)"
    ).bind(event.scheduledTime.toISOString(), event.cron, new Date().toISOString()).run();
    // Trim old rows to keep the table small
    await env.DB.exec(
      "DELETE FROM heartbeats WHERE id NOT IN (SELECT id FROM heartbeats ORDER BY id DESC LIMIT 200)"
    );
  },

  async fetch(request, env) {
    await env.DB.exec(SCHEMA);
    const { results } = await env.DB.prepare(
      "SELECT * FROM heartbeats ORDER BY id DESC LIMIT 50"
    ).all();
    return Response.json(results);
  },
};

Example: daily digest email

A Cell that queries c3 at 8am UTC every day and sends an email summary via an external API.

[[pulse]]
schedule = "0 8 * * *"   # 8am UTC daily

[[bindings]]
type     = "c3"
binding  = "DB"
database = "app"

[[bindings]]
type    = "text"
binding = "SENDGRID_KEY"
value   = "SG...."
export default {
  async pulse(event, env) {
    const { results } = await env.DB.prepare(`
      SELECT COUNT(*) as signups
      FROM users
      WHERE created_at >= datetime('now', '-1 day')
    `).all();

    const body = {
      personalizations: [{ to: [{ email: "you@example.com" }] }],
      from: { email: "noreply@yourapp.com" },
      subject: `Daily digest for ${event.scheduledTime.toDateString()}`,
      content: [{ type: "text/plain", value: `New signups yesterday: ${results[0].signups}` }],
    };

    await fetch("https://api.sendgrid.com/v3/mail/send", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        authorization: `Bearer ${env.SENDGRID_KEY}`,
      },
      body: JSON.stringify(body),
    });
  },
};

Example: periodic cleanup

Remove old rows on a daily schedule to keep tables compact.

[[pulse]]
schedule = "0 2 * * *"   # 2am UTC daily
export default {
  async pulse(event, env) {
    const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
    const { meta } = await env.DB.prepare(
      "DELETE FROM events WHERE created_at < ?"
    ).bind(cutoff).run();
    console.log(`Deleted ${meta.rows_affected} old events`);
  },
};

See also