Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { AgentIntegrationGuideSection } from './agent-integration-guide-section';
import { AgentIntegrationGuideStep } from './agent-integration-guide-step';

type CloudflareAgentHostingGuideProps = {
agentIdentifier: string;
};

const WORKER_SNIPPET = `import { AIChatAgent } from '@cloudflare/ai-chat';
import { routeAgentRequest } from 'agents';
import { withNovuAgent, createNovuRouter } from '@novu/framework/cloudflare';

export class MyAgent extends withNovuAgent(AIChatAgent)<Env> {
static novuAgentId = '{{AGENT_ID}}';

async onNovuMessage(ctx) {
await ctx.reply('Hello from Cloudflare Workers!');
}
}

export default {
fetch: createNovuRouter({
agents: { MyAgent },
fallthrough: routeAgentRequest,
}),
};`;

const WRANGLER_SNIPPET = `{
"durable_objects": {
"bindings": [{ "name": "MyAgent", "class_name": "MyAgent" }]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["MyAgent"] }
]
}`;

export function CloudflareAgentHostingGuide({ agentIdentifier }: CloudflareAgentHostingGuideProps) {
const workerCode = WORKER_SNIPPET.replace('{{AGENT_ID}}', agentIdentifier);
Comment on lines +36 to +37
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

agentIdentifier is substituted into a single-quoted JS literal without escaping.

If agentIdentifier contains ', \, or line breaks, the generated workerCode snippet becomes invalid JS that a user would paste straight into their Worker. Probably rare for real agent IDs, but since this is rendered as copy-pasteable code it's worth hardening — either validate upstream, JSON-stringify the value, or switch the template to use JSON.stringify(agentIdentifier).

🔧 Suggested diff
-  static novuAgentId = '{{AGENT_ID}}';
+  static novuAgentId = {{AGENT_ID_LITERAL}};
-  const workerCode = WORKER_SNIPPET.replace('{{AGENT_ID}}', agentIdentifier);
+  const workerCode = WORKER_SNIPPET.replace('{{AGENT_ID_LITERAL}}', JSON.stringify(agentIdentifier));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/dashboard/src/components/agents/agent-integration-guides/cloudflare-agent-hosting-guide.tsx`
around lines 36 - 37, The worker snippet interpolation in
CloudflareAgentHostingGuide currently injects agentIdentifier directly into
WORKER_SNIPPET which breaks when the ID contains single quotes, backslashes, or
newlines; update the replacement to safely serialize the value (e.g., use
JSON.stringify(agentIdentifier) or otherwise escape it) when constructing
workerCode so the substituted value is valid JavaScript literal; adjust the call
in CloudflareAgentHostingGuide that does WORKER_SNIPPET.replace('{{AGENT_ID}}',
agentIdentifier) to replace with the JSON-stringified/escaped string instead.


return (
<div className="flex flex-col gap-4">
<AgentIntegrationGuideSection title="Deploy on Cloudflare Workers">
<p>
Use <code className="font-code text-[12px]">@novu/framework/cloudflare</code> to run your agent as a
Cloudflare Durable Object with built-in per-conversation state, scheduling, and multi-channel delivery through
Novu.
</p>
</AgentIntegrationGuideSection>

<div className="flex flex-col gap-3">
<p className="text-text-strong text-label-sm font-medium">Steps</p>

<AgentIntegrationGuideStep
step={1}
title="Install dependencies"
description="npm install @novu/framework agents @cloudflare/ai-chat"
/>

<AgentIntegrationGuideStep
step={2}
title="Create your Worker"
description={
<pre className="bg-bg-weak mt-1 overflow-x-auto rounded-md p-3 font-mono text-xs leading-relaxed">
{workerCode}
</pre>
}
/>

<AgentIntegrationGuideStep
step={3}
title="Add DO bindings to wrangler.jsonc"
description={
<pre className="bg-bg-weak mt-1 overflow-x-auto rounded-md p-3 font-mono text-xs leading-relaxed">
{WRANGLER_SNIPPET}
</pre>
}
/>

<AgentIntegrationGuideStep
step={4}
title="Set the Novu secret and deploy"
description="wrangler secret put NOVU_SECRET_KEY && wrangler deploy"
/>

<AgentIntegrationGuideStep
step={5}
title="Set the Bridge URL"
description="In the Novu dashboard, paste your Worker URL (https://<worker>.workers.dev/) as the agent's Bridge URL."
/>
</div>
</div>
);
Comment on lines +39 to +91
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether sibling integration guides use a shared "copy code" component.
fd -t f 'agent-integration-guide' apps/dashboard/src/components/agents
rg -nP 'copy|Copy|clipboard' apps/dashboard/src/components/agents/agent-integration-guides

Repository: novuhq/novu

Length of output: 1639


🏁 Script executed:

#!/bin/bash
# Examine the Cloudflare guide's imports and structure
head -40 apps/dashboard/src/components/agents/agent-integration-guides/cloudflare-agent-hosting-guide.tsx

# Check how other guides with code snippets handle copying (Slack example)
grep -n "pre\|<pre\|Copy\|copy" apps/dashboard/src/components/agents/agent-integration-guides/slack-agent-integration-guide.tsx | head -20

# Check AgentIntegrationGuideStep component
cat -n apps/dashboard/src/components/agents/agent-integration-guides/agent-integration-guide-step.tsx

# Check the CopyButton implementation
cat -n apps/dashboard/src/components/primitives/copy-button.tsx | head -50

Repository: novuhq/novu

Length of output: 3846


🏁 Script executed:

#!/bin/bash
# Get full AgentIntegrationGuideStep file to check complete type definition
cat apps/dashboard/src/components/agents/agent-integration-guides/agent-integration-guide-step.tsx

# Check what other guides with code snippets do (search for <pre in all guides)
rg -A 5 '<pre' apps/dashboard/src/components/agents/agent-integration-guides/

# Check slack guide more thoroughly for code block patterns
cat apps/dashboard/src/components/agents/agent-integration-guides/slack-agent-integration-guide.tsx

Repository: novuhq/novu

Length of output: 5056


🏁 Script executed:

#!/bin/bash
# Check full CloudflareAgentHostingGuide file for imports and any type assertions
wc -l apps/dashboard/src/components/agents/agent-integration-guides/cloudflare-agent-hosting-guide.tsx
cat apps/dashboard/src/components/agents/agent-integration-guides/cloudflare-agent-hosting-guide.tsx

# Check if other guides use AgentIntegrationGuideLayout wrapper
grep -l "AgentIntegrationGuideLayout" apps/dashboard/src/components/agents/agent-integration-guides/*.tsx

Repository: novuhq/novu

Length of output: 3577


Restructure to align with established pattern: use AgentIntegrationGuideLayout wrapper and update AgentIntegrationGuideStep to accept React elements.

Unlike other integration guides (slack, teams, generic, whatsapp), this guide does not use AgentIntegrationGuideLayout, which provides the infrastructure for copy buttons. Additionally, passing JSX <pre> elements to the description prop (typed as string) violates the component contract.

Either wrap the entire guide in AgentIntegrationGuideLayout to match the established pattern, or change AgentIntegrationGuideStep's description prop to accept React.ReactNode and add <CopyButton> wrapping each <pre> block. The former aligns with how other guides structure code snippets and gives copy affordances consistently.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/dashboard/src/components/agents/agent-integration-guides/cloudflare-agent-hosting-guide.tsx`
around lines 39 - 91, Wrap the guide JSX inside AgentIntegrationGuideLayout
(matching other guides) and update AgentIntegrationGuideStep so its description
prop accepts React.ReactNode instead of string; then replace the raw <pre>
blocks for workerCode and WRANGLER_SNIPPET with CopyButton-wrapped pre elements
(use the existing CopyButton component) so code snippets get copy affordances
and types align (update the prop type in AgentIntegrationGuideStep and any
usages).

}
60 changes: 60 additions & 0 deletions examples/cloudflare-agent/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Novu + Cloudflare Agents Example

A minimal example showing how to connect a Cloudflare `AIChatAgent` Durable Object to Novu's multi-channel delivery (Slack, WhatsApp, Teams, etc.).

## How it works

1. A user sends a message in Slack (or WhatsApp, Teams, etc.)
2. Novu receives the webhook and normalizes it
3. Novu POSTs a signed `AgentBridgeRequest` to your Worker's bridge URL
4. `createNovuRouter` verifies the HMAC and routes to the correct Durable Object (keyed by conversation ID)
5. `withNovuAgent` intercepts the request inside the DO and calls your `onNovuMessage` handler
6. Your handler calls `ctx.reply()` which POSTs back to Novu
7. Novu delivers the reply to the original channel

## Setup

### 1. Install dependencies

```bash
npm install
```

### 2. Set your Novu secret key

```bash
wrangler secret put NOVU_SECRET_KEY
```

Paste the secret key from your Novu dashboard (Settings > API Keys).

### 3. Deploy

```bash
npm run deploy
```

### 4. Connect to Novu

In the Novu dashboard:

1. Go to **Agents** and create an agent with identifier `wine-bot`
2. Set the **Bridge URL** to `https://<your-worker>.workers.dev/`
3. Add a channel integration (Slack, WhatsApp, etc.) to the agent

That's it. Messages from the connected channel will now flow through your Cloudflare Agent.

## Local development

```bash
npm run dev
```

Use `wrangler dev --remote` for a stable URL that Novu can reach, or use a tunnel like `cloudflared tunnel`.

## Key concepts

- **`withNovuAgent(AIChatAgent)`** — mixin that adds `onNovuMessage`, `onNovuAction`, `onNovuReaction`, `onNovuResolve` lifecycle hooks to any Cloudflare Agent class
- **`createNovuRouter`** — Worker fetch handler that verifies Novu signatures, routes bridge calls to the right DO, and falls through to `routeAgentRequest` for standard Cloudflare Agent traffic
- **`ctx.serialize()`** — returns a `NovuConversationRef` you can pass to `this.schedule()`, `this.setState()`, or any other persistence mechanism to reply later
- **`this.replyFromRef(ref, content)`** — reply to a Novu conversation from any context (scheduled task, `@callable`, `onEmail`, etc.)
17 changes: 17 additions & 0 deletions examples/cloudflare-agent/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "novu-cloudflare-agent-example",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy"
},
"dependencies": {
"@novu/framework": "file:../../packages/framework",
"agents": "latest"
},
"devDependencies": {
"wrangler": "latest",
"typescript": "^5.5.0"
}
Comment on lines +9 to +16
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Latest stable versions of @cloudflare/ai-chat, agents, ai (Vercel), workers-ai-provider, and wrangler npm packages

💡 Result:

Latest stable versions (as of 2026-04-17): - @cloudflare/ai-chat: 0.4.2 - agents: 0.11.0 - ai (Vercel): 6.0.167 - workers-ai-provider: 3.1.11 - wrangler: 4.83.0

Citations:


Pin dependency versions instead of "latest".

Using "latest" makes this example non-reproducible and violates the coding guideline requiring adherence to minimumReleaseAge"latest" bypasses that control entirely. Additionally, in a monorepo context, @novu/framework should resolve to the local workspace using workspace:* rather than the npm tag.

Replace each "latest" with a pinned version (e.g., caret ranges like ^X.Y.Z) that you've tested against. For @novu/framework, use workspace:* to reference the local package.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/cloudflare-agent/package.json` around lines 9 - 19, Update
package.json dependencies to avoid "latest": replace each "latest" for
"@cloudflare/ai-chat", "agents", "ai", and "workers-ai-provider" with pinned,
tested caret ranges (e.g., "^X.Y.Z") you verified; change "@novu/framework":
"latest" to "@novu/framework": "workspace:*" so it resolves to the local
workspace; keep devDependencies pinned as well if needed and ensure versions
comply with minimumReleaseAge policy.

}
88 changes: 88 additions & 0 deletions examples/cloudflare-agent/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Agent, routeAgentRequest } from 'agents';
import {
withNovuAgent,
createNovuRouter,
toMessageList,
Card,
CardText,
Actions,
Button,
type NovuConversationRef,
type AgentContext,
} from '@novu/framework/cloudflare';

interface Env {
AI: Ai;
CleaningBot: DurableObjectNamespace;
NOVU_SECRET_KEY: string;
}

export class CleaningBot extends withNovuAgent(Agent)<Env> {
static novuAgentId = 'cleaning-bot';

async onNovuMessage(ctx: AgentContext) {
const messages = toMessageList(ctx);

const result = await this.env.AI.run('@cf/meta/llama-3.3-70b-instruct-fp8-fast', {
messages: [
{
role: 'system',
content:
'You are a helpful cleaning assistant. Keep answers concise (2-3 sentences max). Give practical cleaning tips.',
},
...messages,
],
});

const text = (result as { response?: string }).response ?? 'Sorry, I could not generate a response.';

await ctx.reply(
Card({
title: 'Cleaning Assistant',
children: [
CardText(text),
Actions([
Button({ id: 'follow-up', label: 'Remind me in 30s', style: 'primary' }),
Button({ id: 'done', label: 'Thanks, all clean!', style: 'secondary' }),
]),
],
})
);
}

async onNovuAction(ctx: AgentContext) {
if (ctx.action?.actionId === 'follow-up') {
await ctx.reply('Got it! I\'ll send you a reminder in 30 seconds.');
this.schedule(30, 'sendReminder', ctx.serialize());

return;
}

if (ctx.action?.actionId === 'done') {
ctx.metadata.set('resolved', true);
ctx.metadata.set('resolvedAt', new Date().toISOString());
await ctx.reply(
Card({
title: 'Session complete',
children: [
CardText('Great job! Your space is sparkling clean. Message me anytime you need more tips.'),
],
})
);
ctx.resolve('User marked as done');
}
}

async sendReminder(ref: NovuConversationRef) {
await this.replyFromRef(ref, {
markdown: 'Hey! Just checking in — did you finish that cleaning task? Let me know if you need more tips!',
});
}
}

export default {
fetch: createNovuRouter({
agents: { CleaningBot },
fallthrough: routeAgentRequest,
}),
};
33 changes: 33 additions & 0 deletions examples/cloudflare-agent/wrangler.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "novu-cloudflare-agent-example",
"main": "src/index.ts",
"compatibility_date": "2025-04-01",
"compatibility_flags": ["nodejs_compat"],
"observability": {
"logs": {
"enabled": true,
"invocation_logs": true
}
},
"ai": {
"binding": "AI"
},
"durable_objects": {
"bindings": [
{
"name": "CleaningBot",
"class_name": "CleaningBot"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["WineBot"]
},
{
"tag": "v2",
"renamed_classes": [{ "from": "WineBot", "to": "CleaningBot" }]
}
]
}
3 changes: 3 additions & 0 deletions packages/framework/cloudflare/helpers/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"main": "../../dist/cjs/servers/cloudflare/helpers.cjs"
}
3 changes: 3 additions & 0 deletions packages/framework/cloudflare/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"main": "../dist/cjs/servers/cloudflare/index.cjs"
}
Loading
Loading