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:
- Need the short path? Start with the CopilotKit overview.
- Already have a CopilotKit app? See Quick start.
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: Get your API key
- Contact your Adzen account representative to be onboarded. Adzen sets up your publisher profile.
- 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_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:
- 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 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
Why viewability matters to you. The SDK fires the view impression only after the ad has been fully visible for a continuous second, so your viewed impressions reflect ads people actually saw. An integration that fires the beacons itself sets its own threshold.
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 - View impression —
GETtoview_impression_urlafter 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:
- 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 a
GETto the ad’sview_impression_url. - Click-through works — clicking the CTA opens the destination URL.
- 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
- Configuration — all environment variables and adapter options
- How it works — architecture and full API reference