docs/agents/custom-code-agent/setup-your-agent/handle-events.mdx
Novu gives your handler the same building blocks whether the message came from Slack, email, or another provider, and whether you call an LLM or plain TypeScript.
Event handlers are functions that respond to events in a conversation. Your agent can respond to four event types:
| Handler | When it runs | Common use case |
|---|---|---|
onMessage | A user sends a message in the conversation | Process the message and reply |
onAction | A user clicks a button or selects a value in an interactive card | Handle form submissions, button clicks, dropdown selections |
onReaction | A user adds or removes a reaction | Capture feedback or trigger a follow-up |
onResolve | The conversation is marked as resolved | Clean up state, log analytics, or send a summary |
Handlers are where the communication layer connects to your application logic. For example, an onMessage handler receives the user's message, passes conversation context to an LLM or custom function, and sends the response back through Novu.
Each event handler receives a context object with the information needed to understand the current event and respond. Depending on the event type, it can include:
The context object is how your code talks to Novu. You do not call Slack, Teams, or email APIs directly in the handler.
When a user messages your agent:
sequenceDiagram
participant User
participant Provider as Chat Provider
participant Novu
participant Bridge as Agent Bridge
participant Agent as Agent Logic
User->>Provider: Send message
Provider->>Novu: Platform webhook
Novu->>Novu: Map thread to conversation
Novu->>Bridge: Call onMessage with context
Bridge->>Agent: Pass message and history
Agent->>Bridge: Return reply and signals
Bridge->>Novu: ctx.reply and signals
Novu->>Provider: Deliver reply to thread
Novu->>Novu: Persist conversation state
Provider->>User: Show reply
The same agent logic works across all connected providers because Novu handles the provider-specific communication layer. Connecting a new provider does not require changing your agent code.
onMessage fires every time a user sends a message in a conversation with your agent.
/** @jsxImportSource @novu/framework */
import { agent } from '@novu/framework';
export const myAgent = agent('my-agent', {
onMessage: async ({ message, ctx }) => {
const userMessage = message.text ?? '';
const conversationHistory = ctx.history;
const subscriber = ctx.subscriber;
const response = await yourLLM.chat(userMessage, conversationHistory);
ctx.metadata.set('lastIntent', response.intent);
await ctx.reply(response.text);
},
});
Inbound messages can include file attachments when the platform supports them. Novu normalizes files into message.attachments with short-lived signed URLs. Keep in mind:
import { agent } from '@novu/framework';
export const myAgent = agent('my-agent', {
onMessage: async ({ message, ctx }) => {
const attachments = message.attachments ?? [];
for (const file of attachments) {
// file.type: 'image', 'document', 'audio', 'video'
// file.url: short-lived download URL
// file.name: original filename
// file.mimeType: e.g. 'image/jpeg'
// file.size: size in bytes
}
},
});
onAction fires when a user clicks a button or selects a value in an interactive card. See Interactive cards.
import { agent } from '@novu/framework';
export const myAgent = agent('my-agent', {
onAction: async ({ actionId, value, ctx }) => {
if (actionId === 'approve' && value === 'true') {
await ctx.reply('Request approved!');
ctx.trigger('approval-workflow', {
to: ctx.subscriber?.subscriberId,
payload: { approved: true },
});
}
},
});
onReaction fires when a user adds or removes an emoji reaction on a message.
import { agent } from '@novu/framework';
export const myAgent = agent('my-agent', {
onReaction: async ({ emoji, added, message, ctx }) => {
if (emoji.name === 'thumbs_up' && added) {
ctx.metadata.set('userSatisfied', true);
} else if (emoji.name === 'thumbs_down' && added) {
ctx.metadata.set('userUnsatisfied', true);
}
await ctx.reply('Thank you for your feedback!');
},
});
onResolve fires when the conversation is marked as resolved via ctx.resolve() or the resolve signal.
import { agent } from '@novu/framework';
export const myAgent = agent('my-agent', {
onResolve: async (ctx) => {
ctx.metadata.set('resolvedAt', new Date().toISOString());
},
});