# Lulu Ads — SDK quickstart

Monetize your MCP server or agent tool with one disclosed, labeled sponsored
line. Fail-open, no prompt injection, 70% revenue share to publishers.

## Zero-friction path (recommended)

If you're already talking to a coding agent, skip everything below and let
it drive the whole flow:

```bash
claude mcp add --transport http lulu-ads https://ads.getlulu.dev/mcp
```

Then tell the agent:

> monetize my server

The MCP tools register you as a publisher (with your consent — it's your
email), hand back a snippet tailored to your stack, and verify a slot went
live with `verify_integration`. If you can't add MCP servers, follow the
manual steps below.

## Install

Python:

```bash
pip install lulu-ads
```

TypeScript:

```bash
npm install lulu-ads
```

## Get a publisher ID

Three ways to get a `publisher_id` / `api_key` pair — pick whichever fits
your setup, none gated on the others.

**Option A — the MCP tool above.** Ask the agent to call `create_publisher`;
it returns `publisher_id`, a one-time `api_key`, and a tailored snippet.

**Option B — web form.** https://getlulu.dev/publishers

**Option C — curl:**

```bash
curl -X POST https://ads.getlulu.dev/publishers \
  -H "content-type: application/json" \
  -d '{"name": "my-server", "contact_email": "you@example.com", "server_url": "https://my-server.example.com"}'
```

Response:

```json
{"publisher_id": "pub_...", "api_key": "lk_..."}
```

The `api_key` is shown once — store it immediately, e.g. as environment
variables. Every SDK entry point reads them from here by default, so no code
needs to hardcode a key:

```bash
export LULU_ADS_PUBLISHER_ID=pub_...
export LULU_ADS_API_KEY=lk_...
```

## Attach a slot (Python)

For servers/agents that don't use FastMCP middleware — call the client
directly wherever you build a tool result:

```python
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"},
    timeout_ms=150,
)
return result
```

A missing `sponsored` field is expected and fine — it means no campaign
matched, the client is inert (no creds), or the backend didn't respond in
time. It's never a broken call.

## One-line FastMCP middleware

FastMCP servers get every tool monetized in one line — credentials come from
the environment:

```python
from fastmcp import FastMCP
from lulu_ads.middleware import LuluAdsMiddleware

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

To exclude specific tools from ever carrying sponsored content:

```python
mcp.add_middleware(LuluAdsMiddleware(exclude_tools=("private_tool",)))
```

## TypeScript

```ts
import { LuluAds } from "lulu-ads";
const ads = new LuluAds({ publisherId: "pub_123", apiKey: "lk_..." });
result.sponsored = await ads.sponsoredSlot({ context: { tool: "search_flights" } });
```

For a TypeScript MCP server built on `@modelcontextprotocol/sdk`, wrap the
server instead of calling the client per-tool:

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

const server = new McpServer({ name: "my-server", version: "1.0.0" });
withLuluAds(server); // call before registerTool — reads env vars
```

## The contract

Base URL: `https://ads.getlulu.dev`. Full wire-level detail: /docs.md and
the [public contract doc](https://github.com/Lulu-The-Narwhal/lulu-ads/blob/master/docs/contract.md).

### `POST /slot`

| Header | Required | Value |
|---|---|---|
| `x-api-key` | yes | your publisher `api_key` |
| `content-type` | yes | `application/json` |

Body:

```json
{"context": {"tool": "search_flights", "category": "travel.flights"}}
```

`context` is optional; only six allowlisted keys ever leave your process —
`tool`, `category`, `query`, `route`, `locale`, `country` — everything else
is silently dropped client-side, before a request is even built.

**200 (slot filled):**

```json
{"label": "Sponsored", "text": "Direct flights TLV–BKK from $412", "url": "https://ads.getlulu.dev/c/<token>"}
```

`label` is always the literal string `"Sponsored"`. `url` is always a
`/c/{token}` signed redirect on this domain, never a raw advertiser URL.

**204 (no fill):** empty body — a normal, expected outcome (no matching
campaign, budget exhausted, etc.), not an error. The SDK enforces a hard
150ms wall-clock cap and returns `None`/`null` on any failure path,
including timeout.

### Guarantees (enforced in code, not just promised)

- **A tool call can never break because of ads** — every failure path
  (missing creds, network error, non-200/204 response, malformed body,
  timeout) returns `None`/`null`; nothing raises.
- **Always disclosed** — `label: "Sponsored"` is set by the SDK, never
  sourced from the response body's own framing.
- **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](https://dali.getlulu.dev)
  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 means the client is
  inert, returns `None`/`null`, zero network calls.

## Publisher controls

Two layers of control: SDK-side (per call) and account-side (per publisher).

**SDK-side:**

- **`exclude_tools`** (FastMCP middleware / TS `withLuluAds`) — tool names
  that must never carry sponsored content, e.g. anything security-sensitive
  or already monetized.
- **`context`** (`tool`, `category`, `query`, `route`, `locale`,
  `country`) — the only signals used for matching; send as little or as much
  of this allowlist as you want, more context generally means better fill
  rate.
- **`timeout_ms`** — override the default 150ms wall-clock cap if your
  runtime needs a tighter budget; it never blocks longer than you set.

**Account-side** — every publisher has a `settings` object evaluated on
every fill:

| Field | Type | Does |
|---|---|---|
| `blocked_categories` | string[] | never fill with a campaign explicitly tagged one of these categories (or a sub-category of one) |
| `blocked_domains` | string[] | never fill with a campaign whose destination domain matches (or is a subdomain of) one of these |
| `allowlist_mode` | boolean | when true, only categories in `allowed_categories` are eligible — everything else is blocked |
| `allowed_categories` | string[] | the allowlist used when `allowlist_mode` is on |

Email tal@getlulu.dev to set these on your account until self-serve controls ship.

## Money

- **70% to publishers, 30% to Lulu** on every attributed conversion.
- **CPA only** — you're paid when a click converts, tracked through a
  signed `/c/{token}` redirect and reported via `POST /postback`. No charge
  for impressions no one can verify.
- **Payouts** — accrued monthly and paid out via Stripe to the account tied
  to your publisher email.

Docs: https://getlulu.dev/docs · Agent-readable: https://getlulu.dev/docs.md ·
Coding-agent install file: https://getlulu.dev/install.md · Publisher signup:
https://getlulu.dev/publishers · Source: https://github.com/Lulu-The-Narwhal/lulu-ads
