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:
You almost never touch route.ts after the first day. It just wires your agent into Novu:
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.
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.
A few things worth pointing out:
ctx.subscribercarries 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
Buttoncarries anidand avalue. 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.
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:
Drop your API key in .env.local:
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:
A few practical notes:
ctx.historyalready gives you{ role, content }entries, which is exactly what the SDK expects.gpt-4o-miniis cheap and fast enough for most support replies. Bump togpt-4ofor trickier questions.- Switching to Anthropic is one line:
npm install @ai-sdk/anthropic, thenmodel: anthropic('claude-3-5-sonnet-latest'). - For a typing-effect reply, swap
generateTextforstreamTextand pipe the stream toctx.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.replydirectly with thefilesoption 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:
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:
How the pieces fit together
Reading the file end to end, here's the mental model:
onMessageruns on every user text message. You branch on what turn it is and what was said.onActionruns when the user clicks a button or picks a dropdown option from a card you sent.ctx.metadatais your scratchpad on the conversation. Write on one turn, read on the next.ctx.historyis 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.triggerfires a Novu workflow, useful for emails, escalations, surveys.ctx.resolveends the conversation.
Next steps
Once this skeleton is working, here's where to go from here:
Give the model real context
Pull from your docs with a RAG step before generateText, or add tool calls so the model can hit your API.
Capture reactions
Add an onReaction handler to record thumbs-up/down feedback on your agent's replies.
Send a CSAT email
Use ctx.trigger after resolution to fire a Novu workflow and follow up with a short survey.
Build richer cards
Return dropdowns, links, text inputs, and multi-action cards from your handlers.
Deploy your agent
Run your agent locally with the dev tunnel, then ship it to production.
The shape of the file stays the same. You just keep adding branches and handlers as the agent grows up.