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.

@appbricx/workflows19 membersSDK referenceWorkflows guide

On this page
  1. Triggers
  2. on.created
  3. on.updated
  4. on.transition
  5. on.deleted
  6. cron
  7. webhook
  8. Steps
  9. step.run
  10. step.now
  11. step.sleep
  12. step.sleepUntil
  13. step.waitFor
  14. step.sql
  15. step.ctx
  16. Messages, PDFs and AI
  17. step.send.email
  18. step.send.sms
  19. step.send.notify
  20. step.prompt
  21. step.pdf
  22. step.url
  23. Types

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>): Workflow

Parameters

NameTypeDescription
tablerequiredstring
handlerrequiredHandler<R>
Returns
Workflow
Errors
Does not throw.
Permissions
The run acts as the platform (row-level security doesn't apply to step.ctx.db and step.sql).
Example
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>): Workflow

Parameters

NameTypeDescription
tablerequiredstring
handlerrequiredHandler<R>
Returns
Workflow
Errors
Does not throw.
Permissions
As on.created.
Example
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>): Workflow

Parameters

NameTypeDescription
tablerequiredstring
columnrequiredstring
torequiredstring
handlerrequiredHandler<R>
Returns
Workflow
Errors
Does not throw.
Permissions
As on.created.
Example
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>): Workflow

Parameters

NameTypeDescription
tablerequiredstring
handlerrequiredHandler<R>
Returns
Workflow
Errors
Does not throw.
Permissions
As on.created.
Example
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): Workflow

Parameters

NameTypeDescription
schedulerequiredstring
optsrequired{ tz?: string }
handlerrequiredHandler
Returns
Workflow
Errors
Does not throw.
Permissions
As on.created.
Example
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): Workflow

Parameters

NameTypeDescription
namerequiredstring
optsrequired{ verify?: string | Record<string, unknown> }
handlerrequiredHandler
Returns
Workflow
Errors
Does not throw.
Permissions
Callers prove who they are with the signature; each event id runs once.
Example
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

NameTypeDescription
namerequiredstring
fnrequired() => Promise<T> | T
Returns
Promise<T>
Errors
  • Rejects with what fn threw; the step is retried with backoff, then the run goes to the dead letters (run {action: "replay"}).
Permissions
None beyond the ctx it uses.
Example
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.
Example
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

NameTypeDescription
durationrequiredstring | number
Returns
Promise<void>
Errors
Does not throw.
Permissions
None.
Example
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

NameTypeDescription
whenrequiredDate | string
optsoptional{ minus?: string | number }
Returns
Promise<void>
Errors
Does not throw.
Permissions
None.
Example
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

NameTypeDescription
namerequiredstring
optsoptional{ key?: string; timeout?: string | number }
Returns
Promise<T | null>
Errors
  • A signal name can't contain : (put the rest in its key).
Permissions
None.
Example
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

NameTypeDescription
namerequiredstring
sqlrequiredstring
paramsoptionalunknown[]
Returns
The rows it returned.
Errors
  • Rejects with Postgres's message.
Permissions
Runs as the platform: row-level security doesn't apply.
Example
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.
Example
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

NameTypeDescription
templaterequiredN
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.
Example
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

NameTypeDescription
templaterequiredN
mrequired{ to: string | string[]; vars: VarsOf<"sms", N>; locale?: string }
Returns
Promise<{ queued: number }>
Errors
Does not throw.
Permissions
As step.send.email.
Example
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

NameTypeDescription
templaterequiredN
mrequired{ user?: string; role?: string; vars: VarsOf<"notify", N>; locale?: string }
Returns
Promise<unknown>
Errors
Does not throw.
Permissions
None.
Example
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

NameTypeDescription
namerequiredN
varsrequiredVarsOf<"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.
Example
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

NameTypeDescription
namerequiredstring
htmlrequiredstring
optsoptional{ landscape?: boolean }
Returns
{ fileId, url, bytes }.
Errors
Does not throw.
Permissions
None.
Example
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): string

Parameters

NameTypeDescription
pathrequiredstring
Returns
string
Errors
Does not throw.
Permissions
None.
Example
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