Pulse — Scheduled Jobs/Overview

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 validates every schedule expression before uploading — invalid cron strings are rejected immediately.


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 of the intended fire time
    // 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 0–6 (Sun=0) 1-5 (weekdays), 1 (Mondays)

Common schedule examples

* * * * *         every minute
*/5 * * * *       every 5 minutes
0 * * * *         every hour
0 0 * * *         daily at midnight UTC
0 9 * * 1-5       9am UTC on weekdays
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://word-ab1c.dev.tissue.systems
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 — ${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