- name
- novu-framework-integration
- description
- Build code-first notification workflows with @novu/framework. Use when defining workflows in TypeScript (Zod / JSON Schema / Class Validator), composing channel steps (email, SMS, push, chat, in-app) with action steps (delay, digest, custom), exposing Step Controls for non-technical teammates, rendering React/Vue/Svelte Email templates, hosting the Bridge Endpoint inside Next.js, Express, NestJS, Remix, Nuxt, SvelteKit, H3, or AWS Lambda, syncing to Novu Cloud via CLI / GitHub Actions, securing production with HMAC, or implementing translations, hydration, multi-channel orchestration, and LLM-powered notification logic in code.
- inputs
- [{"name":"NOVU_SECRET_KEY","description":"Server-side API key from https://dashboard.novu.co/api-keys. Used by @novu/framework and the Bridge Endpoint.","required":true,"type":"secret"}]
# Framework Integration
Use `@novu/framework` to build notification workflows **in code**, alongside your application source. Workflows live in your repo, content is rendered using libraries you already use (React Email, Vue Email, Svelte Email), and a single HTTP endpoint (the Bridge) lets Novu Cloud execute them with just-in-time data from your services.
> Use this skill when building workflows **in code**. For workflows authored in the Novu Dashboard, just trigger them via [`trigger-notification`](../trigger-notification) — no Framework needed.
## When to Use the Framework
| Use Framework | Use Dashboard Workflows |
| --- | --- |
| Workflows must live in source control / GitOps | Non-technical peers own all the content |
| Need just-in-time data from your DB / APIs | All data fits in the trigger payload |
| Render emails with React/Vue/Svelte Email | Block editor is enough |
| Execute custom code (LLMs, third-party APIs) | Pure send-only flows |
| Need typed payload + step controls | Quick prototype |
The two approaches **coexist** — a single environment can have both code-defined and dashboard-defined workflows.
## How It Works
1. You define workflows in code with `workflow(...)` from `@novu/framework`.
2. You expose a single `/api/novu` HTTP route in your app — the **Bridge Endpoint**.
3. You sync the bridge URL to Novu Cloud (via `npx novu sync` or GitHub Action).
4. Novu Cloud calls your bridge over an authenticated tunnel during workflow execution to fetch step content with the latest data.
```
Trigger ──► Novu Cloud Worker ──► Your Bridge (/api/novu) ──► Provider (SendGrid, FCM, …)
```
## Quick Start
### 1. Bootstrap a project
```bash
npx novu init --secret-key=<YOUR_NOVU_SECRET_KEY>
```
This creates a sample bridge app with a workflow, env file, and a working `/api/novu` route.
### 2. Or add to an existing app
```bash
npm install @novu/framework zod @react-email/components react-email
```
```bash
NOVU_SECRET_KEY=<YOUR_NOVU_SECRET_KEY>
```
### 3. Define a workflow
```typescript
import { workflow } from "@novu/framework";
import { z } from "zod";
export const welcomeWorkflow = workflow(
"welcome-email",
async ({ step, payload, subscriber }) => {
await step.email("send-email", async (controls) => {
return {
subject: controls.subject,
body: `Welcome ${subscriber.firstName ?? payload.userName}!`,
};
}, {
controlSchema: z.object({
subject: z.string().default("Welcome to {{payload.appName}}"),
}),
});
},
{
payloadSchema: z.object({
userName: z.string(),
appName: z.string().default("Acme"),
}),
name: "Welcome Email",
description: "Sent when a new user signs up",
tags: ["onboarding"],
}
);
```
### 4. Mount the Bridge Endpoint
Pick the wrapper that matches your framework — see [Bridge Endpoint Setup](#bridge-endpoint-setup) below.
### 5. Run the Local Studio
```bash
npx novu@latest dev --port <YOUR_APP_PORT>
```
Open `http://localhost:2022` to preview workflows, edit controls, and trigger test events. The Studio creates a public tunnel automatically so Novu Cloud can reach your local bridge.
## Workflow Anatomy
> Designing the workflow itself? See [`design-workflow/`](../design-workflow) for channel selection, severity, `critical`, digest defaults, step conditions, and the 9 reference templates (order confirmation, payment failed, account suspended, comment, trial expiring, password reset, webhook fan-out, fetch-then-notify). The Framework SKILL covers **how to express** those decisions in code; `design-workflow/` covers **what to decide**.
```typescript
workflow(workflowId, handler, options);
```
| Param | Type | Description |
| --- | --- | --- |
| `workflowId` | `string` | Unique identifier in your environment |
| `handler` | `({ step, payload, subscriber }) => Promise<void>` | Workflow body — calls steps in order |
| `options` | `WorkflowOptions` | Schema, name, description, tags, preferences |
### Workflow Options
| Option | Type | Purpose |
| --- | --- | --- |
| `payloadSchema` | `ZodSchema \| JsonSchema \| ClassValidatorClass` | Validates the trigger payload, infers TS type for `payload` |
| `name` | `string` | Human-readable name shown in Dashboard / `<Inbox />` |
| `description` | `string` | Description shown in Dashboard |
| `tags` | `string[]` | Categorize for filtering / Inbox tabs |
| `severity` | `'low' \| 'medium' \| 'high'` | Visual prioritization in the Inbox. Leave unset for most workflows. |
| `critical` | `boolean` | Bypasses subscriber preferences, skips digest, runs without delays. Reserve for must-deliver events. |
| `preferences` | `WorkflowPreferences` | Default channel preferences and `readOnly` flag |
### Workflow Context
The handler receives `{ step, payload, subscriber }`:
- `step` — channel and action step builders (`step.email`, `step.delay`, `step.digest`, …)
- `payload` — strongly-typed data passed at trigger time, validated against `payloadSchema`
- `subscriber` — `{ subscriberId, firstName?, lastName?, locale?, data?, ... }` of the recipient
## Channel Steps
All channel steps share the same shape:
```typescript
await step.<channel>(stepId, resolver, options?);
```
| Step | Output Required | Notable Outputs | Returns Result |
| --- | --- | --- | --- |
| `step.email` | `subject`, `body` | `attachments`, `from`, `replyTo` | No |
| `step.sms` | `body` | — | No |
| `step.push` | `title` (or `subject`), `body` | `data`, `image`, `icon` | No |
| `step.chat` | `body` | — (override per-provider) | No |
| `step.inApp` | `body` | `subject`, `avatar`, `redirect`, `primaryAction`, `secondaryAction`, `data` | `{ seen, read, lastSeenDate, lastReadDate }` |
### Email Step
```typescript
await step.email("welcome", async (controls) => ({
subject: controls.subject,
body: render(<WelcomeEmail name={subscriber.firstName} />),
from: "hello@acme.com",
replyTo: "support@acme.com",
}));
```
### In-App Step (rich payload)
```typescript
await step.inApp("inbox", async () => ({
subject: "Welcome to Acme!",
body: "We are excited to have you on board.",
avatar: "https://acme.com/avatar.png",
redirect: { url: "/welcome", target: "_self" },
primaryAction: {
label: "Get Started",
redirect: { url: "/get-started", target: "_self" },
},
data: { entityType: "user", entityId: payload.userId },
}));
```
The In-App step **returns** `{ seen, read, lastSeenDate, lastReadDate }` — use it to drive the `skip` of subsequent steps.
### SMS / Push / Chat
```typescript
await step.sms("verification", async () => ({
body: `Your code is ${payload.code}`,
}));
await step.push("new-message", async () => ({
title: "New message",
body: payload.preview,
data: { messageId: payload.id },
}));
await step.chat("notify", async () => ({
body: `:rocket: Deploy ${payload.id} succeeded`,
}));
```
## Action Steps
### `step.delay`
Pause workflow execution before the next step.
```typescript
await step.delay("wait-a-day", async () => ({
unit: "days",
amount: 1,
}));
```
Supported `unit` values: `seconds`, `minutes`, `hours`, `days`, `weeks`, `months`.
### `step.digest`
Aggregate multiple triggers into a single notification over a window.
```typescript
const { events } = await step.digest("daily", async () => ({
unit: "days",
amount: 1,
digestKey: payload.projectId, // optional — group by custom key
}));
await step.email("summary", async () => ({
subject: `${events.length} updates today`,
body: render(<DigestEmail events={events} />),
}));
```
Use `cron: "0 0 * * *"` instead of `unit`/`amount` for cron-based digests. Each digest event has `{ id, time, payload }`. **Only one digest per workflow** — chain a second workflow via `step.custom` if you need a two-stage digest.
### `step.http`
Call an external HTTP endpoint as part of the workflow — webhook fan-out or just-in-time data fetch.
```typescript
const plan = await step.http("fetch-plan", async () => ({
method: "GET",
url: `https://api.example.com/users/${payload.userId}/plan`,
responseBodySchema: {
type: "object",
properties: { planName: { type: "string" }, renewalDate: { type: "string" } },
required: ["planName", "renewalDate"],
} as const,
}));
await step.email("notify", async () => ({
subject: `Your ${plan.planName} plan`,
body: `Renews on ${plan.renewalDate}.`,
}));
```
Webhook-style:
```typescript
await step.http("webhook", async () => ({
method: "POST",
url: payload.webhookUrl,
headers: [{ key: "Content-Type", value: "application/json" }],
body: [
{ key: "event", value: "payment_failed" },
{ key: "subscriberId", value: subscriber.subscriberId },
],
continueOnFailure: true,
}));
```
When a subsequent step references HTTP response data, the HTTP step **must** declare a `responseBodySchema`. Only properties declared in the schema are addressable as `{{ steps.<http-step-id>.<property> }}`.
### `step.custom`
Run arbitrary code and persist its result for later steps.
```typescript
const task = await step.custom("fetch-task", async () => {
const t = await db.fetchTask(payload.taskId);
return { id: t.id, title: t.title, complete: t.complete };
}, {
outputSchema: {
type: "object",
properties: {
id: { type: "string" },
title: { type: "string" },
complete: { type: "boolean" },
},
required: ["id", "complete"],
} as const,
});
await step.email("reminder", async () => ({
subject: `Reminder: ${task.title}`,
body: "Please complete your task.",
}), {
skip: () => task.complete,
});
```
The custom step result is **only** usable inside subsequent step `resolver`, `providers`, and `skip` functions — not in step controls.
## Step Options
```typescript
await step.email(stepId, resolver, {
controlSchema, // Zod | JSON Schema | Class-Validator class
skip, // (controls) => boolean | Promise<boolean>
providers, // per-provider override callbacks
disableOutputSanitization, // boolean — for raw HTML in Inbox
});
```
### `skip`
Conditionally skip a step. Receives the resolved controls.
```typescript
await step.email("follow-up", resolver, {
skip: () => inAppNotification.read === true,
});
```
### `providers` (Per-Step Provider Overrides)
Customize the request sent to the underlying provider — e.g. Slack `blocks` or SendGrid `cc`.
```typescript
await step.email("alert", resolver, {
providers: {
sendgrid: ({ controls, outputs }) => ({
from: "alerts@acme.com",
在 GitHub 查看