Hooks and events
A hook runs before a write and can modify or reject it. When something notable happens it emits an event, and agents react to it: send an email, write an audit row, emit the next event. Each piece stays small and independent.
Hooks
A file in hooks/ named after a collection runs before each write to it. beforeCreate, beforeUpdate and beforeDelete receive the record and return it, modified if needed; reject aborts the write with a message.
export function beforeCreate({ record, reject, alert }) {
if (String(record.note ?? "").includes("<script")) {
alert("A script in an order's note", { email: record.email });
reject("That note is not allowed.");
}
// Card numbers never reach the database.
return { ...record, note: String(record.note ?? "").replace(/\d{16}/g, "[card]") };
}
export function beforeUpdate({ record, previous, emit }) {
// `record` holds the changes; `previous` is the current row.
const order = { ...previous, ...record };
if (order.paid && !previous.paid) emit("order.paid", { id: order.id, email: order.email });
return record;
}hooks/_all.ts runs before every collection’s hook, for cross-cutting logic.
Events
emit(name, data) publishes an event. From a hook, events are only published once the write commits; a rejected write emits nothing except alerts. Server functions and agents can emit too.
An agent watching an event receives each one, in order:
export const watch = { event: "order.paid" };
export async function act({ event, mail, emit }) {
mail({ to: event.data.email, subject: "Paid, thank you", text: `Order ${event.data.id} is paid.` });
emit("receipt.sent", { order: event.data.id });
}Another agent can react to that:
export const watch = { event: "receipt.sent" };
export async function act({ event, server }) {
server.collection("audit").create({ what: `receipt for ${event.data.order}` });
}The hook doesn’t send email; it just announces the order is paid. What happens next is up to the agents, and adding one doesn’t touch existing code. Event chains deeper than eight are rejected, so two agents can’t loop forever.
Alerts
alert(message, details) flags something for a human. It’s logged, counted in the dev overlay, and emitted as an alert event so an agent can forward it:
export const watch = { event: "alert" };
export async function act({ event, mail }) {
mail({ to: "ops@example.com", subject: "Alert", text: event.data.message });
}Events from outside
External programs can emit events too. Over HTTP, as a superuser:
curl -X POST http://localhost:8090/api/events/door.opened \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"door": 3}'JSON, MessagePack (application/msgpack) and CBOR (application/cbor) are all delivered as JSON; other payloads as { "bytes": "<base64>" }. ?app=reports targets only the app mounted as reports.
Locally, without a token, the server can also listen on UDP:
sluurp serve --public ./app --events
# from anything else on this machine:
sluurp emit door.opened '{"door": 3}'--events listens on 127.0.0.1:9900 by default. Each datagram is a map with name, data and optionally app, encoded as JSON, MessagePack or CBOR. A sensor, script or cron job can send one in a single line.