skills/framework-integration/SKILL.md
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.
npx skillsauth add novuhq/skills novu-framework-integrationInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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— no Framework needed.
| 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.
workflow(...) from @novu/framework./api/novu HTTP route in your app — the Bridge Endpoint.npx novu sync or GitHub Action).Trigger ──► Novu Cloud Worker ──► Your Bridge (/api/novu) ──► Provider (SendGrid, FCM, …)
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.
npm install @novu/framework zod @react-email/components react-email
NOVU_SECRET_KEY=<YOUR_NOVU_SECRET_KEY>
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"],
}
);
Pick the wrapper that matches your framework — see Bridge Endpoint Setup below.
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.
Designing the workflow itself? See
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.
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 |
| 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 |
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 payloadSchemasubscriber — { subscriberId, firstName?, lastName?, locale?, data?, ... } of the recipientAll channel steps share the same shape:
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 } |
await step.email("welcome", async (controls) => ({
subject: controls.subject,
body: render(<WelcomeEmail name={subscriber.firstName} />),
from: "[email protected]",
replyTo: "[email protected]",
}));
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.
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`,
}));
step.delayPause workflow execution before the next step.
await step.delay("wait-a-day", async () => ({
unit: "days",
amount: 1,
}));
Supported unit values: seconds, minutes, hours, days, weeks, months.
step.digestAggregate multiple triggers into a single notification over a window.
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.httpCall an external HTTP endpoint as part of the workflow — webhook fan-out or just-in-time data fetch.
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:
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.customRun arbitrary code and persist its result for later steps.
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.
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
});
skipConditionally skip a step. Receives the resolved controls.
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.
await step.email("alert", resolver, {
providers: {
sendgrid: ({ controls, outputs }) => ({
from: "[email protected]",
cc: ["[email protected]"],
_passthrough: {
body: { ip_pool_name: "transactional" },
headers: { "X-Custom": "value" },
},
}),
},
});
_passthrough deep-merges into the final provider request — typed provider keys take precedence over _passthrough.
disableOutputSanitizationAllow raw HTML / unescaped characters in the output (e.g. & in In-App data.link):
await step.inApp("link", async () => ({
body: "Check it out",
data: { link: "/p/123?active=true&env=prod" },
}), { disableOutputSanitization: true });
The payload is the data passed at trigger time. Define a schema to get typed payload and runtime validation.
import { z } from "zod";
workflow("comment", handler, {
payloadSchema: z.object({
postId: z.number(),
authorName: z.string(),
comment: z.string().max(200),
}),
});
workflow("comment", handler, {
payloadSchema: {
type: "object",
properties: {
postId: { type: "number" },
authorName: { type: "string" },
comment: { type: "string", maxLength: 200 },
},
required: ["postId", "comment"],
additionalProperties: false,
} as const,
});
The
as constis required for TS to infer the payload type from JSON Schema.
import { IsString, IsNumber } from "class-validator";
class CommentPayload {
@IsNumber() postId!: number;
@IsString() authorName!: string;
@IsString() comment!: string;
}
workflow("comment", handler, { payloadSchema: CommentPayload });
Requires class-validator, class-validator-jsonschema, reflect-metadata. See references/schema-validation.md.
Controls are step-level inputs your non-technical peers can edit in the Novu Dashboard UI without touching code. They're validated by a schema you define (Zod / JSON Schema / Class-Validator).
await step.email("welcome", async (controls) => ({
subject: controls.subject,
body: render(<EmailTemplate hideBanner={controls.hideBanner} />),
}), {
controlSchema: z.object({
hideBanner: z.boolean().default(false),
subject: z.string().default("Hi {{subscriber.firstName | capitalize}}"),
}),
});
Control values support LiquidJS templating:
{{subscriber.firstName}} — any subscriber attribute{{payload.userId}} — any payload field defined in payloadSchema{{payload.invoiceDate | date: '%a, %b %d, %y'}} — Liquid filters{{subscriber.firstName | append: ': ' | append: payload.status | capitalize}} — chained filtersType {{ in the Dashboard UI to autocomplete available variables.
| | Controls | Payload |
| --- | --- | --- |
| Edited by | Non-technical peers in Dashboard | Developers in code |
| Schema | controlSchema per step | payloadSchema per workflow |
| Persistence | Stored in Novu Cloud per environment | Sent at trigger time |
| Use case | Subject, copy, styling, behaviour toggles | Dynamic per-trigger data |
Define default channel preferences in code. See manage-preferences for the full preference resolution model.
workflow("system-alert", handler, {
preferences: {
all: { enabled: true, readOnly: false },
channels: {
email: { enabled: true },
sms: { enabled: false },
inApp: { enabled: true },
},
},
});
all.readOnly: true makes the workflow critical — subscribers cannot disable it.all.enabled is the fallback for any channel not in channels.enabled: true, readOnly: false for all channels.The Bridge is a single HTTP route (/api/novu by default) where Novu Cloud calls your app to:
GET)POST)Each framework ships a serve wrapper that handles parsing, HMAC verification, and response shaping.
import { serve } from "@novu/framework/next";
import { welcomeWorkflow } from "@/novu/workflows";
export const { GET, POST, OPTIONS } = serve({
workflows: [welcomeWorkflow],
});
import { serve } from "@novu/framework/next";
import { welcomeWorkflow } from "../../novu/workflows";
export default serve({ workflows: [welcomeWorkflow] });
import express from "express";
import { serve } from "@novu/framework/express";
import { welcomeWorkflow } from "./novu/workflows";
const app = express();
app.use(express.json()); // required
app.use("/api/novu", serve({ workflows: [welcomeWorkflow] }));
app.listen(4000);
import { Module } from "@nestjs/common";
import { NovuModule } from "@novu/framework/nest";
import { welcomeWorkflow } from "./novu/workflows";
@Module({
imports: [
NovuModule.register({
apiPath: "/api/novu",
workflows: [welcomeWorkflow],
}),
],
})
export class AppModule {}
For dependency injection, use NovuModule.registerAsync — see references/bridge-endpoint.md.
import { serve } from "@novu/framework/remix";
import { welcomeWorkflow } from "../novu/workflows";
const handler = serve({ workflows: [welcomeWorkflow] });
export { handler as action, handler as loader };
import { serve } from "@novu/framework/sveltekit";
import { welcomeWorkflow } from "$lib/novu/workflows";
export const { GET, POST, OPTIONS } = serve({ workflows: [welcomeWorkflow] });
import { serve } from "@novu/framework/nuxt";
import { welcomeWorkflow } from "~/novu/workflows";
export default defineEventHandler(serve({ workflows: [welcomeWorkflow] }));
import { createApp, eventHandler, toNodeListener } from "h3";
import { createServer } from "node:http";
import { serve } from "@novu/framework/h3";
import { welcomeWorkflow } from "./novu/workflows";
const app = createApp();
app.use("/api/novu", eventHandler(serve({ workflows: [welcomeWorkflow] })));
createServer(toNodeListener(app)).listen(4000);
import { serve } from "@novu/framework/lambda";
import { welcomeWorkflow } from "./novu/workflows";
export const novu = serve({ workflows: [welcomeWorkflow] });
import { NovuRequestHandler, ServeHandlerOptions } from "@novu/framework";
export const serve = (options: ServeHandlerOptions) =>
new NovuRequestHandler({
frameworkName: "my-framework",
...options,
handler: (req, res) => ({ /* method, headers, body, url, transformResponse */ }),
}).createHandler();
See references/bridge-endpoint.md for the full custom handler signature.
Live preview of your workflows with a public tunnel for Novu Cloud to reach your machine.
npx novu@latest dev
# Defaults: --port 4000 --route /api/novu --studio-port 2022
Then open http://localhost:2022 (Chrome only).
| Flag | Default | Purpose |
| --- | --- | --- |
| -p, --port | 4000 | Your app's port |
| -r, --route | /api/novu | Bridge route path |
| -o, --origin | http://localhost | Bridge origin |
| -d, --dashboard-url | https://dashboard.novu.co | Dashboard URL — use https://eu.dashboard.novu.co for EU |
| -sp, --studio-port | 2022 | Studio UI port |
| -t, --tunnel | auto | Self-hosted tunnel URL (e.g. ngrok) |
| -H, --headless | false | Skip the Studio UI |
npx novu@latest dev --port 3002 --dashboard-url https://eu.dashboard.novu.co
The Studio:
https://<id>.novu.sh/api/novuprocess.env.NODE_ENV=development — HMAC verification is off to allow Studio accessCode-defined workflows are triggered the same way as Dashboard workflows — using @novu/api from your trigger surface (server, queue worker, webhook handler):
import { Novu } from "@novu/api";
const novu = new Novu({ secretKey: process.env.NOVU_SECRET_KEY });
await novu.trigger({
workflowId: "welcome-email",
to: { subscriberId: "user-123", email: "[email protected]" },
payload: { userName: "Jane", appName: "Acme" },
});
You can also trigger a workflow from inside a step.custom of another workflow:
await step.custom("trigger-summary", async () => {
return await summaryWorkflow.trigger({
to: subscriber.subscriberId,
payload: { events: events.map(e => e.payload) },
});
});
See trigger-notification for full trigger options (bulk, broadcast, topics, overrides, transactionId, cancel).
Render emails using your existing component library.
npm install @react-email/components react-email
import { Body, Container, Head, Html, render } from "@react-email/components";
export const WelcomeEmail = ({ name }: { name: string }) => (
<Html>
<Head />
<Body>
<Container>Hello {name}, welcome!</Container>
</Body>
</Html>
);
export const renderWelcome = (name: string) => render(<WelcomeEmail name={name} />);
await step.email("welcome", async () => ({
subject: "Welcome",
body: renderWelcome(payload.userName),
}));
Vue Email, Svelte Email, and Remix + React Email are also supported. See references/email-templates.md.
For Framework-based workflows, translation lives in your code (not in the Novu Translation system, which targets Dashboard workflows). Use any i18n library (e.g. i18next) and resolve content from subscriber.locale inside the resolver.
import { workflow } from "@novu/framework";
import i18n from "./i18n";
export const localizedWorkflow = workflow(
"welcome-localized",
async ({ step, subscriber }) => {
await step.email("email", async (controls) => {
const t = i18n.getFixedT([subscriber.locale ?? controls.defaultLocale]);
return {
subject: t("welcomeEmailSubject", { username: subscriber.firstName }),
body: render(<Welcome subject={t("subject")} body={t("body")} />),
};
}, {
controlSchema: z.object({
defaultLocale: z.string().default("en_US"),
}),
});
},
);
See references/translations.md for a complete i18next + React Email example.
Tag a workflow to group it with related notifications (used by Inbox tabs and Dashboard filtering):
workflow("login-alert", handler, { tags: ["security"] });
workflow("password-change", handler, { tags: ["security"] });
In the Inbox, render a "Security" tab with tabs={[{ label: "Security", filter: { tags: ["security"] } }]} (see inbox-integration).
Push your workflows to Novu Cloud:
npx novu@latest sync \
--bridge-url https://api.acme.com/api/novu \
--secret-key $NOVU_SECRET_KEY \
--api-url https://api.novu.co # use https://eu.api.novu.co for EU
name: Sync Novu Workflows
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: novuhq/actions-novu-sync@v2
with:
secret-key: ${{ secrets.NOVU_SECRET_KEY }}
bridge-url: ${{ secrets.NOVU_BRIDGE_URL }}
api-url: https://api.novu.co
npx novu sync against the Development environment to test e2e.main — CI runs npx novu sync against Production.GitLab CI, Jenkins, CircleCI, Bitbucket, Azure DevOps, and Travis CI all work via the CLI.
NODE_ENV !== "development". The serve wrapper handles this — you don't need to write any code. Each request includes a Novu-Signature header (t=timestamp,v1=signature) that's verified against NOVU_SECRET_KEY.NODE_ENV=development. Don't disable it in production.?x-vercel-protection-bypass=<token> in your bridge URL.Override defaults globally:
import { Client as NovuFrameworkClient } from "@novu/framework";
import { serve } from "@novu/framework/next";
export const { GET, POST, OPTIONS } = serve({
client: new NovuFrameworkClient({
secretKey: process.env.NOVU_SECRET_KEY,
strictAuthentication: false, // disables HMAC — only for local dev
}),
workflows: [/* … */],
});
Environment variables read by the Client:
NOVU_SECRET_KEY — your secret keyNOVU_API_URL — defaults to https://api.novu.co (use https://eu.api.novu.co for EU)localhost won't work for Novu Cloud. Use the Studio tunnel locally; deploy publicly for production.workflowId is the trigger identifier — same id you'll pass to novu.trigger({ workflowId }). Use kebab-case and keep it stable.ids must be unique within a workflow — duplicates throw at registration.as const on JSON Schema — without it, TS infers string instead of literal types and payload becomes unknown.step.digest per workflow — chain a second workflow via step.custom for two-stage digest patterns.resolver, providers, or skip callbacks.npx novu sync to your CI/CD.NODE_ENV !== "development" — set it to development for the Studio to reach your bridge, or disable strict auth in your Client.secretKey in the client bundle — it's server-only. Keep workflows + bridge route inside server code, not in any "use client" module._passthrough is unvalidated — typos won't error at compile time. Use known typed provider keys whenever possible.step.custom (custom is the only step whose result is durably persisted).@novu/framework requires Node.js ≥ 20.src/novu/workflows/<workflow-id>.ts, re-exported from a barrel src/novu/workflows/index.ts.oneOf, if/then/else, $ref).src/novu/workflows/welcome/template.tsx).step.custom logic into helpers (fetchUser(payload.userId)) for reuse.NovuModule.registerAsync with a NotificationService so workflow definitions can inject services.serve, NestJS DInpx novu sync, GitHub Action, GitOps recipe, EU regiondata-ai
Trigger Novu notification workflows to send messages across email, SMS, push, chat, and in-app channels. Supports single triggers, bulk triggers, broadcast to all subscribers, topic-based targeting, and cancellation. Use when sending transactional notifications, alerts, or any event-driven messages.
testing
Create, update, search, and delete subscribers in Novu. Manage topics for group-based notification targeting. Set subscriber credentials for push and chat channels. Use when managing notification recipients, creating subscriber records, organizing subscribers into topics, or configuring channel-specific credentials.
development
Configure notification preferences in Novu at the workflow and subscriber level. Set default channel preferences (email, SMS, push, chat, in-app), mark preferences as read-only or subscriber-editable, and manage subscriber-specific overrides. Use when setting up notification opt-in/opt-out, configuring per-channel delivery preferences, or building a preferences management UI.
development
Integrate Novu's in-app notification inbox into web applications. Supports React, Next.js, and vanilla JavaScript. Includes the Inbox component (bell icon + notification feed), composable components (Bell, Notifications, InboxContent, Preferences), headless hooks, branded theming, custom render props, multi-tenancy via contexts, tabs, localization, and HMAC security. Use when adding an in-app notification center, bell icon, notification feed, real-time notification updates, or building a personalized and branded notification experience.