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 minutesMultiple [[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: jan…dec or january…december, and sun…sat or sunday…saturday (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 dailyHow 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 dailyexport 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