Skip to Content

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 pathEnvironmentContent
@adzenai/aiAny (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/reactBrowser (React ≥18)Generic React/Next entry (carries "use client"): AdzenCard, InlineAd, useAdImpressions, useAdzenPlacement, useAdzenInlineAds, buildInlineAdSegments. No CopilotKit/AG-UI dependency.
@adzenai/ai/copilotkitAdzenAsyncMiddleware: server (recommended — in the browser it would expose your key). AdzenStreamMiddleware: server only.AdzenAsyncMiddleware, AdzenAsyncConfig, AdzenStreamMiddleware, AdzenStreamMiddlewareConfig, AdzenStreamConfig
@adzenai/ai/copilotkit/reactBrowser (React ≥18)Everything from @adzenai/ai/react plus the AG-UI bridge helpers dispatchPlacementEvent, dispatchInlineAdEvent, dispatchPlacementEvents.

Public API summary

Generic ad fetching (@adzenai/ai)

ExportTypeDescription
fetchAdzenAdFunctionfetchAdzenAd(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.
mapResponseToPlacementFunctionmapResponseToPlacement(ad: ProcessResponseAd, adUnitPositionFallback?): AdzenPlacement. Pure field-rename mapping from the raw /process ad to AdzenPlacement.
generateIdempotencyKeyFunctionGenerates the per-request Idempotency-Key (/process returns 400 without one).
FetchAdzenAdOptionsInterfacecontent, 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

ExportTypeDescription
AdzenAsyncMiddlewareClassAG-UI middleware that buffers text, calls the Adzen /process API, and emits adzen_placement custom events
AdzenAsyncConfigInterfaceConfiguration for AdzenAsyncMiddleware (apiKey, endpointUrl, timeoutMs, adUnitPosition, location, conversationId, profileId)
AdzenStreamMiddlewareClassAG-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.
AdzenStreamMiddlewareConfigInterfaceExtends AdzenStreamConfig with adUnitPosition. Coming soon.
AdzenStreamConfigInterfaceStream client configuration (apiKey, endpointUrl, timeoutMs, location, conversationId, profileId, placementPollIntervalMs, prefetch, sidecar, and lifecycle callbacks). Coming soon.

React

ExportTypeDescription
AdzenCardComponentRenders 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.
useAdImpressionsHookuseAdImpressions(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.
InlineAdComponentRenders 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.
useAdzenPlacementHookSubscribes to adzen_placement browser events and maintains a Map<string, AdzenPlacement>.
useAdzenInlineAdsHookSubscribes to adzen_inline_ad browser events and exposes getInlineAdsForMessage(messageId) plus getInlineAdSegments(messageId, content).
buildInlineAdSegmentsFunctionPure helper behind getInlineAdSegments: splits message content at each ad’s content_offset.
dispatchPlacementEventFunctionDispatches a single AG-UI adzen_placement event as a browser CustomEvent.
dispatchInlineAdEventFunctionDispatches a single AG-UI adzen_inline_ad event as a browser CustomEvent.
dispatchPlacementEventsFunctionDispatches all placement and inline ad events from an array of AG-UI events.
InlineAdSegmentType{ kind: "text"; text: string } or { kind: "ad"; ad: InlineAdEventPayload }.

Types (from @adzenai/core)

TypeDescription
AdzenPlacementAd placement data: ad_id, advertiser_name, advertiser_image_url?, headline, description?, cta_text, destination_url, creative_url?, adUnitPosition, render_impression_url, view_impression_url
ProcessResponseAPI response shape: message_id, ads array
ProcessResponseAdRaw ad object from the API: ad_id, title, cta, click_through_url, render_impression_url, view_impression_url, etc.
AdzenBaseConfigShared base config: endpointUrl, apiKey, timeoutMs
InlineAdEventIn-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
InlineAdCreativeInline creative fields: headline, cta_text, destination_url, format, label, and optional advertiser_name
InlineAdEventPayloadInlineAdEvent (including paragraph_index) plus message_id and content_offset, the payload of the adzen_inline_ad custom event
PlacementRecord / PlacementsResponseSidecar placement records returned by the /placements polling endpoint
AdAnchorEventPayload 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
AdzenStreamEventUnion 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 beacons

Ad fetch lifecycle

  1. RUN_STARTED captures threadId as conversation_id (if present).
  2. TEXT_MESSAGE_START creates a buffer keyed by messageId.
  3. TEXT_MESSAGE_CONTENT events accumulate text in the buffer.
  4. TEXT_MESSAGE_END triggers a POST to the Adzen /process API with the complete message text, message_id, and optionally location and conversation_id.
  5. If the API returns ads, the middleware maps the first ad to AdzenPlacement and appends an adzen_placement custom event to the downstream array.
  6. If the API returns an empty ads array, times out, or fails, no placement event is emitted.
  7. RUN_FINISHED is held until all pending ad fetches settle via Promise.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 up

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

  1. Render impression — fired on mount when the ad first becomes available. GET to render_impression_url.
  2. View impression — fired after the ad is fully visible in the viewport for the configured threshold (default 1000ms). GET to view_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

TriggerWhat is sentWhen
TEXT_MESSAGE_ENDComplete buffered message text + message_id + location + conversation_idAfter every assistant message
Final CUSTOM_EVENTEvent 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 messageId is missing
  • Stale buffer eviction (entries older than timeoutMs * 2 are removed on the next TEXT_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:

ScenarioBehavior
API returns empty ads arrayNo ad card rendered
API times outNo ad card rendered
API returns non-200No ad card rendered
Network errorNo ad card rendered
The ad request throwsCaught silently, no ad card rendered
Prefetch /process failsStream proceeds normally
Runtime cannot send stream request bodiesadzen_error emitted, no /stream request made

The assistant text stream is never delayed or modified, regardless of ad API behavior.

Supported runtime conditions

RequirementValue
Node.js>=18
React>=18
AdzenStreamMiddlewareNode >=18 only — requires fetch upload stream support, which browsers other than Chromium lack
AG-UI protocolAny agent producing standard AG-UI events (RUN_STARTED, TEXT_MESSAGE_*, RUN_FINISHED, CUSTOM_EVENT)
CopilotKitCompatible with @copilotkit/react-core and @copilotkit/runtime (no version lock — Adzen operates at the AG-UI layer)
@adzenai/aiPeer dependencies: @ag-ui/client, @ag-ui/core, rxjs, react (all optional)

Next

Last updated on