docs/agents/custom-code-agent/building-blocks/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.
Handlers are provider-agnostic. The same onMessage, onAction, onReaction, onResolve, onToolApproval, and onError APIs run on Slack, Microsoft Teams, WhatsApp, Telegram, and email. What the user sees (buttons vs action links, typing vs eyes reaction) depends on the channel. See Channels overview.
| Event surface | Slack | Teams | Telegram | ||
|---|---|---|---|---|---|
onMessage | Yes | Yes | Yes | Yes | Yes |
onAction (buttons / links) | Yes | Yes | Yes | Yes | Yes |
onReaction | Yes | Yes | Yes | Yes | Gmail only |
onResolve | Yes | Yes | Yes | Yes | Yes |
onToolApproval | Yes | Yes | Yes | Yes | Yes |
Event handlers are functions that respond to events in a conversation. Your agent can respond to these 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 |
onToolApproval | A user approves or denies a gated tool call | Run or skip the tool, audit the decision. See Tool approval |
onError | A turn fails (handler throw, promise rejection, delivery error) | Log, suppress, send custom copy, or rely on Novu's generic reply |
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.
import { agent, toModelMessages } from '@novu/framework/ai-sdk';
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
export const myAgent = agent('my-agent', {
onMessage: async (message, ctx) => {
const userMessage = message.text ?? '';
const conversationHistory = ctx.history;
const subscriber = ctx.subscriber;
return generateText({
model: openai('gpt-4o-mini'),
instructions: 'You are a helpful support agent.',
messages: toModelMessages(conversationHistory),
});
},
});
Return generateText() or streamText() from the handler - Novu delivers the model output to the thread. See AI SDK.
import { tool } from '@langchain/core/tools';
import { agent } from '@novu/framework/langchain';
import { z } from 'zod';
const lookupOrder = tool(
async ({ orderId }) => ({ orderId, status: 'shipped' }),
{
name: 'lookupOrder',
description: 'Look up an order by ID',
schema: z.object({ orderId: z.string() }),
},
);
export const myAgent = agent('my-agent', {
onMessage: async (message, ctx) => {
const userMessage = message.text ?? '';
const conversationHistory = ctx.history;
const subscriber = ctx.subscriber;
return {
model: 'openai:gpt-4o-mini',
system: 'You are a helpful support agent.',
tools: [lookupOrder],
};
},
});
Return a LangChainAgentConfig from the handler - Novu runs the agent and delivers the final text. See LangChain.
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);
},
});
Call your LLM or business logic yourself, then reply with ctx.reply() or by returning a string.
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. The action payload carries action.id (the element's id prop), action.value (its value prop, if set), and action.sourceMessageId. See Interactive cards.
import { agent } from '@novu/framework';
export const myAgent = agent('my-agent', {
onAction: async (action, ctx) => {
if (action.id === 'approve' && action.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. The reaction payload carries reaction.emoji.name, reaction.added (true when added, false when removed), and reaction.messageId.
import { agent } from '@novu/framework';
export const myAgent = agent('my-agent', {
onReaction: async (reaction, ctx) => {
if (reaction.emoji.name === 'thumbs_up' && reaction.added) {
ctx.metadata.set('userSatisfied', true);
} else if (reaction.emoji.name === 'thumbs_down' && reaction.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. It receives only the context object.
import { agent } from '@novu/framework';
export const myAgent = agent('my-agent', {
onResolve: async (ctx) => {
ctx.metadata.set('resolvedAt', new Date().toISOString());
},
});
onToolApproval fires when a user approves or denies a gated tool call. It receives a decision with the tool call, the verdict, and a handle to the approval card.
On the AI SDK path, tools gated with needsApproval: true in onMessage trigger this handler when the user clicks Approve or Deny. Register it when you need a hook after the click - for example to audit the decision or change how the card is handled.
import { agent, toModelMessages } from '@novu/framework/ai-sdk';
import { openai } from '@ai-sdk/openai';
import { generateText, tool } from 'ai';
import { z } from 'zod';
export const myAgent = agent('my-agent', {
onMessage: async (message, ctx) => {
return generateText({
model: openai('gpt-4o'),
messages: toModelMessages(ctx.history),
tools: {
issueRefund: tool({
inputSchema: z.object({ orderId: z.string() }),
needsApproval: true,
execute: async ({ orderId }) => refund(orderId),
}),
},
});
},
onToolApproval: async (decision, ctx) => {
await decision.approvalMessage.delete();
if (!decision.approved) return 'Refund denied.';
// return nothing → onMessage auto-resumes
},
});
import { tool } from '@langchain/core/tools';
import { agent } from '@novu/framework/langchain';
import { z } from 'zod';
const issueRefund = tool(
async ({ orderId }) => refund(orderId),
{
name: 'issueRefund',
description: 'Issue a refund for an order',
schema: z.object({ orderId: z.string() }),
},
);
export const myAgent = agent('my-agent', {
onMessage: async (message, ctx) => ({
model: 'openai:gpt-4o',
tools: [issueRefund],
needsApproval: (toolCall) => toolCall.name === 'issueRefund',
}),
onToolApproval: async (decision, ctx) => {
await decision.approvalMessage.delete();
if (!decision.approved) return 'Refund denied.';
// return nothing → onMessage auto-resumes
},
});
Gate the tool with ctx.toolApproval.request() and handle the click in onToolApproval:
import { agent } from '@novu/framework';
export const myAgent = agent('my-agent', {
onMessage: async (message, ctx) => {
return ctx.toolApproval.request({ id: 'call_1', name: 'issueRefund', input: { orderId: 'A-123' } });
},
onToolApproval: async (decision, ctx) => {
await decision.approvalMessage.delete();
if (!decision.approved) return 'Action cancelled.';
await issueRefund(decision.toolCall.input);
return 'Done.';
},
});
See Tool approval for card customization, auditing, and renderApproval.
When a turn fails - your handler throws, a returned promise rejects, or message delivery fails inside the handler - Novu runs an error pipeline on the bridge before the turn ends:
onError if you registered oneonError does not handle the failure, auto-report { error: true } to NovuBy default, users see:
Something went wrong while processing your message. Please try again in a moment.
Return from onError | Behavior |
|---|---|
nothing / undefined | Auto-report - Novu sends the generic message |
{ suppress: true } | No user-visible reply (failure is logged only) |
string / JSX Card | Custom reply delivered through ctx.reply() |
The first argument is an AgentError (use toAgentError() from @novu/framework if you normalize errors in your own code). Reply delivery failures surface as AgentDeliveryError, a subclass of AgentError, with statusCode and responseBody for debugging.
import { agent } from '@novu/framework';
export const myAgent = agent('my-agent', {
onMessage: async (message, ctx) => {
const answer = await runAgent(message, ctx);
return answer;
},
onError: async (error, ctx) => {
console.error('[my-agent]', error.message, error.cause);
return 'Sorry - something went wrong. Please try again.';
},
});
When onError returns message content, Novu posts it like a normal reply. When it returns nothing, the framework auto-reports the turn failure and Novu sends the generic message.
Use { suppress: true } when a failure should be logged on your bridge but should not post another message in the thread. A common case is side-effect handlers such as onReaction or onAction: if analytics or feedback logging fails, a generic "Something went wrong…" reply is usually more confusing than helpful.
import { agent } from '@novu/framework';
export const myAgent = agent('my-agent', {
onMessage: async (message, ctx) => runAgent(message, ctx),
onReaction: async (reaction, ctx) => {
await recordFeedback(reaction, ctx.conversation.identifier);
},
onError: async (error, ctx) => {
console.error('[my-agent]', ctx.event, error.message, error.cause);
if (ctx.event === 'onReaction' || ctx.event === 'onAction') {
return { suppress: true };
}
return 'Sorry - something went wrong. Please try again.';
},
});
For onMessage failures, prefer a custom reply or the default generic message so the user knows their message was not handled.
Runtime adapters forward onError from the same agent config. For adapter-specific failure behavior, see AI SDK, LangChain, or Other frameworks.