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:
- Need the short path? Start with the CopilotKit overview.
- Already have a CopilotKit app? See Add Adzen to CopilotKit.
Prerequisites
- Node.js
>=18 - A CopilotKit app with a React frontend
- An AG-UI-compatible backend agent (LangGraph, custom AG-UI, etc.)
@adzenai/aiand@adzenai/corepackages installed
Part 1: Adzen Account Setup
- Sign up or log in to your Adzen account.
- Create a publisher profile for your app.
- Note your API key.
- Configure ad targeting rules in the Adzen dashboard.
Part 2: Configure Environment
Add Adzen credentials to your .env file:
ADZEN_API_KEY=your_api_keyOptional:
ADZEN_TIMEOUT_MS=3000
ADZEN_AD_UNIT_POSITION=chin
ADZEN_LOCATION=US-CA-803Part 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→ capturesthreadIdasconversation_idfor subsequent/processrequestsTEXT_MESSAGE_START→ creates a buffer for themessageIdTEXT_MESSAGE_CONTENT→ appendsdeltato the buffer (event passes through immediately)TEXT_MESSAGE_END→ triggers the ad fetch; the returned array includes the original event plus anadzen_placementevent if matchedRUN_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_urlis 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 IDplacements: 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:
| Setting | Default | Description |
|---|---|---|
viewabilityThresholdMs | 1000 | Milliseconds the ad must be continuously visible before a view impression fires |
AdzenCard fires two impression beacons automatically:
- Render impression —
GETtorender_impression_urlon mount (withad_unit_positionandtimestampappended) - View impression —
GETtoview_impression_urlafter the viewability threshold is met (withad_unit_position,viewability_ms, andtimestampappended)
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:
- Assistant response streams normally — no added latency on the text.
- Ad card appears below the assistant message after the response completes.
- Impression fires after the ad is visible for the viewability threshold. Check the browser Network tab for
GETrequests todelivery.adzen.ai. - Click-through works — clicking the CTA opens the destination URL.
- 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
- Configuration — all environment variables and adapter options
- Developer Guide — architecture and full API reference
- Run the Demo — self-contained demo with mock data