How it works
Additive by design: Adzen adds to the stream and never changes it.
Adzen sits alongside CopilotKit as AG-UI middleware. It intercepts the AG-UI event stream, matches ads to assistant output, and renders sponsored placements — all without modifying or blocking content.
The integration is additive only: Adzen adds adzen_placement custom events to the stream. The original event stream passes through unchanged with zero added latency to the text flow.
Package exports
| Export path | Environment | Content |
|---|---|---|
@adzenai/ai | Any (server-safe) | Runtime: fetchAdzenAd, mapResponseToPlacement, generateIdempotencyKey. Types: FetchAdzenAdOptions plus core re-exports AdzenPlacement, ProcessResponse, ProcessResponseAd, AdzenBaseConfig, and the streaming types InlineAdEvent, InlineAdEventPayload, InlineAdCreative, AdAnchorEvent, PlacementRecord, PlacementsResponse, AdzenStreamStartEvent, AdzenStreamEndEvent, AdzenStreamErrorEvent, AdzenStreamEvent. No "use client" — safe to import from server code. |
@adzenai/ai/react | Browser (React ≥18) | Generic React/Next entry (carries "use client"): AdzenCard, InlineAd, useAdImpressions, useAdzenPlacement, useAdzenInlineAds, buildInlineAdSegments. No CopilotKit/AG-UI dependency. |
@adzenai/ai/copilotkit | AdzenAsyncMiddleware: server (recommended — in the browser it would expose your key). AdzenStreamMiddleware: server only. | AdzenAsyncMiddleware, AdzenAsyncConfig, AdzenStreamMiddleware, AdzenStreamMiddlewareConfig, AdzenStreamConfig |
@adzenai/ai/copilotkit/react | Browser (React ≥18) | Everything from @adzenai/ai/react plus the AG-UI bridge helpers dispatchPlacementEvent, dispatchInlineAdEvent, dispatchPlacementEvents. |
Public API summary
Generic ad fetching (@adzenai/ai)
| Export | Type | Description |
|---|---|---|
fetchAdzenAd | Function | fetchAdzenAd(opts: FetchAdzenAdOptions): Promise<AdzenPlacement | null>. Framework-agnostic POST to /process; returns the first matched ad as an AdzenPlacement, or null on no-match/non-200/timeout/error (never throws). Sends the API key as X-API-Key, so call it server-side (route handler / server action) and hand the result to the client. |
mapResponseToPlacement | Function | mapResponseToPlacement(ad: ProcessResponseAd, adUnitPositionFallback?): AdzenPlacement. Pure field-rename mapping from the raw /process ad to AdzenPlacement. |
generateIdempotencyKey | Function | Generates the per-request Idempotency-Key (/process returns 400 without one). |
FetchAdzenAdOptions | Interface | content, messageId, apiKey (required); optional endpointUrl (default https://api.adzen.ai/v1/ai), timeoutMs (3000), location, conversationId, profileId, adUnitPosition, signal. |
AdzenAsyncMiddleware delegates its ad fetch to fetchAdzenAd, so async middleware and generic fetching share identical behavior.
Server-side / middleware
| Export | Type | Description |
|---|---|---|
AdzenAsyncMiddleware | Class | AG-UI middleware that buffers text, calls the Adzen /process API, and emits adzen_placement custom events |
AdzenAsyncConfig | Interface | Configuration for AdzenAsyncMiddleware (apiKey, endpointUrl, timeoutMs, adUnitPosition, location, conversationId, profileId) |
AdzenStreamMiddleware | Class | AG-UI middleware that proxies the LLM response stream through /stream for real-time enrichment, emitting adzen_inline_ad and adzen_placement events. Server-side only. Coming soon. |
AdzenStreamMiddlewareConfig | Interface | Extends AdzenStreamConfig with adUnitPosition. Coming soon. |
AdzenStreamConfig | Interface | Stream client configuration (apiKey, endpointUrl, timeoutMs, location, conversationId, profileId, placementPollIntervalMs, prefetch, sidecar, and lifecycle callbacks). Coming soon. |
React
| Export | Type | Description |
|---|---|---|
AdzenCard | Component | Renders a sponsored ad card. Dual-mode props: pass a resolved ad: AdzenPlacement (generic React/Next) or a messageId: string to look one up from placement events (CopilotKit). Returns null when there is no ad. Fires render/view impression beacons via useAdImpressions. |
useAdImpressions | Hook | useAdImpressions(ad, options?) => { ref }. Attach the returned ref to your own ad element to get Adzen impression tracking (render beacon on mount, view beacon after the viewability threshold) while rendering custom markup. options: adUnitPosition, viewabilityThresholdMs. Null-safe; skips a beacon when its URL is null. |
InlineAd | Component | Renders an in-message creative from an adzen_inline stream event as a plain text snippet — an “Ad” pill, the advertiser name, and the CTA as the only link. Fires render and view impression beacons like AdzenCard, but from the inline creative’s own pre-built impression record rather than the two separate render_impression_url / view_impression_url fields an AdzenPlacement carries. |
useAdzenPlacement | Hook | Subscribes to adzen_placement browser events and maintains a Map<string, AdzenPlacement>. |
useAdzenInlineAds | Hook | Subscribes to adzen_inline_ad browser events and exposes getInlineAdsForMessage(messageId) plus getInlineAdSegments(messageId, content). |
buildInlineAdSegments | Function | Pure helper behind getInlineAdSegments: splits message content at each ad’s content_offset. |
dispatchPlacementEvent | Function | Dispatches a single AG-UI adzen_placement event as a browser CustomEvent. |
dispatchInlineAdEvent | Function | Dispatches a single AG-UI adzen_inline_ad event as a browser CustomEvent. |
dispatchPlacementEvents | Function | Dispatches all placement and inline ad events from an array of AG-UI events. |
InlineAdSegment | Type | { kind: "text"; text: string } or { kind: "ad"; ad: InlineAdEventPayload }. |
Types (from @adzenai/core)
| Type | Description |
|---|---|
AdzenPlacement | Ad placement data: ad_id, advertiser_name, advertiser_image_url?, headline, description?, cta_text, destination_url, creative_url?, adUnitPosition, render_impression_url, view_impression_url |
ProcessResponse | API response shape: message_id, ads array |
ProcessResponseAd | Raw ad object from the API: ad_id, title, cta, click_through_url, render_impression_url, view_impression_url, etc. |
AdzenBaseConfig | Shared base config: endpointUrl, apiKey, timeoutMs |
InlineAdEvent | In-stream creative delivered by an adzen_inline SSE event; carries paragraph_index (the placement anchor used with ad_id as the instance key) and an optional absolute content_offset |
InlineAdCreative | Inline creative fields: headline, cta_text, destination_url, format, label, and optional advertiser_name |
InlineAdEventPayload | InlineAdEvent (including paragraph_index) plus message_id and content_offset, the payload of the adzen_inline_ad custom event |
PlacementRecord / PlacementsResponse | Sidecar placement records returned by the /placements polling endpoint |
AdAnchorEvent | Payload of the adzen_ad_anchor SSE event: version, paragraph_index, and an optional absolute content_offset. A positioning event the SDK uses to place inline ads; you do not need to handle it |
AdzenStreamEvent | Union of the SSE lifecycle events: adzen_stream_start, adzen_inline, adzen_ad_anchor, adzen_error, adzen_stream_end |
Architecture
AG-UI event flow
Backend Agent
│
│ AG-UI events (RUN_STARTED, TEXT_MESSAGE_START, TEXT_MESSAGE_CONTENT,
│ TEXT_MESSAGE_END, RUN_FINISHED, CUSTOM_EVENT, ...)
▼
AdzenAsyncMiddleware.processEvent(event)
│
├─ RUN_STARTED → captures threadId as conversation_id
├─ TEXT_MESSAGE_START → creates buffer for messageId
├─ TEXT_MESSAGE_CONTENT → appends delta to buffer
├─ TEXT_MESSAGE_END → triggers ad fetch, emits adzen_placement if matched
├─ CUSTOM_EVENT (final) → fallback content source for non-text agents
├─ RUN_FINISHED → waits for all pending ad fetches to settle
└─ all other events → passed through unchanged
│
▼
Downstream events (original events + adzen_placement custom events)
│
▼
dispatchPlacementEvents() → bridges to browser CustomEvents
│
▼
useAdzenPlacement() → maintains Map<messageId, AdzenPlacement>
│
▼
AdzenCard → renders ad, tracks viewability, fires impression GET beaconsAd fetch lifecycle
RUN_STARTEDcapturesthreadIdasconversation_id(if present).TEXT_MESSAGE_STARTcreates a buffer keyed bymessageId.TEXT_MESSAGE_CONTENTevents accumulate text in the buffer.TEXT_MESSAGE_ENDtriggers aPOSTto the Adzen/processAPI with the complete message text,message_id, and optionallylocationandconversation_id.- If the API returns ads, the middleware maps the first ad to
AdzenPlacementand appends anadzen_placementcustom event to the downstream array. - If the API returns an empty
adsarray, times out, or fails, no placement event is emitted. RUN_FINISHEDis held until all pending ad fetches settle viaPromise.allSettled.
Streaming event flow
Coming soon. Streaming enrichment is not yet available in production. The async middleware is the supported path today; this section describes the streaming mode so you can plan for it.
AdzenStreamMiddleware proxies the LLM response stream instead of buffering it. processStream() takes the AG-UI event iterable plus the upstream LLM response body:
Upstream LLM response
│
├─ tee() branch 1 → parsed into AG-UI events → processStream(events, ...)
└─ tee() branch 2 → passed as options.upstreamBody
│
▼
RUN_STARTED → creates AdzenStreamClient; if prefetch, dispatches
POST /process with the prompt without awaiting it
TEXT_MESSAGE_START → connect(messageId, upstreamBody) opens POST /stream,
piping branch 2 as the request body
TEXT_MESSAGE_* → SSE ad events drained and emitted as adzen_inline_ad
TEXT_MESSAGE_END → finalize() awaits the SSE response
RUN_FINISHED → polls /placements once, then aborts and cleans upEach adzen_inline_ad event carries a content_offset, the position in the message where the ad belongs. getInlineAdSegments(messageId, content) uses it to render the ad directly after that text instead of at the end of the message, so the ad renders on the line below the text it follows.
Each adzen_inline (and adzen_ad_anchor) event carries an absolute content_offset: the exact UTF-16 index in the accumulated assistant text where the ad should be inserted, cumulative for the message and landing on a \n\n block boundary. The middleware uses that offset verbatim, so placement is deterministic and independent of stream timing. Because the unit is UTF-16 code units (the same as String.length/.slice), multi-byte characters do not shift placement and no re-encoding is needed.
When an adzen_ad_anchor carries a content_offset, that value is the authoritative reservation. An anchor with no ad after it is cleared at the next message, so nothing renders and no gap appears.
Inline ads are deduped by their placement instance — the (ad_id, paragraph_index) pair — not by ad_id alone. The same ad may appear more than once in a response, each at a different paragraph, so keying by ad_id would drop every showing after the first. paragraph_index is the identity key; content_offset remains the position anchor.
Offsets are snapped forward to the next paragraph break before the content is split. Deltas are token-sized, so a raw offset usually lands mid-word; splitting there would cut a sentence in half and break Markdown constructs that span the boundary. Segments are therefore always whole paragraphs, which lets each one be passed to a Markdown renderer independently. Ads anchored inside a paragraph that is still streaming render at the end of the message until that paragraph closes.
One ad per paragraph break. Snapping makes collisions common: every ad anchored inside the same paragraph resolves to that paragraph’s end, so several ads can share one offset. Each ad after the first is therefore moved to the next free break, with the end of the content usable as a final slot. An ad with no free slot is not rendered — and so fires no impression — until the message grows enough to hold it.
Request metadata travels in headers (X-API-Key, X-Message-Id, X-Conversation-Id, Idempotency-Key, and optionally X-Profile-Id and X-Location) because the body carries the LLM stream.
The optional 2-step prefetch sends the user prompt to /process before the stream opens. Its response is intentionally discarded and never rendered.
The prefetch is dispatched but not awaited. Only the ordering matters — the request leaves before /stream opens. Awaiting it would hold every AG-UI event behind a full ad-matching round trip, during which the LLM keeps streaming into the tee’s buffer; the assistant message would then appear in one burst instead of streaming. Any prefetch still in flight is awaited by finalize() at TEXT_MESSAGE_END, so nothing is left dangling.
Server-side requirement
This flow passes a ReadableStream as the fetch request body. Only Chromium implements fetch upload streams, and it requires HTTP/2; Safari and Firefox coerce a stream body to the literal string "[object ReadableStream]". Node 18+ (undici) supports it over HTTP/1.1 via chunked transfer encoding.
AdzenStreamClient feature-detects support at module load. If unavailable it emits an adzen_error with code request_streams_unsupported, cancels the body, and makes no request — so an unsupported runtime degrades silently rather than sending an invalid request.
Impression tracking
AdzenCard fires two impression beacons as client-side GET requests directly to the delivery API:
- Render impression — fired on mount when the ad first becomes available.
GETtorender_impression_url. - View impression — fired after the ad is fully visible in the viewport for the configured threshold (default 1000ms).
GETtoview_impression_url.
No server-side proxy or authentication headers are needed. The impression URLs are pre-built by the API with all required identifiers baked in. A single retry after 1 second is attempted on failure.
Ad decision boundaries
| Trigger | What is sent | When |
|---|---|---|
TEXT_MESSAGE_END | Complete buffered message text + message_id + location + conversation_id | After every assistant message |
Final CUSTOM_EVENT | Event payload content (output, result, data, payload, or message field) | Only if no text buffer was already fetched for that messageId |
A final custom event is detected when the event has type of CUSTOM_EVENT/CUSTOM/CustomEvent and either final: true, isFinal: true, or a name containing assistant_final or final_output.
Concurrent message handling
The middleware maintains a Map<string, MessageBuffer> keyed by messageId. This supports:
- Multiple concurrent assistant messages (multi-agent scenarios)
- An ID is generated automatically when
messageIdis missing - Stale buffer eviction (entries older than
timeoutMs * 2are removed on the nextTEXT_MESSAGE_START)
Silent degradation
Adzen can never break your product. Every failure path below ends the same way: no ad, and an assistant that carries on exactly as it would without Adzen.
Adzen is designed to be invisible on failure:
| Scenario | Behavior |
|---|---|
API returns empty ads array | No ad card rendered |
| API times out | No ad card rendered |
| API returns non-200 | No ad card rendered |
| Network error | No ad card rendered |
| The ad request throws | Caught silently, no ad card rendered |
Prefetch /process fails | Stream proceeds normally |
| Runtime cannot send stream request bodies | adzen_error emitted, no /stream request made |
The assistant text stream is never delayed or modified, regardless of ad API behavior.
Supported runtime conditions
| Requirement | Value |
|---|---|
| Node.js | >=18 |
| React | >=18 |
AdzenStreamMiddleware | Node >=18 only — requires fetch upload stream support, which browsers other than Chromium lack |
| AG-UI protocol | Any agent producing standard AG-UI events (RUN_STARTED, TEXT_MESSAGE_*, RUN_FINISHED, CUSTOM_EVENT) |
| CopilotKit | Compatible with @copilotkit/react-core and @copilotkit/runtime (no version lock — Adzen operates at the AG-UI layer) |
@adzenai/ai | Peer dependencies: @ag-ui/client, @ag-ui/core, rxjs, react (all optional) |
Next
- Integration walkthrough — step-by-step wiring guide
- Configuration — environment variables, adapter options, timeouts
- Quick start — quick-start for existing apps