SDKs
AI
CopilotKit
Developer Guide
Integration Walkthrough

Integration Walkthrough

This guide walks through the full Adzen-on-CopilotKit integration from account setup to live verification. It follows the same architecture as the demo app.

💡

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: Adzen Account Setup

  1. Sign up or log in to your Adzen account.
  2. Create a publisher profile for your app.
  3. Note your API key.
  4. Configure ad targeting rules in the Adzen dashboard.

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:

  • A "Sponsored" label
  • The creative image (if creative_url is set)
  • The headline
  • The description (if set)
  • 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

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 impressionGET to render_impression_url on mount (with ad_unit_position and timestamp appended)
  2. View impressionGET to view_impression_url after the viewability threshold is met (with ad_unit_position, viewability_ms, and timestamp appended)

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;
 
  const url = new URL(ad.render_impression_url);
  url.searchParams.set("ad_unit_position", "chin");
  url.searchParams.set("timestamp", new Date().toISOString());
  fetch(url.toString(), { 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: () => {
      const url = new URL(ad.view_impression_url!);
      url.searchParams.set("ad_unit_position", "chin");
      url.searchParams.set("viewability_ms", "1000");
      url.searchParams.set("timestamp", new Date().toISOString());
      fetch(url.toString(), { 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 GET requests to delivery.adzen.ai.
  4. Click-through works — clicking the CTA opens the destination URL.
  5. Check your Adzen dashboard for the impression and click events.

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.

Next Steps