Framework integrations

Lulu Ads is a data source, not a middleware framework. Every adapter here does the same three things: call sponsored_slot/sponsoredSlot, attach the result under a sponsored key on the tool's own output, and fail open (any ads failure — missing creds, network error, timeout, malformed response — leaves the tool result exactly as the tool returned it). Nothing in this SDK ever instructs a model to say, do, or render anything. The host decides.

All adapters resolve credentials the same way as the base client: explicit args first, then LULU_ADS_PUBLISHER_ID / LULU_ADS_API_KEY / LULU_ADS_BASE_URL env vars. Missing credentials never raise — the adapter installs cleanly and every call becomes a no-op (sponsored stays absent).

FastMCP (Python)

from fastmcp import FastMCP
from lulu_ads.middleware import LuluAdsMiddleware

mcp = FastMCP("my-server")
mcp.add_middleware(LuluAdsMiddleware())

Requires fastmcp>=3.0.0. See python/lulu_ads/middleware.py.

LangChain / LangGraph (Python)

from langchain.agents import create_agent
from lulu_ads.integrations.langchain import LuluAdsAgentMiddleware

agent = create_agent(model, tools, middleware=[LuluAdsAgentMiddleware()])

Requires langchain>=1.0. LuluAdsAgentMiddleware subclasses langchain.agents.middleware.AgentMiddleware and implements wrap_tool_call/awrap_tool_call. When the wrapped ToolMessage.content is JSON, sponsored is merged into the parsed payload and re-serialized; for non-JSON content it lands in additional_kwargs["sponsored"] instead so the message text itself is never rewritten. Pass exclude_tools=(...) to skip specific tool names.

CrewAI (Python)

import lulu_ads.integrations.crewai as lulu_crewai

hook = lulu_crewai.install()  # call once, at process/crew startup

Requires crewai>=1.9.1. install() registers a global after_tool_call hook via crewai.hooks.register_after_tool_call_hook. The hook reads context.tool_name/context.tool_result, and — per CrewAI's after-hook contract — returns either a replacement result string (JSON with sponsored merged in, or the original text plus a disclosed one-line suffix for non-JSON results) or None to leave the result untouched. install() returns the hook function; pass it to crewai.hooks.unregister_after_tool_call_hook(hook) to remove it (useful in tests or when tearing down a crew).

MCP servers (TypeScript)

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { LuluAds, withLuluAds } from "lulu-ads";

const server = new McpServer({ name: "my-server", version: "1.0.0" });
withLuluAds(server); // ads is optional — defaults to `new LuluAds({})` (env-driven)

The official TypeScript SDK has no server-side tool middleware hook (typescript-sdk#1238 is still open), so withLuluAds wraps McpServer.registerTool itself: call it once, before registering any tools, and every tool registered after that point gets a sponsored field attached to its result. The field lands in two places — structuredContent.sponsored (skipped if the tool declares an outputSchema, since an unlisted field would fail validation) and the always-safe mirror _meta["ads.getlulu.dev/sponsored"]. Pass { excludeTools: [...] } to skip specific tool names, or { timeoutMs } to override the default 150ms cap.

Requires @modelcontextprotocol/sdk >= 1.x (1.29.0 tested).

Any agent runtime

The two integrations above are conveniences over a runtime-agnostic contract. Any loop that builds tool results — a Hermes-style agent pod, an OpenClaw skill runner, a bespoke harness that isn't LangChain, CrewAI, or MCP — can integrate directly:

  1. After a tool call completes (and only on success — never on an error result), call sponsored_slot(context) (Python) or sponsoredSlot({context}) (TypeScript) with an allowlisted context dict (tool, category, query, route, locale, country — no PII fields exist in the schema).
  2. If the result is non-null, attach the labeled dict verbatim under a "sponsored" key on the tool result payload you're about to hand back to the model or the client.
  3. If the result is None/null — inert client, backend down, timeout — do nothing. Never retry inline, never block the tool call waiting on ads.

Same three rules as every other adapter: data, never a directive; never on errors; allowlisted context only.

OpenClaw / ClawHub skill authors

An AgentSkill's backing script (Python or Node) can call the SDK directly and append the labeled sponsored object to the skill's own structured output — there's no special Lulu Ads/OpenClaw glue required, just the same sponsored_slot/sponsoredSlot call as any other runtime, documented above under "Any agent runtime."

This is also why the object is intentionally boring: {label: "Sponsored", text, url} with no rendering instructions, no markdown, no persuasive copy generated by us. That's exactly the shape ClawHub's Skill Cards and SkillSpector scanner want to see — a labeled data field with nothing hidden in it passes review; a field that tries to talk the model into anything would not.

Why there is no LiteLLM adapter

LiteLLM's hooks (pre_call, post_call_success, etc.) operate on the model request/response stream itself — they exist to let a proxy rewrite what the model sees or says. Wiring sponsored content in at that layer would mean injecting it into the model's input or output without the model (or, more to the point, the end user reading the output) ever being able to tell it wasn't part of the original response. That's undisclosed output tampering — the exact failure mode this SDK's fail-open, label-always-"Sponsored", data-not-directive design exists to prevent.

Tool-result decoration belongs in the tool layer, not the model layer — which is what the FastMCP, LangChain, CrewAI, and MCP-TS adapters above all do. LiteLLM-as-MCP-gateway (routing MCP tool calls through LiteLLM's proxy, where our existing MCP-layer adapters would apply unchanged) remains a partnership target — see the Task 22 brief — but there is no LiteLLM request/response hook adapter in this SDK, and there will not be one.

Runtime owners: response suffix

Everything above decorates a tool result — a data field a model may or may not choose to surface. Runtimes that own the final response surface (a chat bot, a WhatsApp/Telegram agent, a self-hosted assistant like OpenClaw, a Hermes-style pod) have a second option: append a disclosed, human-readable suffix to the message the user actually sees.

from lulu_ads import LuluAds, format_suffix

sponsored = ads.sponsored_slot_sync(context={"tool": "search_flights"})
final_message = model_output + format_suffix(sponsored)
import { LuluAds, formatSuffix } from "lulu-ads";

const sponsored = await ads.sponsoredSlot({ context: { tool: "search_flights" } });
const finalMessage = modelOutput + formatSuffix(sponsored);

format_suffix/formatSuffix returns "\n\n— Sponsored: {text} → {url}", or "" when there's nothing to show (None/null, or a malformed sponsored object) — always safe to concatenate unconditionally.

This is deliberately harness code, not a model instruction: the suffix is appended by your application after the model has already produced its final output, with a plain string concatenation, not by asking the model to write an ad. The model never sees the sponsored object, never decides whether to include it, and never phrases it.

If you don't own the render surface — you're a tool provider, not the runtime showing the final message to the user — there is no suffix path. Ship the data field (via one of the adapters above, or the "Any agent runtime" contract) and let the host decide whether and how to render it. Do not attempt to smuggle a suffix into tool output text; that's a directive wearing a data field's clothes, and it's exactly what this SDK is built to avoid.

Render kits (roadmap)

The sponsored data field is intentionally render-agnostic — text, URL, disclosure label, nothing else. Per-host native card affordances on top of that same field raise render rates without changing the contract: an MCP Apps widget (already proven in Dali's sponsored-card UI), OpenAI Apps SDK components, and Gemini extension cards are all on the roadmap. None of them ship code in this v0 — the data field above is stable and forward-compatible with all of them; a render kit is purely additive when it lands.