-
Notifications
You must be signed in to change notification settings - Fork 4.3k
feat(framework): Add Cloudflare Workers adapter and example #10775
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: next
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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-guidesRepository: 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 -50Repository: 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.tsxRepository: 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/*.tsxRepository: novuhq/novu Length of output: 3577 Restructure to align with established pattern: use Unlike other integration guides (slack, teams, generic, whatsapp), this guide does not use Either wrap the entire guide in 🤖 Prompt for AI Agents |
||
| } | ||
| 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.) |
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: Latest stable versions (as of 2026-04-17): - Citations:
Pin dependency versions instead of Using Replace each 🤖 Prompt for AI Agents |
||
| } | ||
| 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, | ||
| }), | ||
| }; |
| 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" }] | ||
| } | ||
| ] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| { | ||
| "main": "../../dist/cjs/servers/cloudflare/helpers.cjs" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| { | ||
| "main": "../dist/cjs/servers/cloudflare/index.cjs" | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
agentIdentifieris substituted into a single-quoted JS literal without escaping.If
agentIdentifiercontains',\, or line breaks, the generatedworkerCodesnippet 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 useJSON.stringify(agentIdentifier).🔧 Suggested diff
🤖 Prompt for AI Agents