The full upstream README, mirrored here for reference. Install config, tool schemas, adoption signals, and an original overview live on the Lulu Ads — Publisher Concierge listing page.
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.
| What the SDK ships (a data field) | What the host renders (its choice) |
|---|---|
|
Zero-friction start — add the MCP server and let your agent do the rest:
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.
Python
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:
Just want the data field, no widget? The plain middleware still works on its own:
TypeScript
MCP servers built on the official TS SDK get the same one-call treatment — data field AND widget on every tool, automatically:
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:
| Stack | One-liner | Docs |
|---|---|---|
| FastMCP (Python), data + widget | enable_lulu_ads(mcp, endpoint_url=...) | → |
| FastMCP (Python), data only | mcp.add_middleware(LuluAdsMiddleware()) | → |
| MCP TS SDK, data + widget | await enableLuluAds(server, { endpointUrl }) | → |
| LangChain / LangGraph (Python) | middleware=[LuluAdsAgentMiddleware()] | → |
| CrewAI (Python) | lulu_crewai.install() | → |
| MCP TS SDK, data only | withLuluAds(server) | → |
| Skybridge (TypeScript) | withLuluAdsSkybridge(server) | → |
| Runtime owners (chat bots, WhatsApp/Telegram agents) | model_output + format_suffix(sponsored) | → |
| Any other runtime / language | sponsored_slot(context) over the raw contract | → |
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):
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.)
| Host | MCP tool-calling | Rendered widget | Status |
|---|---|---|---|
| Claude (claude.ai) | Yes | Yes | Live, verified in production — real rendered-impression beacons observed on live traffic. |
| ChatGPT | Yes | Yes | Live, verified in production. |
CopilotKit (@ag-ui/mcp-apps-middleware) | Yes | In progress | Fix in review, PR #8, unverified end-to-end — a tool-discovery bug was found and fixed, but the fix has not been tested against a full chat UI (no LLM available in that pass) and is not yet released to npm/PyPI. Do not treat CopilotKit as supported until that PR lands and is verified live. The plain sponsored field is unaffected by this bug and already flows today. |
| VS Code (native MCP + GitHub Copilot Chat agent mode) | Yes | Reported live | Microsoft's own 2026-01-26 blog post and current docs describe VS Code as "the first major AI code editor with full MCP Apps support" and document concrete, checkable implementation detail (sandboxed iframes, CSP domain config, the ui/initialize handshake, the App SDK) — credible, but this is a vendor claim we have not independently reproduced ourselves. Plain MCP tool-calling (Copilot Chat agent mode) has been GA since v1.102. |
| Cursor | Yes | Reported, unverified | Named as an MCP Apps implementer on the upstream modelcontextprotocol.io Extension Support Matrix — a third-party listing, not Cursor's own docs, so weaker evidence than VS Code/Goose's vendor-published detail above. We actually tried to verify this ourselves live (2026-08-25) and got blocked before reaching the test: Cursor's free-tier Agent usage cap (2 prompts) hit before a real tool call went through. Real attempt, real blocker, still unconfirmed — not a claim we're dodging. |
| Goose (Block / AAIF) | Yes | Live (experimental) | Goose's own docs confirm the ui/initialize handshake and sandboxed-iframe rendering (Goose Desktop 1.19.1+), but explicitly flag it as "experimental and based on a draft specification; the implementation is minimal and may change." Treat as live-but-unstable, not a guaranteed render target. |
| Grok (xAI) — grok.com connectors, Grok Build CLI, xAI API Remote MCP Tools | Yes | No evidence found | MCP-capable across all three xAI surfaces (plain tool discovery + calling), but no official doc, changelog, or third-party host-support matrix credits Grok with the MCP Apps UI extension as of this survey. The sponsored data field still flows and still renders purely on the model's own judgment via the always-on JSON fallback — the rich widget just has nothing to render into. |
| Windsurf (Codeium) | Yes | No evidence found | Windsurf's own docs state it supports "an MCP server's tools, resources, and prompts" only; every third-party MCP Apps host-support list we found omits it. Sponsored data field still works via the always-on JSON fallback. |
| Cline (VS Code extension) | Yes | No evidence found | Mature MCP client (tools, resources, prompts, a built-in MCP marketplace); no ui/initialize, ui://, or iframe-rendering code found anywhere in the repo. Sponsored data field still works via the always-on JSON fallback. |
| Zed editor | Yes | No evidence found | Zed's own docs state plainly it "currently supports MCP's Tools and Prompts features" — no Resources-based UI rendering. Sponsored data field still works via the always-on JSON fallback. |
| Continue.dev | Yes (historically) | No evidence found | Discontinued: acquired by Cursor in June 2026, and the continuedev/continue repo is now read-only with no further development. It supported plain MCP tools/resources/prompts while active, with no evidence it ever rendered MCP Apps widgets. Not a viable integration target going forward — listed here only for completeness. |
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).
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.
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:
Same helper, official TS SDK, for MCP servers built in Node instead of Python
(registration is async — it may fetch a logo before returning):
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:
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:
Templates:
template= | Layout | Notes |
|---|---|---|
"card" (default) | Stacked: label line, then logo + text/CTA row | The original hand-built layout — every existing integrator gets this unchanged. |
"banner" | Single horizontal row: label, logo, text/CTA all inline | Better fit for wide-but-short layouts (VS Code sidebar, wide CLI panes) where a stacked card wastes vertical space. |
"hero" | Full-bleed background image (or the shared gradient) with logo/text/CTA anchored over a legibility scrim | background_image is optional and integrator-supplied — not yet something ads-server's automatic ad matching can select on its own. |
"flip-card" | Tap/click flips a 3D card — front shows the label, back reveals text + CTA | The disclosed label is always on the front; only the offer's own details are behind the flip. |
"scratch-reveal" | A scratch-off canvas layer over the offer | Auto-reveals after 3s regardless of interaction — the offer is identical either way, this is decoration, never gated content. |
"spin" | A decorative spin-and-settle flourish on the logo badge | Deterministic — always the one real offer, never a variable-outcome mechanic. |
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.
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).
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:
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 aboveWe 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[]:
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:
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.
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.
| Guarantee | How |
|---|---|
| A tool call can never break because of ads | every failure path returns None/null; hard 800ms wall-clock timeout (3000ms when the call implies server-side classification) |
| Always disclosed | label: "Sponsored" is set by the SDK, never sourced from the response body |
| No prompt injection, ever | we ship a data field; there is no display instruction anywhere in the contract |
| No PII leaves your server | context is filtered against an allowlist client-side, before any request is built |
| Quality-gated | every creative passes Dali scoring (≥70) before it can fill a slot |
| Intent, not identity | targeting uses this call's stated context only — no user profiles, no cross-session ID |
| Misconfigured? Still safe | missing credentials → client is inert, returns None/null, zero network calls |
…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.
Full wire-level detail: docs/contract.md.
Docs: https://getlulu.dev/docs · Quickstart · API contract · Integrations · Publisher signup · Quality gate: Dali · MIT
0.9.19 — Fixes what the SDK reported about itself.
SDK_VERSION in the TypeScript client was a hardcoded "0.9.14" while the
package moved through four releases — and it is sent to /slot as
sdk_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 against package.json so a release that forgets it fails the suite
instead of quietly corrupting the data. The Python client never had this bug;
it imports lulu_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 as docs/integrations.md. And
python/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 by
scripts/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:
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 skybridge@1.4.1 and @2.0.0,
plus an end-to-end example run against the published package. Note that on
2.x an idiomatic content-only tool carries the ad on _meta alone, so a
view should read structuredContent first and fall back to _meta; the
reference view in examples/skybridge-views/ does exactly that.
0.9.15–0.9.17 — Skybridge delivers at all, and the SDK reports who is calling.
On 2.0.1: these changes were briefly published as
2.0.1before being retired — the major stranded every existing^0.9.xdependency range, which meant nobody would have received them without hand-editing their package.json. It is yanked on PyPI and superseded on npm; use the 0.9.x line. Nothing was lost, only renumbered.
Skybridge. withLuluAdsSkybridge used to
attach sponsored to _meta only. Nothing on the Skybridge path reads
_meta — enableLuluAds can't register a widget there (Skybridge's
registerViewResource is private) and there's no CLI card — so the slot
was fetched, logged, and surfaced to nobody. Probing the live result
shape showed content[] arrives empty on Skybridge with
structuredContent populated (the inverse of the official SDK), so
structuredContent is what a view renders from. It now lands there:
| tool shape | sponsored goes to |
|---|---|
no outputSchema | structuredContent and _meta |
has outputSchema | _meta only — an unlisted field would fail the client's validation |
registered before withLuluAdsSkybridge | _meta only — schema status unknown, safe default |
The schema flag is captured at registration time via a 2-arg
registerTool wrapper, since mcpMiddleware can't see outputSchema.
Client identity. Every adapter now forwards the MCP client name as
context.client, so ads-server can tell a real agent from a directory
crawler. "client" joins the context allowlist. No integrator code
changes.
Two silent breakages fixed on fastmcp 4.x / MCP SDK v2. clientInfo
was renamed client_info; the old spelling raised inside a bare
except, so _connected_client_name returned None on every 4.x host
— which also silently disabled the CLI sponsored card
(is_cli_client(None) is False). And 4.x negotiates server/discover
instead of initialize, so on_initialize never fired and the async
warm-up stopped, handing back the cold-start latency the tiered-timeout
work exists to avoid. Both accessors now try new-then-old spellings, and
on_discover runs alongside on_initialize. Python suite: 144 passed /
0 failed on both 4.0.5 and 3.4.4 (was 8 failing). JS: 104/104.
Upgrading: staying on the 0.9.x line is deliberate — a ^0.9.x range
reaches 0.9.18, so npm update lulu-ads / pip install -U lulu-ads is
enough and no dependency range needs editing. That propagation is exactly
what the retired 2.0.1 would have cost.
See examples/skybridge_server.ts and
npm run verify:skybridge, which asserts all of the above against a
real server over an in-memory transport.
0.9.14 — An ad's per-call template (LUL-64, when /slot reports one)
now wins over the template=/template: you registered with, for the
standalone sponsor-card widget (register_sponsored_widget()/
registerSponsoredWidget()). If it's absent or not a template this
bundle build recognizes, it falls back to your registration-time
default as before, then to "card". Behavior change on upgrade, no
code change required: if you registered with a non-default
template= (e.g. "hero"), ads that carry their own live template
will now render in that template instead, with no opt-out. This is
intentional — an admin picking a template for a specific ad is meant to
be more specific than a blanket integrator default — but it does mean
your widget's visual output can change after upgrading even though you
changed no code. Also fixes a lookup hazard where a prototype-chain
name (e.g. "constructor") in a live template value could be
misread as "recognized" and crash the render after the impression
beacon had already fired.
0.9.13 — Fixed a rendered-impression billing gap (LUL-71) in the
standalone sponsor-card widget (register_sponsored_widget()/
registerSponsoredWidget() — the React-built card, banner, flip-card,
scratch-reveal, spin, and hero templates): the wire payload's imp_url/
impUrl field was parsed and exposed by both clients (Sponsored.impUrl
in the Node SDK, imp_url in the Python client) but silently dropped by
the widget bundle's own message parsing before it ever reached the
React components — so the rendered-impression beacon (a 1px <img> that
confirms a human actually saw the ad, which CPM is billed against) never
fired for this widget at all, for any template, since it shipped. Fixed
centrally in App.tsx's loading→"loaded" transition — the same moment
every template's disclosed content becomes visible, including
flip-card's front teaser (fires before the user ever flips it, matching
register_result_widget()'s footer strip's own already-correct
behavior) and scratch-reveal's covered content (fires before anyone
scratches). register_sponsored_widget()/registerSponsoredWidget()
also now declare the ads.getlulu.dev resource/connect CSP domains the
beacon pixel needs to not be silently blocked by a host's default
img-src 'self' data: policy — the same declaration
register_result_widget() already had. Verified live: the beacon fires
a real HTTP request the instant a tool-result with imp_url arrives,
confirmed via a local server's access log, not just unit tests.
0.9.12 — Result-widget footer sponsor_template= (LUL-69) redesigned
from a slim single-line strip into a cover card: a colorful cover band —
a real sponsor image when the live payload supplies cover_image_url/
coverImageUrl, an animated gradient otherwise — with the logo tile
overlapping the seam into the body, bigger type, and a real pill CTA
button. "flip-card"'s teaser now carries the same cover identity
instead of a bare label. "spin" is removed: a coin-flip scaleX
animation on a small, often-flat letter-tile logo turned out visually
illegible in practice (mid-animation it shrinks to a near-invisible
sliver against a busy cover background), caught live rather than
inferred. It's replaced by "carousel" — auto-cycles through three
framings of the same single sponsored payload (a bigger brand logo,
the offer text, then the CTA), never multiple sponsors: this frame only
ever gets one sponsored payload per call, so unlike the standalone
gallery's carousel template there is no multi-advertiser rotation here
(see LUL-49 for that separate, still-blocked, backend question).
"scratch-reveal" is restyled as a lottery-ticket foil stub — gold
metallic gradient, diagonal hatching, a dashed perforation line, a
ticket glyph — and its auto-reveal window is 5s now, not 3s, long enough
to actually register as an interaction; it remains a strictly
deterministic reveal with no "win/lose" state, which is what keeps the
ticket styling out of actual gambling-mechanic territory. The "single
row, no room for a full-bleed image" reasoning that used to also exclude
"hero" no longer holds now that the strip itself is cover-height —
noted as an open follow-up, not resolved by this release.
0.9.11 — register_result_widget()/registerResultWidget() gain
sponsor_template=/sponsorTemplate: (LUL-69): the SPONSORED strip built
into a result widget's footer (stat-card/table-card/notice-card/
carousel-card — a publisher's own tool output, not the standalone
sponsor-card widget) can now pick a visual format too, independent of
template=, this widget's own body layout. "card" (default) is the
original always-visible strip. "flip-card" is a compact "Sponsored"
teaser that crossfades to reveal the real offer on tap — the front
face carries an actual prompt ("Tap to reveal →"), not just a bare
disclosure label, so there's a reason to tap it. "spin" is a
decorative spin-settle flourish on the logo badge, content visible
immediately, never gated (no variable outcomes — see the spin
guardrail below). "scratch-reveal" is a canvas scratch-off layer,
auto-revealing after 3s regardless of interaction — the offer
underneath is identical whether or not anyone scratches. "banner"/
"hero" are deliberately not offered here: this strip is already one
horizontal row (banner would be a no-op) and a full-bleed background
doesn't fit a slim footer bar. This is a different system from the
standalone sponsor-card gallery above (register_sponsored_widget()'s
own template=) — same template names where they overlap, ported
into this frame's own vanilla-JS renderer rather than sharing code,
since the two frames are independent implementations by design.
0.9.10 — Four more templates: "flip-card" (LUL-53, a CSS 3D flip on
tap revealing the offer on the back face — front always shows the
disclosed label first, never hides what this is, only the offer
details), "scratch-reveal" (LUL-52, a canvas scratch-off layer,
auto-reveals after 3s regardless of interaction — the underlying offer
is identical whether or not anyone scratches, this is a reveal
animation, never gated content), "spin" (LUL-54, a decorative
spin-and-settle flourish on the logo badge — deterministic by design, no
wheel-of-fortune-style variable outcomes, since that would be a gambling
mechanic), and "hero" (LUL-48, a full-bleed background image or
gradient with logo/text/CTA anchored over a legibility scrim).
register_sponsored_widget()/registerSponsoredWidget() also gain an
optional background_image/backgroundImage param for "hero" — same
fetch-once-and-inline-as-data:-URI contract logo already has (a
fetch failure just falls back to the shared gradient, never a
registration error). This is integrator-supplied, registration-time
branding, same category as logo/accent* — not yet wired into
ads-server's automatic per-campaign ad matching, which doesn't have an
image field of its own (tracked separately).
0.9.9 — First real entry in the template gallery: template="banner"
(LUL-47), a full-width horizontal strip (label, logo, text/CTA all in one
row) instead of the default "card"'s stacked layout — better fit for
wide-but-short surfaces (VS Code sidebar, wide CLI panes). Same shared
contract as every template: renders inside the same persistent gradient
shell, same disclosed label + "Powered by Lulu Ads" footer, same
skeleton-on-load and fail-open behavior — nothing about those guarantees
is template-specific. No background-image support yet (LUL-47's spec
allows "image or gradient"; a real background-image field needs new
schema on the advertiser side, tracked separately) — uses the existing
accent-token gradient. Docs added: Widget rendering now documents
template= with a live example and a table of available formats.
0.9.8 — register_sponsored_widget() / registerSponsoredWidget() gain
a template= parameter (LUL-46), same shape as register_result_widget's:
keyword-only, defaults to "card" (today's only format, fully backward
compatible — no existing integrator's call site changes behavior), raises
immediately on an unrecognized value (before any network call, e.g. the
logo fetch). This is a registration-time integrator choice, not a
per-call value — matches the static, "baked into the compiled bundle at
registration" behavior register_result_widget's own template already
has, not something that varies per served ad. Foundational only: the
registry/dispatch mechanism in js/widget-src/ now supports looking up a
template by name, but "card" (the existing hand-built layout) is still
the only entry — more templates land in their own follow-up releases as
they're built (banner, hero, carousel, comparison, flip-card,
scratch-reveal, spin, testimonial, countdown, quiz, video). Also fixes a
real, pre-existing drift: the TS SDK's own SDK_VERSION telemetry
constant had been stuck at 0.9.0 since a much earlier release despite
package.json moving on to 0.9.6 — realigned here, and the two
packages' version numbers themselves are now back in sync (the previous
0.9.7 was Python-only; the TS package skips straight to 0.9.8).
0.9.7 (Python only) — CLI/text clients now get a delivery-confirmed
beacon: the normal rendered-impression pixel (imp_url) is fetched by a
rendering client itself the instant it displays the sponsored strip —
terminal/CLI hosts (Claude Code and friends) have no rendering engine to
do that, so every CLI-delivered card was previously invisible in
ad_events, full stop, even on 100%-successful deliveries. The
middleware now fires that same beacon URL itself (fire-and-forget,
non-blocking, tagged src=cli_server) the moment it appends a card to a
CLI client's content[], on both is_cli card-append paths (the
middleware's own fetched slot, and a tool's pre-set sponsored). This
logs as a new, distinct cli_card_delivered event — not
impression_rendered, and never counted toward CPM billing/payout — a
deliberately weaker signal than a real rendered-pixel hit: it proves the
card left the server in the tool response, never that a human actually
saw it. Needs no config; existing imp_url-carrying slots pick this up
automatically. (ads-server: /i/{token} now accepts an optional
src=cli_server query param to log the distinct event type.)
0.9.6 — Docs only: new Supported surfaces
page sorting every agent surface (chat hosts, agentic SDKs/frameworks,
response-suffix runtimes, AI app builders, MCP hosting/registries) by how
it actually reaches the SDK — a direct adapter, MCP protocol passthrough
(no adapter needed once a server has Lulu Ads wired in), the generic
format_suffix contract, or genuinely not yet evaluated — same
evidentiary bar as the rest of this repo. Also adds a "stdio servers"
section clarifying the data-field path needs no endpoint_url and works
unmodified on stdio-transport servers; only the rendered MCP Apps widget
requires one and can't apply to stdio. Also fixes lulu_ads.__version__,
which had drifted to 0.9.0 while the package published as 0.9.5 — same
class of bug as the 0.7.0 entry below, recurred because nothing enforces
the two staying in sync; consider that the next real gap to close here.
0.9.2 (Python only) — Fixed a middleware bug: a tool that sets its
own sponsored field (a documented pattern for e.g. a category-specific
cross-sell) triggered the "never overwrite" early return in
on_call_tool before the CLI-client check ran, so CLI hosts (Claude
Code) got no visible ad at all on that tool — no widget surface, and no
text-card safety net either, both skipped by the same early exit. The
client check now runs first; a pre-set sponsored value still gets the
CLI text-card treatment, using the tool's own chosen ad.
0.9.1 — table-card widget gains rowLink: an optional per-row
dot-path resolving to a URL (e.g. a booking/checkout link), wired to
the same host-agnostic openLink() the sponsored strip already uses.
Rows without a resolvable URL render exactly as before.
0.9.0 — Skybridge (https://skybridge.tech) support:
withLuluAdsSkybridge(server) (lulu-ads/skybridge). Skybridge's
McpServer.registerTool takes a 2-arg (config, handler) shape with
name folded into config, not the official SDK's 3-arg
(name, config, handler) that withLuluAds wraps — reusing withLuluAds
as-is would misread the config object as the tool name. The new adapter
instead uses Skybridge's own mcpMiddleware("tools/call", ...) protocol
hook, verified against the real skybridge@1.4.0 package's shipped types
and a live InMemoryTransport round-trip. Deliberately _meta-only:
the middleware sees the call result but not the tool's registered
outputSchema, so structuredContent is never touched.
0.8.1 — Result-widget template gallery (supersedes 0.8.0, which
briefly shipped on npm with a louder strip design): lulu_ads.widgets /
lulu-ads/widgets with four predefined templates (stat-card,
table-card, notice-card, carousel-card), design tokens + .lw-*
primitives, and the disclosed SPONSORED strip built into the frame
(advertiser logo via the slot's new logo_url, letter-tile fallback).
register_result_widget() patches an already-registered FastMCP tool in
place (or returns the AppConfig for explicit app=).
0.7.4 — Widget: the sponsored card's iframe canvas no longer paints
an opaque white box on dark hosts. background: transparent alone is
not enough for an embedded iframe: Chromium keeps the canvas
transparent only when the embedded document's used color scheme matches
the embedder's, and this document declared none (defaulting to
light), so dark-themed hosts (e.g. claude.ai in dark mode) forced a
white backdrop behind the card. The widget now declares
color-scheme: light dark, which resolves to the user's preferred
scheme — matching hosts that follow it (claude.ai does by default) on
both light and dark themes. Verified empirically against light- and
dark-scheme embedding pages. (Also aligns lulu_ads.__version__, which
had drifted to 0.7.2 while the packages published as 0.7.3.)
0.7.0 — Two real bugs, found live against a real third-party MCP server behind Claude.ai's remote connector, both fixed:
content[] once, from the tool's
original return value, before LuluAdsMiddleware/withLuluAds ever
run — mutating structuredContent alone (the only thing this SDK's
own test suite checked) left content[] permanently stale. Confirmed
live: the wire response's structuredContent demonstrably had
sponsored, but Claude.ai read and reported back from content[],
which didn't. Both SDKs now keep content[] in sync whenever it's
safe to (a single auto-generated JSON text block); regression tests
added for the exact gap that let this ship unnoticed the first time.enable_lulu_ads() (Python) / enableLuluAds() (TS) — one
call that wires both the data field AND the rendered MCP Apps widget
onto every tool automatically, present and future. Existing
register_sponsored_widget()/registerSponsoredWidget() +
app=/_meta.ui per tool still works and is now documented as the
lower-level building block for per-tool control; the gap it left (an
easy-to-forget manual step per tool) is exactly what this closes —
found live on our own dogfood server, which had wired the widget onto
exactly one tool by hand and silently never updated it for tools
added since.0.6.2 — The sponsored card now plays a one-time diagonal light sweep
across itself when it settles into the loaded state (a real ad won) —
pure CSS (.card-shine in js/widget-src/src/index.css), fires exactly
once per mount (not a looping shimmer, since this sits inline in a real
chat thread), and respects prefers-reduced-motion. Skeleton and
no-fill states are unaffected.
0.6.1 — Corrects a stale 0.6.0 published to npm before dist/ was
rebuilt from the merged source (js/ has no prepublishOnly build
step) — 0.6.0 is deprecated on npm pointing here. Also fixes README.md
and both languages' widget.py/widget.ts docstrings, which
incorrectly claimed text/url/logo passed to
register_sponsored_widget() render as a fallback "house ad" until a
live tool-result arrives; they never do — the widget shows the
skeleton indefinitely if a host never pushes it.
0.6.0 — The MCP Apps sponsored widget now shows live, per-call ad
content instead of a fixed house ad baked in at registration time:
rebuilt in React + shadcn/ui (Card, Skeleton, Button), compiled to
a single self-contained bundle shared byte-for-byte by both SDKs
(js/widget-src/). The widget shows a skeleton immediately on load,
then listens for the MCP Apps host's own ui/notifications/tool-result
push — which the spec already delivers once per call, to a fresh iframe
per call, with no server-side change needed — and swaps to that call's
real structuredContent.sponsored data — the widget shows the skeleton
indefinitely if a host never pushes it, not a fallback ad; only
label/cta/accent* from register_sponsored_widget()/
registerSponsoredWidget()'s options are actually used by the live
path. The "Powered by Lulu Ads" footer renders once, immediately, and
is never itself part of the skeleton→card swap. Live-verified against a
real host (claude.ai) with a throwaway test server: skeleton renders
before the tool call resolves, swaps to the real per-call card once it
does, the footer never disappears or reflows during the swap, and two
tool calls in the same turn render two fully independent widget
instances, each showing only its own call's data — confirming the "per
call, not per tool" behavior this feature is built on. (The CTA's
ui/open-link redirect — vs. a raw navigation — was re-confirmed by
static code inspection and this repo's existing unit tests during this
same pass; live click-through capture was attempted but blocked by
browser-automation tooling limits reaching inside the host's
double-sandboxed iframe, not by any observed product failure.)
0.4.0 — Automatic pre-connect on construction for LangChain's
LuluAdsAgentMiddleware, CrewAI's install(), and TypeScript's
withLuluAds (matching the FastMCP LuluAdsMiddleware, which already
had this). Also: FastMCP's LuluAdsMiddleware and LangChain's
LuluAdsAgentMiddleware now additionally warm the async connection
pool their awaited sponsored_slot() traffic actually uses — the
construction-time warm-up above only ever touched the sync client, a
separate pool the async path never touches. LuluAds.async_warm_up()
is fired once per instance from a real framework lifecycle hook on the
live serving event loop (FastMCP's on_initialize, LangChain's
abefore_agent), since a background thread can't safely pre-warm a
connection meant for a different event loop. Gated by the same
auto_warm_up flag as the sync warm-up (this async path is Python-only —
TypeScript's autoWarmUp only ever had one pool to gate). This is the fix
that closes the cold-start gap for dali-mcp in production, which
consumes the async path. Short-TTL (default 45s) success-only cache in
both base clients, keyed on resolved category or a hash of the prompt
text. Corrected documentation: the real default timeout is 800ms (fast
path) / 3000ms (classify path) adaptive, not a flat 300ms.
0.3.7 — cliTextMode (opt-in, off by default): fixes the Claude Code
content[]-drop bug for real, but only for tools whose content[] already
stands on its own without structuredContent — live-tested both
qualifying and non-qualifying cases, see "CLI rendering". Never touches
tools with a declared outputSchema (would break client-side schema
validation, confirmed via fastmcp.exceptions.ToolError).
0.3.6 — automatic connection warm-up on LuluAdsMiddleware construction
(auto_warm_up, on by default): a genuinely cold first tool call measured
804ms against the 800ms fast-path default — right at the ceiling, not
under it. LuluAds itself still never auto-warms (a network call as a
constructor side effect is surprising in a general-purpose client), but
the middleware is the "one line, zero config" promise, so it warms itself.
0.3.5 — fixed a hardcoded 300ms default timeout_ms on
LuluAdsMiddleware that silently dropped real, fillable ads on real
network latency — every test in the suite used an instant mock transport,
which is exactly why this shipped unnoticed. Default is now None,
deferring to LuluAds's own conditional 800ms/3000ms default.
0.3.4 — CLI card gets rounded corners and a "via Lulu Ads" footer
(Unicode box-drawing only — a live test against Claude Code confirmed it
strips raw ANSI color escapes from tool output before the model sees
them, so color was never on the table). Also live-tested and explicitly
rejected dropping structuredContent to force content[] through: it
does make the card arrive, but the model then flags it as suspected
prompt injection and warns the user off it — worse than the status quo,
where the plain field still gets surfaced honestly even without the
card. See "CLI rendering" for the full writeup.
0.3.3 — CLI-adaptive rendering: LuluAdsMiddleware / withLuluAds detect
known CLI clients via the MCP clientInfo.name sent at initialize
(currently: claude-code, verified live) and append a bordered plain-text
card to content[] for them, in addition to the plain field — terminals
have no widget surface, so this is the CLI-safe equivalent of the MCP Apps
widget above. Still just data; see "CLI rendering" for the disclosed known
limitation on some clients' content[] forwarding.
0.3.0 — register_sponsored_widget() / registerSponsoredWidget() gain
a logo option: fetched server-side at registration time and inlined into
the widget as a data: URI, so it renders under the widget sandbox's CSP
(img-src 'self' data: <resourceDomains>) with no resourceDomains config
needed on your part — a raw remote logo URL would otherwise be silently
dropped, with no error anywhere. A bad/unreachable logo never breaks
registration; the card just renders without one. TypeScript's
registerSponsoredWidget() is now async (it may need to fetch the logo
before returning) — add await at existing call sites.
0.2.0 — register_sponsored_widget() (Python: lulu_ads.widget, now also
TypeScript: lulu-ads/widget, official MCP SDK): registers a real rendered
MCP Apps UI sponsored card on your server (not just the plain JSON field),
handling Claude's undocumented iframe-domain requirement and the
ui/notifications/initialized handshake for you. Generalizes the fix
verified live on dali.getlulu.dev/mcp against
ext-apps#671.
Both SDKs produce byte-identical _meta.ui.domain values for the same
endpoint URL.
0.1.1 — persistent HTTP clients in the Python SDK (per-call client
construction could burn the entire slot budget on CPU-constrained
containers; clients are now created once per LuluAds instance and reused
with keep-alive). Fail-open behavior unchanged.
0.1.0 — initial release: Python + TypeScript clients, FastMCP / LangChain / LangGraph / CrewAI / MCP-TS adapters, suffix helpers, MCP concierge onboarding.