Skip to content

Novu Agents Logic

Write your agents lo

Once you've scaffolded an agent, the next question is usually: what do I actually put in this file?

This page builds one agent from scratch, a small support bot for a fictional log monitoring product called Pipelinr. You'll add one piece at a time, and by the end you'll have a single file that greets users, routes them by topic, replies to follow-ups, and resolves the thread when they're done.

If you want the API reference for any of the pieces below, see Handle Events, Reply Types, and Signals.

What we're building

Pipelinr customers usually contact support for one of three reasons:

  • a billing question (invoices, seats, usage spikes)
  • a technical issue (broken pipelines, missing logs, API errors)
  • something else (account access, feature requests)

So the agent's job is simple: say hi, ask which bucket the issue falls in, answer the follow-up, and close the conversation when the user says "thanks". That's enough to be useful in production, and it covers every event type the framework gives you.

Where the code goes

The scaffold drops a Next.js app with this shape:

app/
  api/novu/route.ts        # exposes your agents over HTTP
  novu/
    agents/
      index.ts             # re-exports each agent
      support-agent.tsx    # the file we'll edit

You almost never touch route.ts after the first day. It just wires your agent into Novu:

app/api/novu/route.ts
import { serve } from '@novu/framework/next';
import { supportAgent } from '../../novu/agents';
 
export const { GET, POST, OPTIONS } = serve({
  agents: [supportAgent],
});

Everything below happens inside support-agent.tsx.

Building agents Logic

Step 1: Define the agent shell

Start with the bare minimum, an agent() call with an id and an empty onMessage handler. The id (support-agent) is what you'll reference in the Novu dashboard.

app/novu/agents/support-agent.tsx
/** @jsxImportSource @novu/framework */
import { agent } from '@novu/framework';
 
export const supportAgent = agent('support-agent', {
  onMessage: async ({ message, ctx }) => {
    return `You said: ${message.text}`;
  },
});

The JSX pragma at the top lets us return cards as JSX later. You can skip it if you only ever return strings.

At this point the agent is already alive: send it a message and it echoes back. Now let's make it actually behave like a support bot.

Handle first message

A real support bot doesn't open with "you said: hello". It introduces itself and asks how it can help.

The ctx.conversation.messageCount field tells you which turn you're on. On the first message it's 1, so we can branch on that and send a welcome card with three buttons, one for each topic.

import { Actions, agent, Button, Card, CardText } from '@novu/framework';
 
export const supportAgent = agent('support-agent', {
  onMessage: async ({ message, ctx }) => {
    const firstName = ctx.subscriber?.firstName;
    const isFirstMessage = ctx.conversation.messageCount <= 1;
 
    if (isFirstMessage) {
      return (
        <Card title={`Hi${firstName ? `, ${firstName}` : ''}! I'm the Pipelinr bot`}>
          <CardText>What can I help you with 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 `You said: ${message.text}`;
  },
});

A few things worth pointing out:

  • ctx.subscriber carries whatever you know about the user (firstName, email, custom data). Using it for the greeting makes the bot feel less robotic.
  • Returning the JSX is the shortcut for await ctx.reply(...). Either one works, the return form just reads cleaner inside a handler.
  • Each Button carries an id and a value. We'll use both in the next step.

For the full menu of card components, see Reply Types.

Use metadata for context

When the user clicks one of the buttons, the framework fires onAction instead of onMessage. You get the button's id and value, and your job is to record the choice somewhere the next turn can read it.

That's what ctx.metadata is for. It's a key/value bag attached to the conversation that survives across handler runs.

export const supportAgent = agent('support-agent', {
  // ...onMessage from step 2...
 
  onAction: async ({ actionId, value, ctx }) => {
    if (actionId.startsWith('topic-') && value) {
      ctx.metadata.set('topic', value);
      return `Got it, a **${value}** issue. Tell me what's going on and I'll take a look.`;
    }
  },
});

Now the conversation has a topic saved on it. On the next message you can read it back with ctx.metadata.get('topic') and tailor the response, route to a different prompt, or pull the right docs.

If you wanted to also ping your on-call channel for technical issues, this is where you'd use ctx.trigger to fire a Novu workflow. See Signals.

Answer the follow-up messages with an LLM

After the first welcome card, every subsequent message is a real question. Time to plug in a model.

We'll use the Vercel AI SDK with OpenAI. The SDK is provider-agnostic, so once it's wired in you can swap to Anthropic, Google, or anything else with a one-line change. The framework itself doesn't care which model you pick.

Install the packages:

npm install ai @ai-sdk/openai

Drop your API key in .env.local:

OPENAI_API_KEY=sk-...

Now inside onMessage, after the welcome-card branch, pull the saved topic out of metadata, hand ctx.history to the model, and return the answer:

import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
 
// inside onMessage, after the isFirstMessage branch:
const topic = ctx.metadata.get('topic') ?? 'unknown';
 
const { text } = await generateText({
  model: openai('gpt-4o-mini'),
  system: `You are a Pipelinr support agent. The user's topic is: ${topic}. Keep answers short and link to docs when relevant.`,
  messages: ctx.history.map((h) => ({
    role: h.role,
    content: h.content,
  })),
});
 
return text;

A few practical notes:

  • ctx.history already gives you { role, content } entries, which is exactly what the SDK expects.
  • gpt-4o-mini is cheap and fast enough for most support replies. Bump to gpt-4o for trickier questions.
  • Switching to Anthropic is one line: npm install @ai-sdk/anthropic, then model: anthropic('claude-3-5-sonnet-latest').
  • For a typing-effect reply, swap generateText for streamText and pipe the stream to ctx.reply. See the Vercel AI SDK docs for streaming patterns.
  • If the answer should include a file (a CSV export, a screenshot, a PDF), call ctx.reply directly with the files option instead of returning a string. See attachments.

Close the loop and resolve

When the user signals they're done, mark the conversation resolved. This updates the status in Novu, fires any onResolve handler you've defined, and is a nice place to trigger a CSAT follow-up workflow.

We'll keep it simple and just look for "thanks" or "resolve" in the message text:

const text = (message.text ?? '').toLowerCase();
 
if (text.includes('thanks') || text.includes('resolve')) {
  ctx.resolve('User confirmed the issue is fixed.');
  // ctx.trigger('csat-survey', { to: ctx.subscriber }); // optional follow-up
  return 'Glad I could help. Closing this out, ping me anytime.';
}

ctx.resolve takes an optional summary string that shows up in the Novu dashboard, useful when an agent or analyst opens the thread later. If the user replies again afterwards, the conversation reopens automatically.

Complete agent logic

Here's everything stitched together. This is the file you'd ship as a v1 of the Pipelinr support bot:

app/novu/agents/support-agent.tsx
/** @jsxImportSource @novu/framework */
import { Actions, agent, Button, Card, CardText } from '@novu/framework';
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
 
export const supportAgent = agent('support-agent', {
  onMessage: async ({ message, ctx }) => {
    const firstName = ctx.subscriber?.firstName;
    const userText = (message.text ?? '').toLowerCase();
    const isFirstMessage = ctx.conversation.messageCount <= 1;
 
    if (isFirstMessage) {
      return (
        <Card title={`Hi${firstName ? `, ${firstName}` : ''}! I'm the Pipelinr bot`}>
          <CardText>What can I help you with 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>
      );
    }
 
    if (userText.includes('thanks') || userText.includes('resolve')) {
      ctx.resolve('User confirmed the issue is fixed.');
      return 'Glad I could help. Closing this out, ping me anytime.';
    }
 
    const topic = ctx.metadata.get('topic') ?? 'unknown';
    const { text } = await generateText({
      model: openai('gpt-4o-mini'),
      system: `You are a Pipelinr support agent. The user's topic is: ${topic}. Keep answers short and link to docs when relevant.`,
      messages: ctx.history.map((h) => ({
        role: h.role,
        content: h.content,
      })),
    });
 
    return text;
  },
 
  onAction: async ({ actionId, value, ctx }) => {
    if (actionId.startsWith('topic-') && value) {
      ctx.metadata.set('topic', value);
      return `Got it, a **${value}** issue. Tell me what's going on and I'll take a look.`;
    }
  },
});

How the pieces fit together

Reading the file end to end, here's the mental model:

  • onMessage runs on every user text message. You branch on what turn it is and what was said.
  • onAction runs when the user clicks a button or picks a dropdown option from a card you sent.
  • ctx.metadata is your scratchpad on the conversation. Write on one turn, read on the next.
  • ctx.history is the transcript, ready to feed into an LLM.
  • ctx.reply (or returning a value) is how you talk back. Strings, markdown, cards, or files.
  • ctx.trigger fires a Novu workflow, useful for emails, escalations, surveys.
  • ctx.resolve ends the conversation.

Next steps

Once this skeleton is working, here's where to go from here:

The shape of the file stays the same. You just keep adding branches and handlers as the agent grows up.

On this page

Edit this page on GitHub