Skip to Content

Quick start — first matched ad

From an existing CopilotKit app to a matched ad on screen.

Use this guide when you already have a CopilotKit app and want Adzen to deliver contextual ads alongside assistant messages.

Adzen operates as AG-UI middleware. It does not wrap or modify the CopilotKit runtime — it processes the AG-UI event stream and adds adzen_placement custom events when an ad matches the assistant output.

Prerequisites

  • A working CopilotKit app with an AG-UI-compatible backend agent
  • @copilotkit/react-core and @copilotkit/runtime
  • Node.js >=18
  • Adzen API key

Step 1: Install packages

npm install @adzenai/ai @adzenai/core

Or with pnpm:

pnpm add @adzenai/ai @adzenai/core

Step 2: Configure environment

Add these variables to your .env:

ADZEN_API_KEY=your_api_key

Step 3: Add middleware to the AG-UI event stream

Instantiate AdzenAsyncMiddleware and pipe every AG-UI event through processEvent(). The middleware passes all events through unchanged and appends adzen_placement custom events when an ad is returned.

Run it server-side — in your CopilotKit runtime route or AG-UI agent. The Adzen API uses key auth, so calling it from the browser would expose your key.

import { AdzenAsyncMiddleware } from "@adzenai/ai/copilotkit"; const adzen = new AdzenAsyncMiddleware({ apiKey: process.env.ADZEN_API_KEY!, }); // In your AG-UI event processing loop: for await (const event of upstreamEvents) { const downstream = await adzen.processEvent(event); for (const evt of downstream) { sendToClient(evt); } }

Step 3b (optional): Streaming enrichment

Coming soon. Streaming enrichment is not yet available in production. The async middleware is the supported path today; this section describes the streaming mode so you can plan for it.

For in-message ads delivered while the response streams, use AdzenStreamMiddleware instead. It proxies the LLM response stream through Adzen’s /stream endpoint rather than waiting for the completed message.

This is server-side only. It passes the LLM response body as a ReadableStream fetch body, which browsers other than Chromium do not support — they coerce the stream to the string "[object ReadableStream]". Node 18+ handles it natively.

Tee the upstream LLM response so one branch feeds your AG-UI event parsing and the other is piped to Adzen:

import { AdzenStreamMiddleware } from "@adzenai/ai/copilotkit"; const adzen = new AdzenStreamMiddleware({ apiKey: process.env.ADZEN_API_KEY!, prefetch: true, // dispatch POST /process with the prompt before the stream opens }); const res = await fetch(llmUrl, { /* ... */ }); const [parseBranch, proxyBranch] = res.body!.tee(); const events = parseIntoAgUiEvents(parseBranch); for await (const evt of adzen.processStream(events, { prompt, upstreamBody: proxyBranch, })) { sendToClient(evt); }

Alongside adzen_placement, this emits adzen_inline_ad events for creatives rendered inside the message body via the InlineAd component. Each carries a content_offset so the ad can be rendered at the point in the text where it arrived — see Step 6.

Step 4: Bridge events to React

On the client, use dispatchPlacementEvents() to bridge AG-UI adzen_placement events to browser CustomEvents that the React components listen for:

import { dispatchPlacementEvents } from "@adzenai/ai/copilotkit/react"; // After processing events through the middleware: dispatchPlacementEvents(downstream);

This must run in a browser environment. dispatchPlacementEvents bridges both adzen_placement card events and adzen_inline_ad in-message events (see Step 6), so a single call handles async and streaming modes; any other event is silently ignored.

Step 5: Render ad cards

Add <AdzenCard> below each assistant message in your chat UI:

import { AdzenCard } from "@adzenai/ai/copilotkit/react"; function MessageList({ messages }) { return ( <> {messages.map((msg) => ( <div key={msg.id}> <MessageBubble content={msg.content} role={msg.role} /> {msg.role === "assistant" && ( <AdzenCard messageId={msg.id} /> )} </div> ))} </> ); }

AdzenCard returns null when there is no ad for the given messageId, so it is safe to render unconditionally after every assistant message.

Step 6: Render inline ads in the message body

Coming soon. Inline ads come from streaming enrichment, which is not yet available in production. This step describes it so you can plan for it.

In streaming mode, inline ads arrive mid-message and are anchored to the text that preceded them. getInlineAdSegments(messageId, content) splits the message into text and ad segments at those anchors, so each ad renders directly after the content it followed:

import { InlineAd, useAdzenInlineAds } from "@adzenai/ai/copilotkit/react"; function MessageList({ messages }) { // Subscribe at the top of the list so the listener is attached before the // first ad event arrives. const { getInlineAdSegments } = useAdzenInlineAds(); return ( <> {messages.map((msg) => ( <div key={msg.id} style={{ whiteSpace: "pre-wrap" }}> {getInlineAdSegments(msg.id, msg.content).map((segment, i) => segment.kind === "text" ? ( <span key={i}>{segment.text}</span> ) : ( <InlineAd key={segment.ad.ad_id} ad={segment.ad} /> ), )} </div> ))} </> ); }

InlineAd renders as a plain line of text rather than a card — an “Ad” pill, the advertiser name, then the CTA as the only link:

[AD] Acme Outfitters spring sale

It is block-level, so it starts on its own line below the text it follows. Messages with no ads yield a single text segment, so this is safe to use for every message.

The advertiser text comes from creative.advertiser_name, falling back to creative.headline when the API does not send one. The creative.label field (“Sponsored”) is not rendered — the pill is the disclosure.

Use getInlineAdsForMessage(messageId) instead if you want the raw ad list and want to place the creatives yourself.

Markdown responses

Text segments are plain slices of the assistant message, so each one can be handed to a Markdown renderer:

import Markdown from "react-markdown"; segment.kind === "text" ? ( <Markdown key={i}>{segment.text}</Markdown> ) : ( <InlineAd key={segment.ad.ad_id} ad={segment.ad} /> );

Anchors are snapped forward to the next paragraph break before the split happens, so a segment never ends mid-sentence or inside a Markdown construct such as **bold text** or a list. This matters because LLM deltas are token-sized: the raw offset frequently lands mid-word. While a paragraph is still streaming there is no break to snap to yet, so the ad sits at the end of the message and moves into position once the paragraph closes. The InlineAd component keeps its identity across that move, so no extra impression fires.

At most one ad is placed per paragraph break. When several ads resolve to the same break — which happens whenever they are anchored inside the same paragraph — each one after the first moves to the next free break, and the end of the message serves as a final slot. Any ad still without a slot is held back rather than stacked, and appears once the message has grown enough.

One consequence: if a response contains no blank lines at all, there is a single usable position, so only one inline ad renders and it sits at the end of the message.

Step 7: Verify a live request

Send a message through your CopilotKit UI, then verify:

  1. The assistant response streams normally with no added latency.
  2. After the message completes, an ad card appears below it (if the Adzen API returned a match).
  3. The ad card displays the advertiser avatar and name, a “Relevant Ad” disclosure, the headline, and the CTA.
  4. After the card is visible for 1 second (default viewability threshold), an impression beacon fires.
  5. Your Adzen contact can confirm the impression was recorded.

If the Adzen API returns no ads or is unreachable, no ad card appears and the assistant experience is unaffected.

Next

Last updated on