Skip to content

Tutorial: Build your first Agent

Build a Pipelinr support agent with handlers, cards, metadata, an LLM, and conversation resolution.

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

This walkthrough builds a small support bot for a fake product called Pipelinr. You add one piece at a time in support-agent.tsx until the bot greets users, routes by topic, answers with an LLM, and resolves the thread when the user is done.

For API reference, see Handle events, Reply, and Signals.

What you're building

In this tutorial, you build a Pipelinr support bot that:

  • Greets the user and asks whether their issue is a Billing question, a Technical issue, or Something else
  • Stores the user's choice and answers follow-up questions with an LLM
  • Closes the conversation when the user confirms the issue is resolved

That flow covers onMessage, onAction, metadata, LLM replies, and ctx.resolve().

Where the code goes

The scaffold creates a Next.js bridge app. All tutorial code goes in app/novu/agents/support-agent.tsx:

app/
  api/novu/route.ts       # HTTP entry point (created by the scaffold; no edits in this tutorial)
  novu/agents/
    index.ts              # re-exports each agent
    support-agent.tsx     # agent handlers you edit in this tutorial

The scaffold also adds app/api/novu/route.ts, which exposes your agents over HTTP. You do not need to change that file for this tutorial.

Everything below happens inside support-agent.tsx.

Build the agent

Follow the steps below to add handlers, cards, metadata, an LLM, and conversation resolution to support-agent.tsx.

Step 1: Define the agent shell

Start with the bare minimum: an agent() call with an id and an onMessage handler. The agent id (support-agent) must match the identifier you set in the Novu dashboard.

Replace the contents of support-agent.tsx with the following echo handler:

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}`;
  },
});

Add the /** @jsxImportSource @novu/framework */ pragma at the top of the file so you can return JSX cards in later steps. If you only return strings, you can omit it.

At this point the agent echoes messages back. In the next step, replace that behavior with a welcome card.

Step 2: Handle the first message

Replace the echo handler with a welcome card. On the first message, the bot introduces itself and asks the user to pick a topic.

Use ctx.conversation.messageCount to detect the first turn. When the count is 1, return a welcome card with three topic buttons:

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}`;
  },
});
  • ctx.subscriber carries user profile data for personalized greetings.
  • Returning JSX is shorthand for await ctx.reply(...).
  • Each Button has an id and value used in onAction.

For all card components, see Interactive cards.

Step 3: Use metadata for context

When the user clicks a button, onAction fires instead of onMessage. Add an onAction handler that stores the user's topic choice in ctx.metadata so the next turn can read it.

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.`;
    }
  },
});

Read it back with ctx.metadata.get('topic') on the next message. To alert on-call for technical issues, use ctx.trigger. For details, see Trigger a workflow.

Step 4: Answer follow-ups with an LLM

After the welcome card, plug in a model. This example uses the Vercel AI SDK with OpenAI.

Install the SDK and set your API key:

npm install ai @ai-sdk/openai
OPENAI_API_KEY=sk-...

Inside onMessage, after the welcome-card branch, add LLM generation:

import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
 
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;
  • ctx.history maps directly to SDK message format.
  • For files in replies, use ctx.reply with the files option. For details, see Sending attachments.

Step 5: Resolve the conversation

When the user confirms the issue is fixed, call ctx.resolve(). Add this check inside onMessage before the LLM branch:

const text = (message.text ?? '').toLowerCase();
 
if (text.includes('thanks') || text.includes('resolve')) {
  ctx.resolve('User confirmed the issue is fixed.');
  return 'Glad I could help. Closing this out, ping me anytime.';
}

The optional summary appears in the dashboard. If the user messages again, the conversation reopens automatically.

Complete agent

The following file combines all five steps:

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

  • onMessage: every user text message; branch on turn and content.
  • onAction: button clicks and dropdown selections from cards.
  • ctx.metadata: conversation scratchpad across turns.
  • ctx.history: transcript for LLM context.
  • ctx.reply (or return value) - strings, markdown, cards, or files.
  • ctx.trigger: fire Novu workflows (email, escalation, CSAT).
  • ctx.resolve: end the conversation.

Next steps

On this page

Edit this page on GitHub