Skip to Content

Integration walkthrough

Everything between “we have a key” and “ads are live”, in order.

This guide walks through the full Adzen-on-CopilotKit integration from account setup to live verification.

Skip ahead:

Prerequisites

  • Node.js >=18
  • A CopilotKit app with a React frontend
  • An AG-UI-compatible backend agent (LangGraph, custom AG-UI, etc.)
  • @adzenai/ai and @adzenai/core packages installed

Part 1: Get your API key

  1. Contact your Adzen account representative to be onboarded. Adzen sets up your publisher profile.
  2. You are issued an API key. Store it where only your server can read it.

Part 2: Configure environment

Add Adzen credentials to your .env file:

ADZEN_API_KEY=your_api_key

Optional:

ADZEN_TIMEOUT_MS=3000 ADZEN_AD_UNIT_POSITION=chin ADZEN_LOCATION=US-CA-803

Part 3: Instantiate the middleware

Create an AdzenAsyncMiddleware instance with your configuration. This can live in your CopilotKit backend, your AG-UI agent, or a shared utility file:

// lib/adzen.ts import { AdzenAsyncMiddleware } from "@adzenai/ai/copilotkit"; export const adzen = new AdzenAsyncMiddleware({ apiKey: process.env.ADZEN_API_KEY!, timeoutMs: Number(process.env.ADZEN_TIMEOUT_MS ?? 3000), adUnitPosition: process.env.ADZEN_AD_UNIT_POSITION ?? "chin", location: process.env.ADZEN_LOCATION, });

The middleware is stateful per conversation turn — it buffers text per messageId and tracks pending ad fetches. Create a new instance per session or conversation if you want isolated state, or reuse one instance if your event loop is sequential.

Part 4: Pipe AG-UI events through the middleware

Every AG-UI event from your backend agent must pass through processEvent(). The method returns an array of downstream events — always including the original event, plus an adzen_placement custom event if an ad was matched.

import { adzen } from "./lib/adzen"; async function processAgentStream( agentStream: AsyncIterable<Record<string, unknown>>, emit: (event: Record<string, unknown>) => void, ) { for await (const event of agentStream) { const downstream = await adzen.processEvent(event); for (const evt of downstream) { emit(evt); } } }

Key behaviors in this loop:

  • RUN_STARTED → captures threadId as conversation_id for subsequent /process requests
  • TEXT_MESSAGE_START → creates a buffer for the messageId
  • TEXT_MESSAGE_CONTENT → appends delta to the buffer (event passes through immediately)
  • TEXT_MESSAGE_END → triggers the ad fetch; the returned array includes the original event plus an adzen_placement event if matched
  • RUN_FINISHED → awaits all pending ad fetches before returning
  • All other events → passed through unchanged

Part 5: Bridge AG-UI events to the browser

On the client side, use dispatchPlacementEvents() to bridge adzen_placement events from the AG-UI stream into browser CustomEvents. The useAdzenPlacement hook and AdzenCard component listen for these events.

import { dispatchPlacementEvents } from "@adzenai/ai/copilotkit/react"; function handleDownstreamEvents(events: Record<string, unknown>[]) { // 1. Update your chat UI state (message content, streaming status, etc.) updateChatState(events); // 2. Bridge placement events to the browser dispatchPlacementEvents(events); }

dispatchPlacementEvents silently ignores non-placement events, so it is safe to call on every batch of downstream events.

Part 6: Render ad placements with AdzenCard

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

import { AdzenCard } from "@adzenai/ai/copilotkit/react"; function ChatMessage({ message }) { return ( <div> <div className="message-bubble">{message.content}</div> {message.role === "assistant" && ( <AdzenCard messageId={message.id} /> )} </div> ); }

AdzenCard internally calls useAdzenPlacement() to look up the ad for the given messageId. If no ad exists, it returns null — no empty DOM element is rendered.

When an ad is available, the card renders:

  • The advertiser avatar and name
  • A “Relevant Ad” disclosure
  • The headline
  • A CTA link to the destination URL

Part 7: Custom ad rendering

For full control over ad rendering, use useAdzenPlacement() directly instead of AdzenCard:

import { useAdzenPlacement } from "@adzenai/ai/copilotkit/react"; function CustomAdDisplay({ messageId }: { messageId: string }) { const { getAdForMessage } = useAdzenPlacement(); const ad = getAdForMessage(messageId); if (!ad) return null; return ( <div className="my-custom-ad"> <img src={ad.creative_url} alt={ad.headline} /> <h3>{ad.headline}</h3> <p>{ad.description}</p> <a href={ad.destination_url}>{ad.cta_text}</a> </div> ); }

The useAdzenPlacement hook returns:

  • getAdForMessage(messageId: string): AdzenPlacement | null — look up an ad by message ID
  • placements: Map<string, AdzenPlacement> — the full map of all placements received

When using custom rendering, you are responsible for impression tracking. See Part 8.

Part 8: Impression tracking and viewability

Why viewability matters to you. Counting an impression only once the ad has actually been seen means your impressions reflect ads people actually saw.

AdzenCard handles impression tracking automatically. It fires client-side GET requests directly to the delivery API using the pre-built impression URLs from the ad data. No server-side proxy or authentication is required.

Default behavior:

SettingDefaultDescription
viewabilityThresholdMs1000Milliseconds the ad must be continuously visible before a view impression fires

AdzenCard fires two impression beacons automatically:

  1. Render impression — GET to render_impression_url on mount
  2. View impression — GET to view_impression_url after the viewability threshold is met

If render_impression_url or view_impression_url is null, the corresponding beacon is skipped.

Customizing viewability on AdzenCard:

<AdzenCard messageId={msg.id} viewabilityThresholdMs={2000} />

Manual impression tracking with useAdzenPlacement:

If you use custom rendering, fire impressions yourself using observeViewability from @adzenai/core:

import { observeViewability } from "@adzenai/core"; // Fire render impression on mount useEffect(() => { if (!ad || !ad.render_impression_url) return; // The impression URL is pre-built by the API — fire it as-is. fetch(ad.render_impression_url, { method: "GET", keepalive: true }); }, [ad]); // Fire view impression on viewability threshold useEffect(() => { if (!ad || !ad.view_impression_url || !elementRef.current) return; return observeViewability({ element: elementRef.current, thresholdMs: 1000, onViewed: () => { fetch(ad.view_impression_url!, { method: "GET", keepalive: true }); }, }); }, [ad]);

Each beacon should fire at most once per ad instance. If the beacon fails, retry once after 1 second, then silently drop.

Part 9: Verify a live run

Send a real message through your CopilotKit UI and verify the full pipeline:

  1. Assistant response streams normally — no added latency on the text.
  2. Ad card appears below the assistant message after the response completes.
  3. Impression fires after the ad is visible for the viewability threshold. Check the browser Network tab for a GET to the ad’s view_impression_url.
  4. Click-through works — clicking the CTA opens the destination URL.
  5. Recorded — your Adzen contact can confirm the impression and click events were recorded.

If the Adzen API is unreachable or returns an empty ads array:

  • No ad card is rendered.
  • No error is shown to the user.
  • The assistant experience is completely unaffected.

An empty response is a feature. It means nothing cleared our relevance and brand-safety bar for that turn, and we declined to fill the slot rather than show your users something that does not belong there.

Next

Last updated on