Skip to content
Knobase

Vercel AI SDK

Call Knobase beside streamText and keep the model answer unchanged.

This is host-application sample code. The Knobase repository does not depend on the `ai` package. The example uses the current public AI SDK streamText / UIMessage surface.

What the host may do

  • Call Knobase from the server independently from streamText.
  • Preserve the normal answer stream.
  • Attach a structured offer as typed application data or UIMessage metadata/data parts.
  • Let the existing app decide whether it has an appropriate native UI surface.

Do not

  • Put offer content into the model system prompt.
  • Ask the model to produce sponsored text.
  • Re-feed a sponsored offer tool result to the model as answer content.
  • Add a required card component.

Independent decide beside streamText

import { streamText } from "ai";

type CommercialAttachment = {
  disclosure: string;
  advertiser: string;
  link: string;
  title?: string;
  description?: string;
  cta?: string;
};

async function decideOffer(message: string): Promise<CommercialAttachment | undefined> {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 400);
  try {
    const response = await fetch("https://knobase.com/v1/offers/decide", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.KNOBASE_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        message,
        locale: "ko",
        audience: { age_band: "18_plus", contextual_offer_consent: true },
        native_surface: { clickable: true, disclosure_capable: true },
      }),
      signal: controller.signal,
    });
    if (!response.ok) return undefined;
    const decision = await response.json();
    if (!decision.serve) return undefined;
    return {
      disclosure: decision.disclosure,
      advertiser: decision.advertiser,
      link: decision.link,
      title: decision.title,
      description: decision.description,
      cta: decision.cta,
    };
  } catch {
    return undefined;
  } finally {
    clearTimeout(timer);
  }
}

export async function POST(req: Request) {
  const { messages } = await req.json();
  const current = messages.at(-1)?.content ?? "";

  const [result, commercialAttachment] = await Promise.all([
    streamText({
      model: "openai/gpt-4.1-mini",
      messages,
      // Do not put offer copy in the system prompt.
    }),
    decideOffer(typeof current === "string" ? current : ""),
  ]);

  // Return the model stream unchanged. Attach the offer as separate
  // application data or UIMessage metadata — never as answer text.
  return result.toUIMessageStreamResponse({
    messageMetadata: {
      commercialAttachment,
    },
  });
}

Docs version v1 · implemented publisher API