Skip to content

Commit 840763a

Browse files
authored
feat(root): add agent template to novu init CLI scaffolding fixes NV-7371 (#10734)
1 parent 319360f commit 840763a

File tree

21 files changed

+517
-52
lines changed

21 files changed

+517
-52
lines changed

apps/api/src/app/agents/services/agent-inbound-handler.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export class AgentInboundHandler {
9393
organizationId: config.organizationId,
9494
});
9595

96-
const channel = conversation.channels[0];
96+
const channel = conversation.channels?.[0];
9797
const isFirstMessage = !channel?.firstPlatformMessageId;
9898

9999
if (isFirstMessage && config.reactionOnMessageReceived && message.id) {

apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
SubscriberRepository,
1111
} from '@novu/dal';
1212
import { AgentEventEnum } from '../../dtos/agent-event.enum';
13-
import { AgentConfigResolver } from '../../services/agent-config-resolver.service';
13+
import { AgentConfigResolver, ResolvedAgentConfig } from '../../services/agent-config-resolver.service';
1414
import { AgentConversationService } from '../../services/agent-conversation.service';
1515
import { BridgeExecutorService } from '../../services/bridge-executor.service';
1616
import { ChatSdkService } from '../../services/chat-sdk.service';
@@ -59,10 +59,15 @@ export class HandleAgentReply {
5959
return { status: 'update_sent' };
6060
}
6161

62+
const needsConfig = !!(command.reply || command.resolve);
63+
const config = needsConfig
64+
? await this.agentConfigResolver.resolve(conversation._agentId, command.integrationIdentifier)
65+
: null;
66+
6267
if (command.reply) {
6368
await this.deliverMessage(command, conversation, channel, command.reply, ConversationActivityTypeEnum.MESSAGE);
6469

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

7479
if (command.resolve) {
75-
await this.executeResolveSignal(command, conversation, channel, command.resolve);
80+
await this.executeResolveSignal(command, config!, conversation, channel, command.resolve);
7681
}
7782

7883
return { status: 'ok' };
@@ -198,6 +203,7 @@ export class HandleAgentReply {
198203

199204
private async executeResolveSignal(
200205
command: HandleAgentReplyCommand,
206+
config: ResolvedAgentConfig,
201207
conversation: ConversationEntity,
202208
channel: ConversationChannel,
203209
signal: { summary?: string }
@@ -223,29 +229,26 @@ export class HandleAgentReply {
223229
}),
224230
]);
225231

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

230-
this.fireOnResolveBridgeCall(command, conversation).catch((err) => {
236+
this.fireOnResolveBridgeCall(command, config, conversation).catch((err) => {
231237
this.logger.error(err, `[agent:${command.agentIdentifier}] Failed to fire onResolve bridge call`);
232238
});
233239
}
234240

235241
private async removeAckReaction(
236-
command: HandleAgentReplyCommand,
242+
config: ResolvedAgentConfig,
237243
conversation: ConversationEntity,
238244
channel: ConversationChannel
239245
): Promise<void> {
240246
const firstMessageId = channel.firstPlatformMessageId;
241-
if (!firstMessageId) return;
242-
243-
const config = await this.agentConfigResolver.resolve(conversation._agentId, command.integrationIdentifier);
244-
if (!config.reactionOnMessageReceived) return;
247+
if (!firstMessageId || !config.reactionOnMessageReceived) return;
245248

246249
await this.chatSdkService.removeReaction(
247250
conversation._agentId,
248-
command.integrationIdentifier,
251+
config.integrationIdentifier,
249252
channel.platform,
250253
channel.platformThreadId,
251254
firstMessageId,
@@ -254,19 +257,16 @@ export class HandleAgentReply {
254257
}
255258

256259
private async reactOnResolve(
257-
command: HandleAgentReplyCommand,
260+
config: ResolvedAgentConfig,
258261
conversation: ConversationEntity,
259262
channel: ConversationChannel
260263
): Promise<void> {
261264
const firstMessageId = channel.firstPlatformMessageId;
262-
if (!firstMessageId) return;
263-
264-
const config = await this.agentConfigResolver.resolve(conversation._agentId, command.integrationIdentifier);
265-
if (!config.reactionOnResolved) return;
265+
if (!firstMessageId || !config.reactionOnResolved) return;
266266

267267
await this.chatSdkService.reactToMessage(
268268
conversation._agentId,
269-
command.integrationIdentifier,
269+
config.integrationIdentifier,
270270
channel.platform,
271271
channel.platformThreadId,
272272
firstMessageId,
@@ -276,10 +276,9 @@ export class HandleAgentReply {
276276

277277
private async fireOnResolveBridgeCall(
278278
command: HandleAgentReplyCommand,
279+
config: ResolvedAgentConfig,
279280
conversation: ConversationEntity
280281
): Promise<void> {
281-
const config = await this.agentConfigResolver.resolve(conversation._agentId, command.integrationIdentifier);
282-
283282
const subscriberParticipant = conversation.participants.find((p) => p.type === 'subscriber');
284283
const [subscriber, history] = await Promise.all([
285284
subscriberParticipant

libs/dal/src/repositories/conversation/conversation.repository.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,12 @@ export class ConversationRepository extends BaseRepositoryV2<
121121
_id: id,
122122
_environmentId: environmentId,
123123
_organizationId: organizationId,
124-
'channels.platformThreadId': platformThreadId,
124+
channels: {
125+
$elemMatch: {
126+
platformThreadId,
127+
firstPlatformMessageId: { $exists: false },
128+
},
129+
},
125130
},
126131
{ $set: { 'channels.$.firstPlatformMessageId': firstPlatformMessageId } }
127132
);

packages/novu/src/commands/init/create-app.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export class DownloadError extends Error {}
1616
export async function createApp({
1717
appPath,
1818
packageManager,
19+
templateChoice,
1920
typescript,
2021
eslint,
2122
srcDir,
@@ -26,6 +27,7 @@ export async function createApp({
2627
}: {
2728
appPath: string;
2829
packageManager: PackageManager;
30+
templateChoice: string;
2931
typescript: boolean;
3032
eslint: boolean;
3133
srcDir: boolean;
@@ -36,7 +38,7 @@ export async function createApp({
3638
}): Promise<void> {
3739
let repoInfo: RepoInfo | undefined;
3840
const mode: TemplateMode = typescript ? 'ts' : 'js';
39-
const template: TemplateType = 'app-react-email';
41+
const template: TemplateType = templateChoice === 'agent' ? 'app-agent' : 'app-react-email';
4042

4143
const root = path.resolve(appPath);
4244

packages/novu/src/commands/init/index.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export interface IInitCommandOptions {
2828
secretKey?: string;
2929
projectPath?: string;
3030
apiUrl: string;
31+
template?: string;
3132
}
3233

3334
export async function init(program: IInitCommandOptions, anonymousId?: string): Promise<void> {
@@ -148,11 +149,36 @@ export async function init(program: IInitCommandOptions, anonymousId?: string):
148149
process.exit(1);
149150
}
150151

152+
const supportedTemplates = ['notifications', 'agent'] as const;
153+
let templateChoice = program.template;
154+
155+
if (templateChoice && !supportedTemplates.includes(templateChoice as (typeof supportedTemplates)[number])) {
156+
console.error(`Invalid template "${program.template}". Supported templates: ${supportedTemplates.join(', ')}`);
157+
process.exit(1);
158+
}
159+
160+
if (!templateChoice) {
161+
const res = await prompts({
162+
onState: onPromptState,
163+
type: 'select',
164+
name: 'template',
165+
message: 'What type of Novu app do you want to create?',
166+
choices: [
167+
{ title: 'Notifications', value: 'notifications', description: 'Workflows, email templates, and in-app inbox' },
168+
{ title: 'Agent', value: 'agent', description: 'Conversational AI agent with chat platform support' },
169+
],
170+
initial: 0,
171+
});
172+
173+
templateChoice = res.template;
174+
}
175+
176+
if (!templateChoice) {
177+
console.error('No template selected.');
178+
process.exit(1);
179+
}
180+
151181
const preferences = {} as Record<string, boolean | string>;
152-
/**
153-
* If the user does not provide the necessary flags, prompt them for whether
154-
* to use TS or JS.
155-
*/
156182
const defaults: typeof preferences = {
157183
typescript: true,
158184
eslint: true,
@@ -175,6 +201,7 @@ export async function init(program: IInitCommandOptions, anonymousId?: string):
175201
await createApp({
176202
appPath: resolvedProjectPath,
177203
packageManager: 'npm',
204+
templateChoice,
178205
typescript: defaults.typescript as boolean,
179206
eslint: defaults.eslint as boolean,
180207
srcDir: defaults.srcDir as boolean,
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Novu Agent
2+
3+
A conversational AI agent powered by [Novu](https://novu.co) and [Next.js](https://nextjs.org).
4+
5+
## Getting Started
6+
7+
1. Start the development server:
8+
9+
```bash
10+
npm run dev
11+
```
12+
13+
2. Connect a chat platform in the [Novu Dashboard](https://dashboard.novu.co).
14+
15+
3. Replace the demo handler in `app/novu/agents/support-agent.tsx` with your LLM call.
16+
17+
Your agent is served at `/api/novu` and handles incoming messages via the Novu Bridge protocol.
18+
19+
## Project Structure
20+
21+
```text
22+
app/
23+
api/novu/route.ts → Bridge endpoint serving your agent
24+
novu/agents/
25+
index.ts → Agent exports
26+
support-agent.tsx → Your agent handler (edit this!)
27+
page.tsx → Landing page
28+
```
29+
30+
## Agent API
31+
32+
Your agent handler receives a context object with:
33+
34+
| Method / Property | Description |
35+
|---|---|
36+
| `ctx.message` | The inbound message (text, author, timestamp) |
37+
| `ctx.conversation` | Current conversation state and metadata |
38+
| `ctx.history` | Recent conversation history |
39+
| `ctx.subscriber` | Resolved subscriber info |
40+
| `ctx.platform` | Source platform (slack, teams, whatsapp) |
41+
| `ctx.reply(content)` | Send a reply (text, Markdown, or Card) |
42+
| `ctx.metadata.set(k, v)` | Set conversation metadata |
43+
| `ctx.resolve(summary?)` | Mark conversation as resolved |
44+
| `ctx.trigger(workflowId)` | Trigger a Novu workflow |
45+
46+
## Wiring Up Your LLM
47+
48+
Replace the demo handler in `app/novu/agents/support-agent.tsx` with your LLM call:
49+
50+
```typescript
51+
onMessage: async (ctx) => {
52+
const response = await openai.chat.completions.create({
53+
model: 'gpt-4',
54+
messages: [
55+
{ role: 'system', content: 'You are a helpful support agent.' },
56+
...ctx.history.map((h) => ({ role: h.role, content: h.content })),
57+
{ role: 'user', content: ctx.message?.text ?? '' },
58+
],
59+
});
60+
61+
await ctx.reply(response.choices[0].message.content ?? '');
62+
},
63+
```
64+
65+
## Learn More
66+
67+
- [Novu Agent Docs](https://docs.novu.co/agents)
68+
- [Novu Framework SDK](https://docs.novu.co/framework)
69+
- [Next.js Documentation](https://nextjs.org/docs)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { serve } from '@novu/framework/next';
2+
import { supportAgent } from '../../novu/agents';
3+
4+
export const { GET, POST, OPTIONS } = serve({
5+
agents: [supportAgent],
6+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
*,
2+
*::before,
3+
*::after {
4+
box-sizing: border-box;
5+
margin: 0;
6+
padding: 0;
7+
}
8+
9+
body {
10+
background: #fafafa;
11+
color: #171717;
12+
}
13+
14+
@media (prefers-color-scheme: dark) {
15+
body {
16+
background: #0a0a0a;
17+
color: #fafafa;
18+
}
19+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { Metadata } from 'next';
2+
import './globals.css';
3+
4+
export const metadata: Metadata = {
5+
title: 'Novu Agent',
6+
description: 'Conversational AI agent powered by Novu',
7+
};
8+
9+
export default function RootLayout({
10+
children,
11+
}: Readonly<{
12+
children: React.ReactNode;
13+
}>) {
14+
15+
return (
16+
<html lang="en">
17+
<body>{children}</body>
18+
</html>
19+
);
20+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { supportAgent } from './support-agent';

0 commit comments

Comments
 (0)