Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
4 changes: 3 additions & 1 deletion packages/novu/src/commands/init/create-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export class DownloadError extends Error {}
export async function createApp({
appPath,
packageManager,
templateChoice,
typescript,
eslint,
srcDir,
Expand All @@ -26,6 +27,7 @@ export async function createApp({
}: {
appPath: string;
packageManager: PackageManager;
templateChoice: string;
typescript: boolean;
eslint: boolean;
srcDir: boolean;
Expand All @@ -36,7 +38,7 @@ export async function createApp({
}): Promise<void> {
let repoInfo: RepoInfo | undefined;
const mode: TemplateMode = typescript ? 'ts' : 'js';
const template: TemplateType = 'app-react-email';
const template: TemplateType = templateChoice === 'agent' ? 'app-agent' : 'app-react-email';

const root = path.resolve(appPath);

Expand Down
29 changes: 25 additions & 4 deletions packages/novu/src/commands/init/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface IInitCommandOptions {
secretKey?: string;
projectPath?: string;
apiUrl: string;
template?: string;
}

export async function init(program: IInitCommandOptions, anonymousId?: string): Promise<void> {
Expand Down Expand Up @@ -148,11 +149,30 @@ export async function init(program: IInitCommandOptions, anonymousId?: string):
process.exit(1);
}

let templateChoice = program.template;

if (!templateChoice) {
const res = await prompts({
onState: onPromptState,
type: 'select',
name: 'template',
message: 'What type of Novu app do you want to create?',
choices: [
{ title: 'Notifications', value: 'notifications', description: 'Workflows, email templates, and in-app inbox' },
{ title: 'Agent', value: 'agent', description: 'Conversational AI agent with chat platform support' },
],
initial: 0,
});

templateChoice = res.template;
}

if (!templateChoice) {
console.error('No template selected.');
process.exit(1);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const preferences = {} as Record<string, boolean | string>;
/**
* If the user does not provide the necessary flags, prompt them for whether
* to use TS or JS.
*/
const defaults: typeof preferences = {
typescript: true,
eslint: true,
Expand All @@ -175,6 +195,7 @@ export async function init(program: IInitCommandOptions, anonymousId?: string):
await createApp({
appPath: resolvedProjectPath,
packageManager: 'npm',
templateChoice,
typescript: defaults.typescript as boolean,
eslint: defaults.eslint as boolean,
srcDir: defaults.srcDir as boolean,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Novu Agent

A conversational AI agent powered by [Novu](https://novu.co) and [Next.js](https://nextjs.org).

## Getting Started

1. Start the development server:

```bash
npm run dev
```

2. Connect a chat platform in the [Novu Dashboard](https://dashboard.novu.co).

3. Replace the demo handler in `app/novu/agents/support-agent.tsx` with your LLM call.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Your agent is served at `/api/novu` and handles incoming messages via the Novu Bridge protocol.

## Project Structure

```text
app/
api/novu/route.ts → Bridge endpoint serving your agent
novu/agents/
index.ts → Agent exports
support-agent.tsx → Your agent handler (edit this!)
page.tsx → Landing page
```

## Agent API

Your agent handler receives a context object with:

| Method / Property | Description |
|---|---|
| `ctx.message` | The inbound message (text, author, timestamp) |
| `ctx.conversation` | Current conversation state and metadata |
| `ctx.history` | Recent conversation history |
| `ctx.subscriber` | Resolved subscriber info |
| `ctx.platform` | Source platform (slack, teams, whatsapp) |
| `ctx.reply(content)` | Send a reply (text, Markdown, or Card) |
| `ctx.metadata.set(k, v)` | Set conversation metadata |
| `ctx.resolve(summary?)` | Mark conversation as resolved |
| `ctx.trigger(workflowId)` | Trigger a Novu workflow |

## Wiring Up Your LLM

Replace the demo handler in `app/novu/agents/support-agent.tsx` with your LLM call:

```typescript
onMessage: async (ctx) => {
const response = await openai.chat.completions.create({
model: 'gpt-4',
messages: [
{ role: 'system', content: 'You are a helpful support agent.' },
...ctx.history.map((h) => ({ role: h.role, content: h.content })),
{ role: 'user', content: ctx.message?.text ?? '' },
],
});

await ctx.reply(response.choices[0].message.content ?? '');
},
```

## Learn More

- [Novu Agent Docs](https://docs.novu.co/agents)
- [Novu Framework SDK](https://docs.novu.co/framework)
- [Next.js Documentation](https://nextjs.org/docs)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { serve } from '@novu/framework/next';
import { supportAgent } from '../../novu/agents';

export const { GET, POST, OPTIONS } = serve({
agents: [supportAgent],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}

body {
background: #fafafa;
color: #171717;
}

@media (prefers-color-scheme: dark) {
body {
background: #0a0a0a;
color: #fafafa;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Metadata } from 'next';
import './globals.css';

export const metadata: Metadata = {
title: 'Novu Agent',
description: 'Conversational AI agent powered by Novu',
};

export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {

return (
<html lang="en">
<body>{children}</body>
</html>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { supportAgent } from './support-agent';
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/** @jsxImportSource @novu/framework */
import { agent, Card, CardText, Actions, Button } from '@novu/framework';

export const supportAgent = agent('support-agent', {
onMessage: async (ctx) => {
const text = (ctx.message?.text ?? '').toLowerCase();
const isFirstMessage = ctx.conversation.messageCount <= 1;

if (isFirstMessage) {
ctx.metadata.set('topic', 'unknown');
await ctx.reply(
<Card title="Hi, I'm Support Agent">
<CardText>How can I help you today?</CardText>
<Actions>
<Button id="topic-billing" label="Billing question" value="billing" />
<Button id="topic-technical" label="Technical issue" value="technical" />
<Button id="topic-other" label="Something else" value="other" />
</Actions>
</Card>
);

return;
}

if (text.includes('resolve') || text.includes('thanks')) {
ctx.resolve(`Resolved by user: ${text}`);
await ctx.reply('Glad I could help! Marking this resolved.');

return;
}

// Replace this block with your LLM call (OpenAI, Anthropic, etc.)
ctx.metadata.set('lastMessage', text);
await ctx.reply({
markdown:
`**Got it.** You said: "${ctx.message?.text}"\n\n` +
`_This is a demo agent. Replace this handler with your LLM call._\n\n` +
`**Conversation so far:** ${ctx.history.length} messages | ` +
`**Topic:** ${ctx.conversation.metadata?.topic ?? 'unknown'}`,
});
},

onAction: async (ctx) => {
const { actionId, value } = ctx.action!;
if (actionId.startsWith('topic-') && value) {
ctx.metadata.set('topic', value);
await ctx.reply({
markdown: `Topic set to **${value}**. Describe your issue and I'll help.`,
});
}
},

onResolve: async (ctx) => {
ctx.metadata.set('resolvedAt', new Date().toISOString());
// Trigger a follow-up workflow when a conversation is resolved:
// ctx.trigger('follow-up-survey', { to: ctx.subscriber?.subscriberId });
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
.main {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 2rem;
font-family:
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
}

.container {
max-width: 640px;
width: 100%;
}

.title {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 0.5rem;
color: #171717;
}

.description {
font-size: 1.1rem;
color: #525252;
margin-bottom: 2rem;
}

.code {
background: #f5f5f5;
padding: 0.2em 0.4em;
border-radius: 4px;
font-size: 0.9em;
font-family: monospace;
}

.steps,
.features {
margin-bottom: 2rem;
}

.steps h2,
.features h2 {
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 0.75rem;
color: #171717;
}

.steps ol {
padding-left: 1.5rem;
line-height: 1.8;
color: #525252;
}

.features ul {
padding-left: 1.5rem;
line-height: 1.8;
color: #525252;
}

.steps a {
color: #2563eb;
text-decoration: none;
}

.steps a:hover {
text-decoration: underline;
}

.links {
display: flex;
gap: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #e5e5e5;
}

.links a {
color: #2563eb;
text-decoration: none;
font-weight: 500;
}

.links a:hover {
text-decoration: underline;
}

@media (prefers-color-scheme: dark) {
.title {
color: #fafafa;
}

.description {
color: #a3a3a3;
}

.code {
background: #262626;
}

.steps h2,
.features h2 {
color: #fafafa;
}

.steps ol,
.features ul {
color: #a3a3a3;
}

.links {
border-top-color: #404040;
}
}
Loading
Loading