lulu-ads
The monetization layer for the agent economy.
Monetize your MCP server or agent tool with one labeled sponsored line.
Quickstart · Integrations · Supported hosts · Supported surfaces · stdio servers · Guarantees · API contract · Hosted docs · Blog · Become a publisher
70% to publishers · CPA only · 800ms fail-open · 0 prompt injections, by design
↑ live rendered sponsor card — real rotating ad demand, refreshes every ~60s. Any claimed listing can embed this in its own README.
Lulu Ads attaches a disclosed, labeled data field to your tool's own result. The host model — Claude, Cursor, any agent — decides on its own judgment whether it's relevant enough to surface. We never instruct it to.
{
"sponsored": {
"label": "Sponsored",
"text": "Direct flights TLV–BKK from $412",
"url": "https://ads.getlulu.dev/c/9f2a1c"
}
}
Sponsored — Direct flights TLV–BKK from $412 ads.getlulu.dev/c/9f2a1c
Zero-friction start — add the MCP server and let your agent do the rest:
claude mcp add --transport http lulu-ads https://ads.getlulu.dev/mcp
monetize my server
It'll fetch the right integration guide for your stack, register a publisher (with your consent), wire up the one-liner, and verify a slot went live.
If it renders and gets clicked, you earn 70% on CPA. If it doesn't — nobody pays, nothing breaks.
No prompt injection — we ship a data field; the host decides.
Quickstart
Python
pip install lulu-ads
# or: uv add lulu-ads
# or: poetry add lulu-ads
from lulu_ads import LuluAds
ads = LuluAds(publisher_id="pub_123", api_key="lk_...")
result = search_flights("TLV", "BKK", dates)
result["sponsored"] = await ads.sponsored_slot(
context={"tool": "search_flights", "category": "travel.flights"},
)
return result
FastMCP servers get it in one call — credentials come from the environment,
and every tool (present and future) gets both the plain sponsored data
field AND, in hosts that support it (e.g. Claude.ai), the rendered
Sponsored-card widget, automatically:
export LULU_ADS_PUBLISHER_ID=pub_123
export LULU_ADS_API_KEY=lk_...
from lulu_ads.enable import enable_lulu_ads
enable_lulu_ads(mcp, endpoint_url="https://my-server.example.com/mcp")
Just want the data field, no widget? The plain middleware still works on its own:
mcp.add_middleware(LuluAdsMiddleware())
TypeScript
npm install lulu-ads
# or: pnpm add lulu-ads
# or: yarn add lulu-ads
# or: bun add lulu-ads
import { LuluAds } from "lulu-ads";
const ads = new LuluAds({ publisherId: "pub_123", apiKey: "lk_..." });
result.sponsored = await ads.sponsoredSlot({ context: { tool: "search_flights" } });
MCP servers built on the official TS SDK get the same one-call treatment — data field AND widget on every tool, automatically:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { enableLuluAds } from "lulu-ads/mcp";
const server = new McpServer({ name: "my-server", version: "1.0.0" });
await enableLuluAds(server, { endpointUrl: "https://my-server.example.com/mcp" });
No publisher ID yet? See docs/quickstart.md — three
ways to get one, none of them gated on the others.
Tiered pricing (ads on a free tier, ad-free on paid)? Pass enabled —
your own subscription check decides the value, no separate deployment or
scattered conditionals needed:
result["sponsored"] = await ads.sponsored_slot(
context={"tool": "search_flights"},
enabled=user.tier != "paid", # False resolves instantly, no network call
)
result.sponsored = await ads.sponsoredSlot({
context: { tool: "search_flights" },
enabled: user.tier !== "paid",
});
Framework integrations
Result widgets — templates for your OWN tool output (0.8.5)
One widget, every host. The frame speaks three bridges — stable MCP
Apps (ui/initialize, 2026-01-26), the draft-era fallback, and ChatGPT's
window.openai — and the SDK registers both template keys
(_meta.ui.resourceUri + openai/outputTemplate) and both CSP dialects
automatically. Verified rendering live on claude.ai and ChatGPT, including
the rendered-impression beacon (impressions count what a human actually
saw, never mere API output). After upgrading, refresh your connector in
ChatGPT's plugin settings — it caches tool metadata.
The SPONSORED strip's own format is separately configurable via
sponsor_template= (independent of template=, this widget's own body
layout):
register_result_widget(
mcp, "search_flights",
template="table-card",
mapping={"rows": "flights", "columns": [...]},
endpoint_url="https://my-server.example.com/mcp",
sponsor_template="flip-card", # or "carousel", "scratch-reveal" -- "card" is the default
)
Supported hosts
The plain sponsored JSON field is the always-on baseline: it ships on
every tool result, on every MCP host, because it's nothing more than an
extra key on a dict — no host-specific support is required for it to work,
and the model decides on its own whether to surface it. The rendered
MCP Apps widget above that is additive, and only paints where a host has
actually implemented the ui/initialize handshake. This table says exactly
which is which per host, based on our own production verification where we
have it and a fresh survey (2026-08-25) everywhere else — a host only gets
a "Live" widget status here when we've confirmed it ourselves or the
vendor has published concrete, checkable implementation detail, never on a
generic "should work" assumption.
Hosts we've looked at — logos are not a support claim on their own; read the Status column below for what each one actually does. (Continue.dev is in the table but not the strip above: it's a discontinued product, kept here only for completeness.)
Why some hosts need zero extra code and others don't
Different hosts converged on different conventions for how a tool advertises "I have a renderable UI" — and where a host's convention differs from the one we shipped first, discovery silently fails before rendering ever gets a chance to run (that was the CopilotKit gap PR #8 fixed, 2026-08-25). We track each convention we've confirmed and register against all of them on every widget-capable tool — additive only, never a rewrite, so a host that doesn't recognize one signal just ignores it. That's the practical reason Claude and VS Code render with zero extra code (they share a convention) while CopilotKit needed a targeted fix, and it's why "no evidence found" in the table below means exactly that — evidence not found yet, not evidence of absence.
Anything else not listed above (LangGraph Studio, custom in-house agent
harnesses, and every host we simply haven't looked at yet): unknown / not
yet investigated — the plain sponsored field is designed to fail open and
degrade gracefully on any of them regardless, per the Guarantees
below. If you've verified rendering on a host not in this table, open an
issue or PR — this list is meant to stay honest, not exhaustive.
This table is specifically about widget rendering in chat hosts. For the fuller picture — agentic SDKs/frameworks (most reach the data field via MCP passthrough, no dedicated adapter needed), response-suffix runtimes (WhatsApp/Telegram/Slack/SMS bots, background agents), AI app builders (not yet evaluated), and MCP hosting/registries (irrelevant to this SDK by design) — see Supported surfaces.
Don't design UI. Pick one of four predefined, host-native-quality result
widgets and map your tool's structuredContent fields into it — the frame,
design tokens, and the disclosed SPONSORED strip are fixed by the SDK. The
strip renders only when a live sponsored payload exists, always at the
bottom, always labeled, with the advertiser's logo (letter-tile fallback
when none loads). Your body can't remove or restyle it.
Templates: stat-card (big value + chips + optional condition-keyed
atmospheric background), table-card (headed rows, mono numerics, best-row
highlight), notice-card (verdict glyph + detail rows), carousel-card
(3–8 swipeable option cards).
from lulu_ads.widgets import register_result_widget
# after your @mcp.tool definitions:
register_result_widget(
mcp, "get_weather",
template="stat-card",
mapping={
"eyebrow": "location.name",
"value": {"path": "temperature_c", "suffix": "°"},
"condition": "conditions",
"chips": [{"path": "humidity_pct", "prefix": "💧 ", "suffix": "%"}],
"atmosphere": "weather_code", # WMO code or words -> sky gradient
},
endpoint_url="https://my-server.example.com/mcp",
)
TypeScript: import { registerResultWidget } from "lulu-ads/widgets" —
same templates and mapping shape; spread the returned _meta into
server.registerTool(...). Mapping entries are dot-paths or
{path, prefix, suffix}; a body_html= escape hatch accepts custom
markup composed from the .lw-* primitives for the cases the templates
don't cover. Calling it for a tool deliberately replaces the generic
sponsored card from enable_lulu_ads on that tool — the sponsored data
still flows and renders in the widget's own strip.
Widget rendering (MCP Apps UI)
The plain sponsored field always ships and always works — some hosts
render it as a card purely on the model's own judgment, no instruction
anywhere. For hosts that support the MCP Apps
extension (io.modelcontextprotocol/ui), enable_lulu_ads / enableLuluAds
(see Quickstart above) already register an actual rendered widget and
attach it to every tool automatically — you don't need anything below this
line for that. It exists as a distinct step at all because
register_sponsored_widget() requires your server's exact public endpoint
URL, which LuluAdsMiddleware/withLuluAds alone have no way to know.
Prefer per-tool control (a different widget on different tools, or
only some tools get one)? Use the lower-level building block directly
instead of enable_lulu_ads:
from fastmcp import FastMCP
from lulu_ads.widget import register_sponsored_widget
mcp = FastMCP("my-server")
sponsored_app = register_sponsored_widget(
mcp,
endpoint_url="https://my-server.example.com/mcp", # your public MCP connector URL
text="Save 15% at checkout",
url="https://example.com/deal",
logo="https://example.com/logo.png", # optional, see "Logos" below
)
@mcp.tool(app=sponsored_app)
def search(...): ...
Same helper, official TS SDK, for MCP servers built in Node instead of Python
(registration is async — it may fetch a logo before returning):
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerSponsoredWidget } from "lulu-ads/widget";
const server = new McpServer({ name: "my-server", version: "1.0.0" });
const appMeta = await registerSponsoredWidget(server, {
endpointUrl: "https://my-server.example.com/mcp", // your public MCP connector URL
text: "Save 15% at checkout",
url: "https://example.com/deal",
logo: "https://example.com/logo.png", // optional, see "Logos" below
});
server.registerTool("search", { ...appMeta }, handler);
Card formats. template= picks the widget's visual layout — a
registration-time choice, same shape as register_result_widget's own
template= (Result widgets, above). Defaults to "card" (the layout
shown above); pass a different name for a different format:
sponsored_app = register_sponsored_widget(
mcp,
endpoint_url="https://my-server.example.com/mcp",
text="Save 15% at checkout",
url="https://example.com/deal",
template="banner", # full-width horizontal strip -- see Templates below
)
const appMeta = await registerSponsoredWidget(server, {
endpointUrl: "https://my-server.example.com/mcp",
text: "Save 15% at checkout",
url: "https://example.com/deal",
template: "banner",
});
An unrecognized template raises immediately (before any network call,
e.g. the logo fetch) rather than silently falling back — same fail-fast
contract register_result_widget's template validation already has.
"hero" accepts one more option, background_image/backgroundImage — a
URL fetched once at registration time and inlined the same way logo is:
sponsored_app = register_sponsored_widget(
mcp,
endpoint_url="https://my-server.example.com/mcp",
text="Save 15% at checkout",
url="https://example.com/deal",
template="hero",
background_image="https://example.com/hero-bg.jpg", # optional -- falls back to the gradient
)
Templates:
More formats land here as they ship (carousel, comparison, testimonial,
countdown, quiz, video — see the gallery tracked in Linear). Every
template shares the exact same guarantees: skeleton-on-load, the
disclosed Sponsored label, the "Powered by Lulu Ads" footer, and
fail-open on missing/malformed data — none of that is renegotiable per
template.
This is also what enable_lulu_ads/enableLuluAds do internally, on your
behalf, for every tool — found live (2026-07-26) that getting this step
right per-tool is easy to forget: our own dogfood server had it wired onto
exactly one tool by hand, and every tool added since then silently never
got it. If you want automatic coverage with no per-tool step, use
enable_lulu_ads/enableLuluAds instead of this directly.
Ships a floating, rounded, gradient card (same visual system as
getlulu.dev) with a disclosed Sponsored label —
still just markup, never a directive. Three host-specific quirks this
handles for you: Claude requires an undocumented _meta.ui.domain value
derived from your endpoint URL (self-computed here, not a credential), the
widget must send a ui/notifications/initialized handshake on load or
Claude keeps the iframe hidden, and logos are inlined rather than linked
(next section) so the widget sandbox's own CSP can't silently drop them.
Verified live against production
(dali.getlulu.dev/mcp, ext-apps#671),
current as of 2026-07-19 — Claude's own rendering of MCP Apps widgets was
broken platform-wide before that fix landed, so treat any "should render"
claim (including this one, elsewhere) as unverified until you've checked
it live in your own host.
The widget shows a shadcn <Skeleton> immediately on load, then swaps to
real content only once a live tool call arrives — text/url/logo
passed to register_sponsored_widget() are not rendered as initial
content; only label/cta/accent* from those options are actually used
by the live path (as defaults for fields the wire payload omits, and as
the static per-integrator brand theme). On every real tool call, the
widget listens for the MCP Apps host's own ui/notifications/tool-result
push (a fresh iframe is mounted per call, not reused — "per call, not per
tool" is a protocol guarantee, nothing had to be built server-side to get
it) and renders with that call's structuredContent.sponsored — live,
per-call ad content, not a fixed payload baked in once at registration. A
host that never sends the notification keeps showing the skeleton
indefinitely (not a fallback ad — see the open gap noted in
js/widget-src/src/mcpBridge.ts's InitialOptions docstring); a call
with no sponsored field (the normal fail-open case) renders an empty
card with only the footer. Card, skeleton, and the "Powered by Lulu Ads"
footer are one compiled React/shadcn bundle shared byte-for-byte between
the Python and TypeScript SDKs (js/widget-src/, checked in, embedded by
both languages), and the footer always renders inside that same
persistent card shell, in every state.
Logos
logo takes a URL to fetch a brand mark from, not a URL to embed
directly — pass it and the SDK downloads the image right there at
registration time and inlines it into the widget as a data: URI. This
isn't incidental: the MCP Apps spec has hosts enforce img-src 'self' data: <resourceDomains> inside the widget's sandboxed iframe, and unless you
separately declare your logo's domain in that resource's CSP config, a
<img src="https://your-cdn.com/logo.png"> gets silently dropped — no
error anywhere, the card just renders with a blank slot forever, in every
host. data: URIs are always allowed under that same rule, so fetching and
inlining server-side sidesteps the whole failure mode — there is no CSP
config for you to get right or forget.
A bad or unreachable logo never breaks registration — it's skipped (with
a warning log) and the card renders without one, same as leaving logo
unset. Only image/png, image/jpeg, image/svg+xml, image/webp, and
image/gif are accepted, capped at 200KB (the logo renders at 28×28 in the
card — there's no reason to ship more than that over the wire).
CLI rendering
Terminals have no widget surface — the model's own text is the only
output there is, and it's genuinely the model's judgment call whether to
mention the disclosed line at all (never forced, ever — see Guarantees).
LuluAdsMiddleware / withLuluAds detect known CLI clients via the MCP
clientInfo.name sent at initialize (currently: claude-code, verified
live) and, when connected from one, append a bordered plain-text card to
content[] in addition to the plain field — still just data, still zero
instruction to the model, just formatted so it reads as a distinct block
instead of a plain sentence if the model does choose to relay it:
╭─ Sponsored ────────────────────────────────────╮
│ Search 700+ airlines in one place — Kiwi.com │
│ finds routes other search engines miss. │
╰─ via Lulu Ads ─────────────────────────────────╯
→ https://ads.getlulu.dev/c/9f2a1c
Known limitation, disclosed here rather than glossed over: some MCP
clients don't forward every content[] block to the model when
structuredContent is also present on the same result — an open
client-side bug in Claude Code, twice reported and twice closed without a
fix (#55677 →
consolidated into
#45575 →
auto-closed stale). Live-tested against Claude Code specifically
(2026-07-21): with structuredContent present (the shipped default), the
boxed card never reaches the model, but the plain sponsored field still
does — the model reliably surfaces it as an honest, labeled
"Sponsored: ..." line in its own words, 3/3 runs, no issues.
cliTextMode — opt-in fix for the client bug above
We also tested the obvious-looking fix — omit structuredContent so
content[] has nothing competing with it — and the result depended
entirely on what else was in content[]:
- Ad alone, no real tool data alongside it: the card arrives every time, but the model flags it as a suspected prompt-injection attempt and warns the user off it, 3/3 runs. Worse than not showing it.
- Ad alongside a real, complete rendering of the tool's own result: the card arrives every time, the model treats it as an ordinary disclosed ad and mentions it neutrally, 3/3 runs. No suspicion.
So the fix is real, but conditional on your tool's own behavior — which this SDK can't verify for you, hence opt-in, off by default:
mcp.add_middleware(LuluAdsMiddleware(cli_text_mode=True))
withLuluAds(server, ads, { cliTextMode: true });
Turn this on only if your tool's content[] already contains a
complete, human-readable rendering of the result on its own — not a
placeholder like "see structuredContent". When on, detected CLI clients
with no declared outputSchema get structuredContent stripped so
content[] (your tool's own text + our card) reliably reaches the
model. Tools that declare an outputSchema are never touched by this —
stripping structuredContent there would break client-side schema
validation outright (confirmed: fastmcp.exceptions.ToolError
"outputSchema defined but no structured output returned"), which is a
broken tool call, a strictly worse outcome than a dropped card. This SDK
never drops structuredContent.sponsored on schema'd tools to chase card
visibility, cliTextMode or not.
Until the upstream client bug is fixed, treat the CLI card as "renders reliably once you opt in and your tool qualifies, on top of a disclosure that already works either way" — same verify-in-your-own-host caveat as the widget path above.
stdio servers
Everything above the "Widget rendering" section works unmodified on a
stdio-transport server — the SDK is a library your code imports and calls;
it doesn't know or care how your own server talks to its clients. The
plain sponsored data field (LuluAdsMiddleware / mcp.add_middleware(),
withLuluAds(server)) takes no endpoint argument and makes a plain
outbound HTTPS call to ads.getlulu.dev/slot — same request whether your
process is a long-running remote server or a npx/uvx-launched local
one. The CLI text-card path (see "CLI rendering" above) is the common
real-world case here: Claude Code launches most of its MCP servers over
stdio, and that's exactly the client this SDK already detects and renders
a disclosed plain-text card for.
The rendered MCP Apps widget is the one piece that doesn't apply —
enable_lulu_ads/enableLuluAds and the lower-level
register_sponsored_widget/registerSponsoredWidget all require a real
endpoint_url, hashed into Claude's undocumented _meta.ui.domain value
for the widget's iframe CSP. That's not a Lulu Ads limit; MCP Apps'
ui/initialize handshake is a network protocol between the host and your
server's own HTTP endpoint, and a stdio server has none. If your server is
stdio-only, call LuluAdsMiddleware/mcp.add_middleware() directly (or
withLuluAds(server) in TypeScript) — never enable_lulu_ads — and you
get the data field plus the CLI text-card, with nothing to configure for
the endpoint you don't have.
Publisher-side note: the marketplace's automatic "monetized" badge
currently matches a listing to your registered publisher account by
remote_url — a stdio listing has none, so it won't auto-badge even once
you've integrated the SDK and are earning. The SDK/earnings path itself is
unaffected; this is purely a marketplace-listing display gap, being
tracked separately.
Guarantees (enforced in code, not just promised)
Why not just…
…tell the model to mention a sponsor in its reply?
Display instructions get MCP servers delisted by registries that scan for
injected directives. We ship a plain data object — label, text, url —
with no field, anywhere in the contract, that tells a model how to render or
phrase anything.
…count impressions and charge per view? An "impression" only exists if a model actually rendered it, and that's unverifiable from the server side — easy to game, hard to audit. We charge CPA only, on a click that redeems a signed, server-verified token. Payment maps to a real user action, not a claim.
…scan the conversation to target better?
Reading transcripts to target ads is a privacy trap: everything a user says
becomes ad-targeting data. We accept six allowlisted context keys — tool,
category, query, route, locale, country — stated intent for this
call only. No transcripts, no profiles, no PII fields exist in the schema.
How it works
tool call
│
▼
your tool's own result
│
▼
POST /slot (1500ms cap — 3000ms when classifying a raw prompt — allowlisted context only)
│
▼
labeled data field { label: "Sponsored", text, url } ← attached, never injected
│
▼
host / model judgment → renders it, or doesn't — not our call
│ user clicks
▼
GET /c/{token} → signed redirect, click recorded
│
▼
advertiser's affiliate rails → POST /postback on conversion
│
▼
70% publisher / 30% Lulu, on the ledger. Earnings accrue to your balance from the first audited conversion — cash out from $100.
Full wire-level detail: docs/contract.md.
Docs: https://getlulu.dev/docs · Quickstart · API contract · Integrations · Publisher signup · Quality gate: Dali · MIT
Changelog
-
0.9.19 — Fixes what the SDK reported about itself.
SDK_VERSIONin the TypeScript client was a hardcoded"0.9.14"while the package moved through four releases — and it is sent to/slotassdk_version, so every TypeScript publisher reported 0.9.14 and upgrade telemetry could not tell who had actually upgraded. Now correct, and pinned by a test againstpackage.jsonso a release that forgets it fails the suite instead of quietly corrupting the data. The Python client never had this bug; it importslulu_ads.__version__.Docs, no behaviour change:
js/src/skybridge.ts's header still described the adapter as_meta-only, which stopped being true in 0.9.15 — it now carries the same four-case landing table asdocs/integrations.md. Andpython/README.md, which is what PyPI renders, had no sync with the root README and had fallen four releases behind, so the package page described Skybridge using that same retired claim. Both package READMEs are now fed byscripts/sync-readmes.sh. -
0.9.18 — Skybridge 2.x support, and a guard against its one silent failure.
skybridge 2.x moved protocol-middleware wiring out of
McpServer.connect()into the app's request path, so the attach point differs by major:// 2.x — inside the app handler new Skybridge({ name, version, handler: (server) => { withLuluAdsSkybridge(server); return server.registerTool({ name: "t" }, handler); }}); // 1.x — on the server you connect yourself withLuluAdsSkybridge(server);Using the 1.x shape on 2.x registers the middleware into a chain nothing applies: no error, no ad, nothing to debug. The adapter now detects that (its middleware always runs before a tool handler, so a tool executing while it never ran means it isn't wired) and warns once with the correct snippet. Warned, never thrown.
Verified against both majors — 106/106 on
[email protected]and@2.0.0, plus an end-to-end example run against the published package. Note that on 2.x an idiomaticcontent-only tool carries the ad on_metaalone, so a view should readstructuredContentfirst and fall back to_meta; the reference view inexamples/skybridge-views/does exactly that. -
0.9.15–0.9.17 — Skybridge delivers at all, and the SDK reports who is calling.