diff --git a/apps/api/src/app/agents/e2e/mock-agent-handler.ts b/apps/api/src/app/agents/e2e/mock-agent-handler.ts
index 24f0fbce91d..d5ee575d492 100644
--- a/apps/api/src/app/agents/e2e/mock-agent-handler.ts
+++ b/apps/api/src/app/agents/e2e/mock-agent-handler.ts
@@ -39,15 +39,15 @@ if (!NOVU_SECRET_KEY) {
}
const echoBot = agent('novu-agent', {
- onMessage: async (ctx) => {
+ onMessage: async ({ message, ctx }) => {
console.log('\n─────────────────────────────────────────');
- console.log(`[${ctx.event}] from ${ctx.subscriber?.firstName ?? 'unknown'} on ${ctx.platform}`);
- console.log(`Message: ${ctx.message?.text ?? '(none)'}`);
+ console.log(`[onMessage] from ${ctx.subscriber?.firstName ?? 'unknown'} on ${ctx.platform}`);
+ console.log(`Message: ${message.text ?? '(none)'}`);
console.log(`Conversation: ${ctx.conversation.identifier} (${ctx.conversation.status})`);
console.log(`History: ${ctx.history.length} entries`);
console.log('─────────────────────────────────────────');
- const userText = ctx.message?.text ?? '';
+ const userText = message.text ?? '';
const turnCount = (ctx.conversation.metadata?.turnCount as number) ?? 0;
ctx.metadata.set('turnCount', turnCount + 1);
@@ -128,14 +128,11 @@ const echoBot = agent('novu-agent', {
await ctx.reply(`Echo: ${userText}`);
},
- onAction: async (ctx) => {
+ onAction: async ({ actionId, value, ctx }) => {
console.log('\n─────────────────────────────────────────');
- console.log(`[${ctx.event}] action: ${ctx.action?.actionId} = ${ctx.action?.value ?? '(no value)'}`);
+ console.log(`[onAction] action: ${actionId} = ${value ?? '(no value)'}`);
console.log('─────────────────────────────────────────');
- const actionId = ctx.action?.actionId ?? 'unknown';
- const value = ctx.action?.value;
-
if (actionId === 'ack') {
await ctx.reply(
Card({
@@ -162,7 +159,7 @@ const echoBot = agent('novu-agent', {
}
},
- onResolve: async (ctx) => {
+ onResolve: async ({ ctx }) => {
console.log(`\n[onResolve] Conversation ${ctx.conversation.identifier} closed.`);
ctx.metadata.set('resolvedAt', new Date().toISOString());
},
diff --git a/packages/framework/src/handler.ts b/packages/framework/src/handler.ts
index e6e03976171..b7686793932 100644
--- a/packages/framework/src/handler.ts
+++ b/packages/framework/src/handler.ts
@@ -21,7 +21,15 @@ import {
SigningKeyNotFoundError,
} from './errors';
import { isPlatformError } from './errors/guard.errors';
-import type { Agent, AgentBridgeRequest, MessageContent } from './resources/agent';
+import type {
+ Agent,
+ AgentActionContext,
+ AgentBridgeRequest,
+ AgentMessageContext,
+ AgentReactionContext,
+ AgentResolveContext,
+ MessageContent,
+} from './resources/agent';
import { AgentContextImpl, AgentDeliveryError, AgentEventEnum } from './resources/agent';
import type { Awaitable, EventTriggerParams, Workflow } from './types';
import { createHmacSubtle, initApiClient } from './utils';
@@ -309,21 +317,51 @@ export class NovuRequestHandler {
}
private async runAgentHandler(registeredAgent: Agent, event: string, ctx: AgentContextImpl): Promise {
- const handlerMap = {
- [AgentEventEnum.ON_MESSAGE]: registeredAgent.handlers.onMessage,
- [AgentEventEnum.ON_REACTION]: registeredAgent.handlers.onReaction,
- [AgentEventEnum.ON_ACTION]: registeredAgent.handlers.onAction,
- [AgentEventEnum.ON_RESOLVE]: registeredAgent.handlers.onResolve,
- } as Partial Awaitable>>;
-
- if (!Object.prototype.hasOwnProperty.call(handlerMap, event)) {
- throw new InvalidActionError(event, AgentEventEnum);
- }
-
- const handler = handlerMap[event as AgentEventEnum];
- if (handler) {
- const result = await handler(ctx);
+ const replyIfPresent = async (result: MessageContent | void) => {
if (result != null) await ctx.reply(result);
+ };
+
+ switch (event) {
+ case AgentEventEnum.ON_MESSAGE:
+ await replyIfPresent(
+ await registeredAgent.handlers.onMessage({
+ message: ctx.message!,
+ ctx: ctx as unknown as AgentMessageContext,
+ })
+ );
+ break;
+ case AgentEventEnum.ON_ACTION:
+ if (registeredAgent.handlers.onAction) {
+ await replyIfPresent(
+ await registeredAgent.handlers.onAction({
+ actionId: ctx.action!.actionId,
+ value: ctx.action!.value,
+ ctx: ctx as unknown as AgentActionContext,
+ })
+ );
+ }
+ break;
+ case AgentEventEnum.ON_REACTION:
+ if (registeredAgent.handlers.onReaction) {
+ await replyIfPresent(
+ await registeredAgent.handlers.onReaction({
+ reaction: ctx.reaction!,
+ ctx: ctx as unknown as AgentReactionContext,
+ })
+ );
+ }
+ break;
+ case AgentEventEnum.ON_RESOLVE:
+ if (registeredAgent.handlers.onResolve) {
+ await replyIfPresent(
+ await registeredAgent.handlers.onResolve({
+ ctx: ctx as unknown as AgentResolveContext,
+ })
+ );
+ }
+ break;
+ default:
+ throw new InvalidActionError(event, AgentEventEnum);
}
await ctx.flush();
diff --git a/packages/framework/src/resources/agent/agent.test.ts b/packages/framework/src/resources/agent/agent.test.ts
index dfde369997a..270437ff03a 100644
--- a/packages/framework/src/resources/agent/agent.test.ts
+++ b/packages/framework/src/resources/agent/agent.test.ts
@@ -119,7 +119,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
});
it('should ACK immediately and run onMessage handler in background', async () => {
- const onMessageSpy = vi.fn(async (ctx) => {
+ const onMessageSpy = vi.fn(async ({ ctx }: { ctx: any }) => {
await ctx.reply('Echo: Hello bot!');
});
@@ -193,7 +193,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
it('should batch metadata signals with reply', async () => {
const testBot = agent('test-bot', {
- onMessage: async (ctx) => {
+ onMessage: async ({ ctx }) => {
ctx.metadata.set('turnCount', 1);
ctx.metadata.set('language', 'en');
await ctx.reply('Got it');
@@ -235,7 +235,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
it('should edit a previously sent reply via the returned handle', async () => {
const testBot = agent('test-bot', {
- onMessage: async (ctx) => {
+ onMessage: async ({ ctx }) => {
const msg = await ctx.reply('Thinking...');
await msg.edit('Done thinking');
},
@@ -283,7 +283,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
it('should not attach signals or resolve to an edit call', async () => {
const testBot = agent('test-bot', {
- onMessage: async (ctx) => {
+ onMessage: async ({ ctx }) => {
ctx.metadata.set('step', 'thinking');
const msg = await ctx.reply('Thinking...');
await msg.edit('Done');
@@ -326,7 +326,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
it('should flush remaining signals after onResolve', async () => {
const testBot = agent('test-bot', {
onMessage: async () => {},
- onResolve: async (ctx) => {
+ onResolve: async ({ ctx }) => {
ctx.metadata.set('archived', true);
ctx.trigger('post-resolve-workflow', { payload: { reason: 'done' } });
},
@@ -373,7 +373,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
let capturedCtx: any;
const testBot = agent('test-bot', {
- onMessage: async (ctx) => {
+ onMessage: async ({ ctx }) => {
capturedCtx = ctx;
await ctx.reply('ok');
},
@@ -411,7 +411,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
it('should serialize markdown content on reply', async () => {
const testBot = agent('test-bot', {
- onMessage: async (ctx) => {
+ onMessage: async ({ ctx }) => {
await ctx.reply('**bold** text');
},
});
@@ -448,7 +448,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
it('should serialize markdown with file attachments', async () => {
const testBot = agent('test-bot', {
- onMessage: async (ctx) => {
+ onMessage: async ({ ctx }) => {
await ctx.reply('Here is the report', {
files: [{ filename: 'report.pdf', url: 'https://example.com/report.pdf' }],
});
@@ -493,7 +493,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
{ label: 'Blob', data: new Blob(['hello'], { type: 'text/plain' }) },
])('should serialize markdown with $label file data as base64', async ({ data }) => {
const testBot = agent('test-bot', {
- onMessage: async (ctx) => {
+ onMessage: async ({ ctx }) => {
await ctx.reply('Here is the report', {
files: [{ filename: 'sample.txt', mimeType: 'text/plain', data }],
});
@@ -536,7 +536,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
it('should serialize large Uint8Array file data without overflowing the call stack', async () => {
const bytes = Uint8Array.from({ length: 200 * 1024 }, (_, index) => index % 256);
const testBot = agent('test-bot', {
- onMessage: async (ctx) => {
+ onMessage: async ({ ctx }) => {
await ctx.reply('Here is the report', {
files: [{ filename: 'sample.bin', data: bytes }],
});
@@ -576,7 +576,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
let caughtError: unknown;
const bytes = new Uint8Array(3 * 1024 * 1024);
const testBot = agent('test-bot', {
- onMessage: async (ctx) => {
+ onMessage: async ({ ctx }) => {
try {
await ctx.reply('Here are the files', {
files: [
@@ -625,7 +625,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
it('should reject unsupported file data before posting a reply', async () => {
let caughtError: unknown;
const testBot = agent('test-bot', {
- onMessage: async (ctx) => {
+ onMessage: async ({ ctx }) => {
try {
await ctx.reply('Here is the report', {
files: [{ filename: 'sample.txt', data: { type: 'Buffer', data: [104, 101, 108, 108, 111] } } as any],
@@ -670,7 +670,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
it('should serialize CardElement on reply', async () => {
const testBot = agent('test-bot', {
- onMessage: async (ctx) => {
+ onMessage: async ({ ctx }) => {
await ctx.reply(
Card({
title: 'Order #123',
@@ -725,7 +725,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
});
const testBot = agent('test-bot', {
- onMessage: async (ctx) => {
+ onMessage: async ({ ctx }) => {
await ctx.reply(jsxCard);
},
});
@@ -764,7 +764,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
it('should serialize CardElement on edit', async () => {
const testBot = agent('test-bot', {
- onMessage: async (ctx) => {
+ onMessage: async ({ ctx }) => {
const msg = await ctx.reply('Loading...');
await msg.edit(Card({ title: 'Loaded', children: [] }));
},
@@ -808,7 +808,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
it('should batch signals with card reply', async () => {
const testBot = agent('test-bot', {
- onMessage: async (ctx) => {
+ onMessage: async ({ ctx }) => {
ctx.metadata.set('intent', 'order_confirm');
await ctx.reply(Card({ title: 'Confirm?', children: [Button({ id: 'yes', label: 'Yes', style: 'primary' })] }));
},
@@ -850,7 +850,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
const testBot = agent('test-bot', {
onMessage: async () => {},
- onAction: async (ctx) => {
+ onAction: async ({ ctx }) => {
capturedCtx = ctx;
await ctx.reply('Action received');
},
@@ -897,7 +897,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
const testBot = agent('test-bot', {
onMessage: async () => {},
- onAction: async (ctx) => {
+ onAction: async ({ ctx }) => {
capturedCtx = ctx;
if (ctx.action?.sourceMessageId) {
ctx.addReaction(ctx.action.sourceMessageId, 'eyes');
@@ -944,7 +944,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
let capturedCtx: any;
const testBot = agent('test-bot', {
- onMessage: async (ctx) => {
+ onMessage: async ({ ctx }) => {
capturedCtx = ctx;
await ctx.reply('ok');
},
@@ -1048,7 +1048,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
const testBot = agent('test-bot', {
onMessage: async () => {},
- onReaction: async (ctx) => {
+ onReaction: async ({ ctx }) => {
capturedCtx = ctx;
await ctx.reply('Reaction received');
},
@@ -1110,7 +1110,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
const testBot = agent('test-bot', {
onMessage: async () => {},
- onReaction: async (ctx) => {
+ onReaction: async ({ ctx }) => {
capturedCtx = ctx;
await ctx.reply('ok');
},
@@ -1153,7 +1153,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
it('should flush addReaction without a reply', async () => {
const testBot = agent('test-bot', {
- onMessage: async (ctx) => {
+ onMessage: async ({ ctx }) => {
ctx.addReaction('msg-123', 'eyes');
},
});
@@ -1191,7 +1191,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
it('should batch addReaction with reply', async () => {
const testBot = agent('test-bot', {
- onMessage: async (ctx) => {
+ onMessage: async ({ ctx }) => {
ctx.addReaction('msg-reacted', 'thumbs_up');
await ctx.reply('Got it');
},
@@ -1232,7 +1232,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
let capturedCtx: any;
const testBot = agent('test-bot', {
- onMessage: async (ctx) => {
+ onMessage: async ({ ctx }) => {
capturedCtx = ctx;
await ctx.reply('ok');
},
@@ -1264,7 +1264,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
it('should send handler return value as reply', async () => {
const testBot = agent('test-bot', {
- onMessage: async (_ctx) => 'hello from return',
+ onMessage: async (_payload) => 'hello from return',
});
const handler = new NovuRequestHandler({
@@ -1298,10 +1298,10 @@ describe('agent dispatch via NovuRequestHandler', () => {
it('should send onAction handler return value as reply', async () => {
const testBot = agent('test-bot', {
- onMessage: async (ctx) => {
+ onMessage: async ({ ctx }) => {
await ctx.reply('noop');
},
- onAction: async (_ctx) => 'action handled',
+ onAction: async (_payload) => 'action handled',
});
const handler = new NovuRequestHandler({
@@ -1335,11 +1335,11 @@ describe('agent dispatch via NovuRequestHandler', () => {
it('should send onReaction handler return value as reply', async () => {
const testBot = agent('test-bot', {
- onMessage: async (ctx) => {
+ onMessage: async ({ ctx }) => {
await ctx.reply('noop');
},
- onReaction: (ctx) => {
- if (!ctx.reaction?.added) return;
+ onReaction: ({ reaction }) => {
+ if (!reaction.added) return;
return "Sorry that wasn't helpful!";
},
@@ -1410,7 +1410,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
let caughtError: unknown;
const testBot = agent('test-bot', {
- onMessage: async (ctx) => {
+ onMessage: async ({ ctx }) => {
try {
await ctx.reply('Hello');
} catch (err) {
@@ -1460,7 +1460,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
let caughtError: unknown;
const testBot = agent('test-bot', {
- onMessage: async (ctx) => {
+ onMessage: async ({ ctx }) => {
try {
await ctx.reply('Hello');
} catch (err) {
@@ -1503,7 +1503,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const testBot = agent('test-bot', {
- onMessage: async (ctx) => {
+ onMessage: async ({ ctx }) => {
await ctx.reply('Hello');
},
});
@@ -1537,11 +1537,11 @@ describe('agent dispatch via NovuRequestHandler', () => {
it('should not send a reply when onReaction returns nothing (reaction removed)', async () => {
const testBot = agent('test-bot', {
- onMessage: async (ctx) => {
+ onMessage: async ({ ctx }) => {
await ctx.reply('noop');
},
- onReaction: (ctx) => {
- if (!ctx.reaction?.added) return;
+ onReaction: ({ reaction }) => {
+ if (!reaction.added) return;
return 'thumbs up noted';
},
@@ -1585,10 +1585,10 @@ describe('agent dispatch via NovuRequestHandler', () => {
it('should send onResolve handler return value as reply', async () => {
const testBot = agent('test-bot', {
- onMessage: async (ctx) => {
+ onMessage: async ({ ctx }) => {
await ctx.reply('noop');
},
- onResolve: async (_ctx) => 'Conversation closed. Thanks for reaching out!',
+ onResolve: async (_payload) => 'Conversation closed. Thanks for reaching out!',
});
const handler = new NovuRequestHandler({
@@ -1622,7 +1622,7 @@ describe('agent dispatch via NovuRequestHandler', () => {
it('should send two replies when ctx.reply() is called and handler also returns a value', async () => {
const testBot = agent('test-bot', {
- onMessage: async (ctx) => {
+ onMessage: async ({ ctx }) => {
await ctx.reply('Thinking…');
return 'Final answer';
diff --git a/packages/framework/src/resources/agent/agent.types.ts b/packages/framework/src/resources/agent/agent.types.ts
index cfcc1b2652e..2c0bb0bbbc2 100644
--- a/packages/framework/src/resources/agent/agent.types.ts
+++ b/packages/framework/src/resources/agent/agent.types.ts
@@ -14,6 +14,7 @@ export enum AgentEventEnum {
// User-facing types (visible on ctx properties)
// ---------------------------------------------------------------------------
+/** Identity of the user or bot that authored a message. */
export interface AgentMessageAuthor {
userId: string;
fullName: string;
@@ -21,6 +22,7 @@ export interface AgentMessageAuthor {
isBot: boolean | 'unknown';
}
+/** A file or media attachment included with a message. */
export interface AgentAttachment {
type: string;
url?: string;
@@ -29,24 +31,37 @@ export interface AgentAttachment {
size?: number;
}
+/** An incoming message from the user in the current conversation. */
export interface AgentMessage {
+ /** Plain-text content of the message. */
text: string;
+ /** Platform-native message ID (e.g. Slack `ts`, Teams `activityId`). */
platformMessageId: string;
author: AgentMessageAuthor;
timestamp: string;
attachments?: AgentAttachment[];
}
+/** Live state of the current conversation thread. */
export interface AgentConversation {
+ /** Stable identifier for this conversation. */
identifier: string;
+ /** Lifecycle status (e.g. `'open'`, `'resolved'`). */
status: string;
+ /**
+ * Key/value store for this conversation.
+ * Values are written via `ctx.metadata.set()` and readable on subsequent messages.
+ */
metadata: Record;
+ /** Number of messages exchanged so far; starts at 1 for the first message. */
messageCount: number;
createdAt: string;
lastActivityAt: string;
}
+/** The Novu subscriber who initiated or is participating in the conversation. */
export interface AgentSubscriber {
+ /** Stable Novu subscriber ID. */
subscriberId: string;
firstName?: string;
lastName?: string;
@@ -54,22 +69,36 @@ export interface AgentSubscriber {
phone?: string;
avatar?: string;
locale?: string;
+ /** Arbitrary custom data attached to the subscriber in Novu. */
data?: Record;
}
+/**
+ * A single entry in the conversation history.
+ * `ctx.history` is an ordered array of these entries — map them to your LLM's
+ * message format before making a model call.
+ */
export interface AgentHistoryEntry {
+ /** Message role: `'user'`, `'assistant'`, or `'system'`. */
role: string;
+ /** Content type: `'text'`, `'card'`, etc. */
type: string;
+ /** Plain-text representation of the message content. */
content: string;
richContent?: Record;
senderName?: string;
+ /** Present on system entries that carry a Novu signal (e.g. metadata updates). */
signalData?: { type: string; payload?: Record };
createdAt: string;
}
+/** Platform-specific identifiers for the thread and channel. */
export interface AgentPlatformContext {
+ /** Platform-native thread ID (e.g. Slack thread `ts`, Teams conversation ID). */
threadId: string;
+ /** Platform-native channel or chat ID. */
channelId: string;
+ /** Whether the message arrived in a direct message rather than a shared channel. */
isDM: boolean;
}
@@ -112,8 +141,11 @@ export interface ReplyContent {
files?: FileRef[];
}
+/** Data carried by a button click or other interactive action. */
export interface AgentAction {
+ /** The `id` prop of the clicked `