Workflows
Durable workflows in workflows/*.ts: run when a row is created, updated, moves into a state or is deleted, on a schedule, or on an inbound webhook. Every step is recorded, so a run can sleep for days, wait for a signal and retry without sending an email twice.
On this page
After a sleep, a wait or a retry the workflow's code runs again from the top, and the steps it already took answer from the run's journal. Code between steps must do the same thing every run: take the time from step.now() and put anything random inside step.run().
Templates live in messages/email, messages/sms, messages/notify and prompts/ with typed vars in their front matter; every step.send call is type-checked against them.
Triggers
on.createdfunction
Runs after a row of the table is inserted (and committed). row is the new row; user who wrote it.
on.created<R>(table: string, handler: Handler<R>): WorkflowParameters
| Name | Type | Description |
|---|---|---|
tablerequired | string | |
handlerrequired | Handler<R> |
- Returns
Workflow- Errors
- Does not throw.
- Permissions
- The run acts as the platform (row-level security doesn't apply to
step.ctx.dbandstep.sql).
export const enquiryReceived = on.created<Enquiry>("enquiry", async ({ row }, step) => {
await step.send.notify("enquiry-received", { role: "ops", vars: { name: row.name } });
});Source: internal/kernel/appsdk/workflows/index.ts:144
on.updatedfunction
Runs after any update of a row; old is the row before.
on.updated<R>(table: string, handler: Handler<R>): WorkflowParameters
| Name | Type | Description |
|---|---|---|
tablerequired | string | |
handlerrequired | Handler<R> |
- Returns
Workflow- Errors
- Does not throw.
- Permissions
- As on.created.
export const priceChanged = on.updated<Experience>("experience", async ({ row, old }, step) => {
if (old && old.price !== row.price) await step.run("log", () => step.ctx.log("price", old.price, "→", row.price));
});Source: internal/kernel/appsdk/workflows/index.ts:145
on.transitionfunction
Runs when a row's column moves into one value (status becomes confirmed), not on every later update while it stays there.
on.transition<R>(table: string, column: string, to: string, handler: Handler<R>): WorkflowParameters
| Name | Type | Description |
|---|---|---|
tablerequired | string | |
columnrequired | string | |
torequired | string | |
handlerrequired | Handler<R> |
- Returns
Workflow- Errors
- Does not throw.
- Permissions
- As on.created.
export const bookingConfirmed = on.transition<Booking>("booking", "status", "confirmed", async ({ row }, step) => {
await step.send.email("booking-confirmed", { to: row.email, vars: { ref: row.ref, team_size: row.team_size, link: step.url("/bookings") } });
});Source: internal/kernel/appsdk/workflows/index.ts:149
on.deletedfunction
Runs when a row is deleted; row is the row as it was. On a soft-delete table, when the row moves into the trash.
on.deleted<R>(table: string, handler: Handler<R>): WorkflowParameters
| Name | Type | Description |
|---|---|---|
tablerequired | string | |
handlerrequired | Handler<R> |
- Returns
Workflow- Errors
- Does not throw.
- Permissions
- As on.created.
export const noteDeleted = on.deleted("ops_note", async ({ row }, step) => {
await step.run("audit", () => step.ctx.log("deleted note", row.id));
});Source: internal/kernel/appsdk/workflows/index.ts:147
cronfunction
Runs on a schedule: "06:00" (daily), "daily 06:00", "every 15m", "weekly mon 09:00" or a 5-field cron expression, in tz (IANA, default UTC). The run's tick is the scheduled time.
cron(schedule: string, opts: { tz?: string }, handler: Handler): WorkflowParameters
| Name | Type | Description |
|---|---|---|
schedulerequired | string | |
optsrequired | { tz?: string } | |
handlerrequired | Handler |
- Returns
Workflow- Errors
- Does not throw.
- Permissions
- As on.created.
export const morningDigest = cron("06:00", { tz: "Asia/Kolkata" }, async (_e, step) => {
const rows = await step.sql("waiting", "select count(*)::int as n from booking where status = 'requested'");
const count = Number(rows[0]?.n ?? 0);
if (count > 0) await step.send.notify("bookings-waiting", { role: "ops", vars: { count } });
});Source: internal/kernel/appsdk/workflows/index.ts:154
webhookfunction
An inbound webhook at /__appbricx/hooks/<name> on the app host (and /hooks/<project>/<name> on the platform). verify checks the sender: "stripe", "github", "razorpay" or a generic HMAC; without it the body must carry Appbricx's own signature. body and headers are the request's.
webhook(name: string, opts: { verify?: string | Record<string, unknown> }, handler: Handler): WorkflowParameters
| Name | Type | Description |
|---|---|---|
namerequired | string | |
optsrequired | { verify?: string | Record<string, unknown> } | |
handlerrequired | Handler |
- Returns
Workflow- Errors
- Does not throw.
- Permissions
- Callers prove who they are with the signature; each event id runs once.
export const stripeEvents = webhook("stripe", { verify: "stripe" }, async ({ body }, step) => {
const e = body as { type: string; data: { object: { id: string } } };
if (e.type === "checkout.session.completed") await step.sql("paid", "update orders set status = 'paid' where checkout_id = $1", [e.data.object.id]);
});Source: internal/kernel/appsdk/workflows/index.ts:161
Steps
step.runmethod
Runs fn once for the run and records its result: on every later run of the code (after a sleep or a retry) it answers from the journal instead of running again. Put reads, writes, HTTP calls, integration calls and anything random inside it.
step.run<T>(name: string, fn: () => Promise<T> | T): Promise<T>Parameters
| Name | Type | Description |
|---|---|---|
namerequired | string | |
fnrequired | () => Promise<T> | T |
- Returns
Promise<T>- Errors
- Rejects with what
fnthrew; the step is retried with backoff, then the run goes to the dead letters (run {action: "replay"}).
- Rejects with what
- Permissions
- None beyond the ctx it uses.
const s = await step.run("session", () => step.ctx.db.get("class_session", row.session_id));Source: internal/kernel/appsdk/workflows/index.ts:169
step.nowmethod
The time, recorded: the same on every run of the code. Use it instead of new Date() (the checks refuse Date.now() and new Date() outside step.run).
step.now(): Promise<Date>- Returns
Promise<Date>- Errors
- Does not throw.
- Permissions
- None.
const now = await step.now();Source: internal/kernel/appsdk/workflows/index.ts:171
step.sleepmethod
Waits "30s", "15m", "2h", "1d" (or milliseconds) without holding anything: the run sleeps and wakes at the time.
step.sleep(duration: string | number): Promise<void>Parameters
| Name | Type | Description |
|---|---|---|
durationrequired | string | number |
- Returns
Promise<void>- Errors
- Does not throw.
- Permissions
- None.
await step.sleep("1d");Source: internal/kernel/appsdk/workflows/index.ts:173
step.sleepUntilmethod
Waits until a time, optionally minus a duration (a reminder a day before). A time already past doesn't wait.
step.sleepUntil(when: Date | string, opts?: { minus?: string | number }): Promise<void>Parameters
| Name | Type | Description |
|---|---|---|
whenrequired | Date | string | |
optsoptional | { minus?: string | number } |
- Returns
Promise<void>- Errors
- Does not throw.
- Permissions
- None.
await step.sleepUntil(row.starts_at, { minus: "1d" });
await step.send.email("reminder", { to: row.email, vars: { title: row.title } });Source: internal/kernel/appsdk/workflows/index.ts:175
step.waitFormethod
Waits for ctx.signal(name, key, payload) from an action or another workflow and answers its payload, or null at the timeout. Each waitFor takes its own signal; one sent before the run got here counts.
step.waitFor<T = unknown>(name: string, opts?: { key?: string; timeout?: string | number }): Promise<T | null>Parameters
| Name | Type | Description |
|---|---|---|
namerequired | string | |
optsoptional | { key?: string; timeout?: string | number } |
- Returns
Promise<T | null>- Errors
- A signal name can't contain
:(put the rest in its key).
- A signal name can't contain
- Permissions
- None.
const feedback = await step.waitFor<{ stars: number }>("feedback", { key: row.id, timeout: "7d" });
if (feedback) await step.run("keep", () => step.ctx.db.update("booking", row.id, { notes: feedback.stars + " stars" }));Source: internal/kernel/appsdk/workflows/index.ts:177
step.sqlmethod
One SQL statement with $1… params, recorded under name.
step.sql(name: string, sql: string, params?: unknown[]): Promise<Loose[]>Parameters
| Name | Type | Description |
|---|---|---|
namerequired | string | |
sqlrequired | string | |
paramsoptional | unknown[] |
- Returns
- The rows it returned.
- Errors
- Rejects with Postgres's message.
- Permissions
- Runs as the platform: row-level security doesn't apply.
await step.sql("complete", "update class_session set status = 'completed' where status = 'scheduled' and ends_at < now()");Source: internal/kernel/appsdk/workflows/index.ts:190
step.ctxproperty
The raw server ctx: db, http.fetch, config, secrets, signal, email, sms, pdf, log, app.url, and integrations.invoke(id, action, input) for a connected integration. Its calls aren't recorded: wrap anything that must happen once in step.run.
step.ctx: ServerCtx- Returns
ServerCtx- Errors
- Does not throw.
- Permissions
- As the platform.
await step.run("slack", () =>
step.ctx.integrations.invoke("slack", "send_channel_message", { channel: "C0123456", text: "New booking " + row.ref }),
);Source: internal/kernel/appsdk/workflows/index.ts:194
Messages, PDFs and AI
step.send.emailmethod
Renders messages/email/<template>.md (or .html, .mjml) with vars and sends it once to to. vars are type-checked against the template's front matter; locale picks <name>.<locale>.md, then <name>.<language>.md, then the default.
step.send.email<N extends Names<"email">>(template: N, m: { to: string | string[]; vars: VarsOf<"email", N>; locale?: string }): Promise<{ queued: number }>Parameters
| Name | Type | Description |
|---|---|---|
templaterequired | N | |
mrequired | { to: string | string[]; vars: VarsOf<"email", N>; locale?: string } |
- Returns
Promise<{ queued: number }>- Errors
- Does not throw.
- Permissions
- In dev and verify, mail to anyone but the project's owners and admins stays in the outbox (tail kind emails) unless the env secret
APPBRICX_EMAIL_DELIVERY=live.
await step.send.email("booking-confirmed", {
to: row.email,
vars: { ref: row.ref, team_size: row.team_size, link: step.url("/bookings") },
});Source: internal/kernel/appsdk/workflows/index.ts:181
step.send.smsmethod
Renders messages/sms/<template>.txt with vars and sends it once.
step.send.sms<N extends Names<"sms">>(template: N, m: { to: string | string[]; vars: VarsOf<"sms", N>; locale?: string }): Promise<{ queued: number }>Parameters
| Name | Type | Description |
|---|---|---|
templaterequired | N | |
mrequired | { to: string | string[]; vars: VarsOf<"sms", N>; locale?: string } |
- Returns
Promise<{ queued: number }>- Errors
- Does not throw.
- Permissions
- As step.send.email.
await step.send.sms("table-ready", { to: row.phone, vars: { name: row.name } });Source: internal/kernel/appsdk/workflows/index.ts:182
step.send.notifymethod
An in-app notification from messages/notify/<template>.md (title, link in the front matter) to one user or everyone with a role.
step.send.notify<N extends Names<"notify">>(template: N, m: { user?: string; role?: string; vars: VarsOf<"notify", N>; locale?: string }): Promise<unknown>Parameters
| Name | Type | Description |
|---|---|---|
templaterequired | N | |
mrequired | { user?: string; role?: string; vars: VarsOf<"notify", N>; locale?: string } |
- Returns
Promise<unknown>- Errors
- Does not throw.
- Permissions
- None.
await step.send.notify("bookings-waiting", { role: "ops", vars: { count } });Source: internal/kernel/appsdk/workflows/index.ts:183
step.promptmethod
Asks the app's AI with prompts/<name>.md (model, max_tokens, system in the front matter) filled with vars; the answer is recorded. The AI runs on the project's own key (the AI_API_KEY secret).
step.prompt<N extends Names<"prompt">>(name: N, vars: VarsOf<"prompt", N>): Promise<string>Parameters
| Name | Type | Description |
|---|---|---|
namerequired | N | |
varsrequired | VarsOf<"prompt", N> |
- Returns
Promise<string>- Errors
- Rejects when the project has no AI key or AI is off for it.
- Permissions
- Server only; the key never reaches the browser.
const summary = await step.prompt("summarise-feedback", { comments: rows.map((r) => r.body).join("\n") });Source: internal/kernel/appsdk/workflows/index.ts:186
step.pdfmethod
Prints HTML as a PDF kept as an app file, once (recorded).
step.pdf(name: string, html: string, opts?: { landscape?: boolean }): Promise<{ fileId: string; url: string; bytes: number }>Parameters
| Name | Type | Description |
|---|---|---|
namerequired | string | |
htmlrequired | string | |
optsoptional | { landscape?: boolean } |
- Returns
{ fileId, url, bytes }.- Errors
- Does not throw.
- Permissions
- None.
const pdf = await step.pdf("invoice", invoiceHtml);
await step.run("save", () => step.ctx.db.update("invoice", row.id, { pdf: pdf.url }));Source: internal/kernel/appsdk/workflows/index.ts:188
step.urlmethod
An absolute URL of the app (its dev host, production host or custom domain), for links in messages.
step.url(path: string): stringParameters
| Name | Type | Description |
|---|---|---|
pathrequired | string |
- Returns
string- Errors
- Does not throw.
- Permissions
- None.
step.url("/bookings/" + row.id)Source: internal/kernel/appsdk/workflows/index.ts:192
Types
interface Event<R = Loose> {
/** The row (data-change triggers). */
row: R;
/** The row before the change (updates). */
old: R | null;
/** Who changed it (data-change triggers), or null. */
user: { id: string; name: string; email: string; role: string } | null;
/** A schedule's tick (RFC 3339). */
tick?: string;
/** A webhook's body and headers. */
body?: unknown;
headers?: Record<string, string>;
}Source: internal/kernel/appsdk/workflows/index.ts:72
interface Step {
/** Runs fn once for the run: its result is recorded and answered on every later run of the code. */
run<T>(name: string, fn: () => Promise<T> | T): Promise<T>;
/** The time, recorded (the same on every run of the code). */
now(): Promise<Date>;
/** Waits ("30s", "15m", "2h", "1d", or ms) without holding anything: the run sleeps and wakes. */
sleep(duration: string | number): Promise<void>;
/** Waits until a time (minus "1d" etc.). A time already past doesn't wait. */
sleepUntil(when: Date | string, opts?: { minus?: string | number }): Promise<void>;
/** Waits for ctx.signal(name, key, payload) (from an action, another workflow…): its payload, or null at the timeout. */
waitFor<T = unknown>(name: string, opts?: { key?: string; timeout?: string | number }): Promise<T | null>;
send: {
// locale (e.g. the recipient's, from your data) picks <name>.<locale>.md,
// else <name>.<language>.md, else the default <name>.md.
email<N extends Names<"email">>(template: N, m: { to: string | string[]; vars: VarsOf<"email", N>; locale?: string }): Promise<{ queued: number }>;
sms<N extends Names<"sms">>(template: N, m: { to: string | string[]; vars: VarsOf<"sms", N>; locale?: string }): Promise<{ queued: number }>;
notify<N extends Names<"notify">>(template: N, m: { user?: string; role?: string; vars: VarsOf<"notify", N>; locale?: string }): Promise<unknown>;
};
/** Asks the app's AI with a prompts/<name>.md template; the answer is recorded. */
prompt<N extends Names<"prompt">>(name: N, vars: VarsOf<"prompt", N>): Promise<string>;
/** Prints html as a PDF kept as an app file, once (recorded): {fileId, url, bytes}. */
pdf(name: string, html: string, opts?: { landscape?: boolean }): Promise<{ fileId: string; url: string; bytes: number }>;
/** One SQL statement as the run (recorded). */
sql(name: string, sql: string, params?: unknown[]): Promise<Loose[]>;
/** An absolute URL of the app: step.url("/bookings/" + id). */
url(path: string): string;
/** The raw ctx (reads that needn't be recorded). */
ctx: ServerCtx;
}Source: internal/kernel/appsdk/workflows/index.ts:167
type Trigger =
| { kind: "created" | "updated" | "deleted"; table: string }
| { kind: "transition"; table: string; column: string; to: string }
| { kind: "cron"; schedule: string; tz?: string }
| { kind: "webhook"; name: string; verify?: string | Record<string, unknown> };Source: internal/kernel/appsdk/workflows/index.ts:88