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. 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:
| 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