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
- Pulse API Reference — event object properties and handler details
- ribo.toml Reference — full
[[pulse]]schema - c3 Overview — structured data for Cells