Skip to content
Merged
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
Expand Up @@ -93,7 +93,7 @@ export class AgentInboundHandler {
organizationId: config.organizationId,
});

const channel = conversation.channels[0];
const channel = conversation.channels?.[0];
const isFirstMessage = !channel?.firstPlatformMessageId;

if (isFirstMessage && config.reactionOnMessageReceived && message.id) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
SubscriberRepository,
} from '@novu/dal';
import { AgentEventEnum } from '../../dtos/agent-event.enum';
import { AgentConfigResolver } from '../../services/agent-config-resolver.service';
import { AgentConfigResolver, ResolvedAgentConfig } from '../../services/agent-config-resolver.service';
import { AgentConversationService } from '../../services/agent-conversation.service';
import { BridgeExecutorService } from '../../services/bridge-executor.service';
import { ChatSdkService } from '../../services/chat-sdk.service';
Expand Down Expand Up @@ -59,10 +59,15 @@ export class HandleAgentReply {
return { status: 'update_sent' };
}

const needsConfig = !!(command.reply || command.resolve);
const config = needsConfig
? await this.agentConfigResolver.resolve(conversation._agentId, command.integrationIdentifier)
: null;

if (command.reply) {
await this.deliverMessage(command, conversation, channel, command.reply, ConversationActivityTypeEnum.MESSAGE);

this.removeAckReaction(command, conversation, channel).catch((err) => {
this.removeAckReaction(config!, conversation, channel).catch((err) => {
this.logger.warn(err, `[agent:${command.agentIdentifier}] Failed to remove ack reaction`);
});
}
Expand All @@ -72,7 +77,7 @@ export class HandleAgentReply {
}

if (command.resolve) {
await this.executeResolveSignal(command, conversation, channel, command.resolve);
await this.executeResolveSignal(command, config!, conversation, channel, command.resolve);
}

return { status: 'ok' };
Expand Down Expand Up @@ -198,6 +203,7 @@ export class HandleAgentReply {

private async executeResolveSignal(
command: HandleAgentReplyCommand,
config: ResolvedAgentConfig,
conversation: ConversationEntity,
channel: ConversationChannel,
signal: { summary?: string }
Expand All @@ -223,29 +229,26 @@ export class HandleAgentReply {
}),
]);

this.reactOnResolve(command, conversation, channel).catch((err) => {
this.reactOnResolve(config, conversation, channel).catch((err) => {
this.logger.warn(err, `[agent:${command.agentIdentifier}] Failed to add resolve reaction`);
});

this.fireOnResolveBridgeCall(command, conversation).catch((err) => {
this.fireOnResolveBridgeCall(command, config, conversation).catch((err) => {
this.logger.error(err, `[agent:${command.agentIdentifier}] Failed to fire onResolve bridge call`);
});
}

private async removeAckReaction(
command: HandleAgentReplyCommand,
config: ResolvedAgentConfig,
conversation: ConversationEntity,
channel: ConversationChannel
): Promise<void> {
const firstMessageId = channel.firstPlatformMessageId;
if (!firstMessageId) return;

const config = await this.agentConfigResolver.resolve(conversation._agentId, command.integrationIdentifier);
if (!config.reactionOnMessageReceived) return;
if (!firstMessageId || !config.reactionOnMessageReceived) return;

await this.chatSdkService.removeReaction(
conversation._agentId,
command.integrationIdentifier,
config.integrationIdentifier,
channel.platform,
channel.platformThreadId,
firstMessageId,
Expand All @@ -254,19 +257,16 @@ export class HandleAgentReply {
}

private async reactOnResolve(
command: HandleAgentReplyCommand,
config: ResolvedAgentConfig,
conversation: ConversationEntity,
channel: ConversationChannel
): Promise<void> {
const firstMessageId = channel.firstPlatformMessageId;
if (!firstMessageId) return;

const config = await this.agentConfigResolver.resolve(conversation._agentId, command.integrationIdentifier);
if (!config.reactionOnResolved) return;
if (!firstMessageId || !config.reactionOnResolved) return;

await this.chatSdkService.reactToMessage(
conversation._agentId,
command.integrationIdentifier,
config.integrationIdentifier,
channel.platform,
channel.platformThreadId,
firstMessageId,
Expand All @@ -276,10 +276,9 @@ export class HandleAgentReply {

private async fireOnResolveBridgeCall(
command: HandleAgentReplyCommand,
config: ResolvedAgentConfig,
conversation: ConversationEntity
): Promise<void> {
const config = await this.agentConfigResolver.resolve(conversation._agentId, command.integrationIdentifier);

const subscriberParticipant = conversation.participants.find((p) => p.type === 'subscriber');
const [subscriber, history] = await Promise.all([
subscriberParticipant
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,12 @@ export class ConversationRepository extends BaseRepositoryV2<
_id: id,
_environmentId: environmentId,
_organizationId: organizationId,
'channels.platformThreadId': platformThreadId,
channels: {
$elemMatch: {
platformThreadId,
firstPlatformMessageId: { $exists: false },
},
},
},
{ $set: { 'channels.$.firstPlatformMessageId': firstPlatformMessageId } }
);
Expand Down
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
35 changes: 31 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,36 @@ export async function init(program: IInitCommandOptions, anonymousId?: string):
process.exit(1);
}

const supportedTemplates = ['notifications', 'agent'] as const;
let templateChoice = program.template;

if (templateChoice && !supportedTemplates.includes(templateChoice as (typeof supportedTemplates)[number])) {
console.error(`Invalid template "${program.template}". Supported templates: ${supportedTemplates.join(', ')}`);
process.exit(1);
}

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 +201,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 });
},
});
Loading
Loading