docs/13-ads-monetization.md

13 — Ads & Monetization

The wedge. Every other coding-agent CLI is a subscription product with a free trial. Picasso is a free product with sponsored surfaces, honestly disclosed. ZeroClick and TryGravity are the day-one ad-network adapters, modeled after freebuff.com's implementation. The owned ad network ships as adapter #3 in v2. This document specifies every ad surface, the adapter SDK, disclosure rules, fraud controls, and the integrity guarantees that make the model defensible.

The promise

A coding agent of equivalent quality to Claude Code, Codex, Droid, and Devin. No subscription. No paywall on premium models. The user pays attention to clearly labeled sponsors on a companion dashboard they may rarely visit, and optionally accepts sponsored interstitials for premium-tier model access. No bait. No degradation. No injection into code.

Where ads live

Three surfaces, in order of revenue contribution.

Surface 1: Companion web dashboard (primary)

The local dashboard is the main ad inventory during development; after domain registration it will move under dashboard.picassohq.com. Modeled on the placement, frequency, and disclosure UX patterns observed in freebuff.com's ZeroClick + TryGravity integration. Specifically:

The dashboard is the surface 80% of revenue flows through. The CLI's job is to nudge users to the dashboard (replay, plan editing, memory inspection, team commands) without ever forcing them.

Surface 2: Terminal sponsor slot (secondary)

A single one-line sponsor slot in the TUI, above the status line. See 10-tui.md and Scene 02 in design/Picasso TUI.html. Rules:

Content rules — what can appear:

Content rules — what cannot appear:

Surface 3: Sponsored model interstitials (high-CPM, low-frequency)

Account panels are served at https://picassohq.com/account. Dashboard ads, replay and the plan editor are not hosted at launch.

When a free-tier managed-mode user requests a premium-tier model (Opus, GPT-5.5), or hits their daily budget cap, they are offered an interstitial in the dashboard:

Continue with Claude Opus 4.8 for this session?

This session is sponsored by [Sponsor]. View 15-second message
to unlock the model for the next hour.

[ Watch ]  [ Skip — use Sonnet instead ]

Strict rules:

Ad SDK architecture

The packages/ads-sdk package exposes one interface for all surfaces. Adapters plug in.

interface AdAdapter {
  name: "zeroclick" | "trygravity" | "owned";
  fetchAd(slot: SlotRequest): Promise<AdContent | null>;
  reportImpression(impressionId: string): Promise<void>;
  reportClick(impressionId: string): Promise<void>;
  reportDismiss(impressionId: string): Promise<void>;
}

type SlotRequest = {
  slotType: "header" | "sidebar" | "in-feed" | "interstitial" | "terminal-line" | "modal";
  surface: "dashboard" | "cli";
  userContext: {
    accountId?: string; // hashed if present
    sessionLanguage?: string; // inferred from repo
    sessionFramework?: string;
    permissionMode: "safe" | "auto" | "yolo" | "paranoid";
    timezone?: string;
  };
};

type AdContent = {
  impressionId: string;
  adapter: string;
  creative: {
    headline?: string;
    body?: string;
    sponsor: string;
    landingUrl: string;
    imageUrl?: string;
    disclaimer?: string;
  };
  expiresAt: ISO8601;
};

Adapter precedence is configured per slot. Default at launch: ZeroClick primary, TryGravity fallback. The runtime racing adapters with a 500ms deadline; first to respond wins. If both miss the deadline, the slot renders blank with a Picasso self-promo as house ad.

Disclosure rules

Non-negotiable, enforced at the SDK before any creative renders:

  1. Every ad surface has the word "Sponsored" rendered at the same or larger font weight as the body copy, prefixed by the ◇ glyph (ASCII fallback <>) in --gold.
  2. Every ad has a "Why am I seeing this?" disclosure link that opens a panel explaining: which ad network, what context signals were used (language inferred from repo, etc.), and that ads fund free-tier managed inference. There is no opt-out — /ads in the TUI only reports status and the "why" explanation.
  3. Native-format ads are never indistinguishable from organic content. Visual treatment (border, background tint, or explicit label) sets them apart.
  4. The terminal sponsor slot's URL is the literal landing URL — no link shorteners, no tracking redirects (impression tracking happens server-side, not in the URL).

Integrity guarantees

Hard rules — written in code, enforced by tests, audited at every release:

  1. No ad content appears in model output, tool results, generated code, or plan documents. This is checked by a runtime invariant: the SDK refuses to serve into any slot category not declared in slotType. The agent loop has no code path that calls fetchAd.
  2. Ad networks do not see user prompts, model outputs, or code. The SlotRequest payload is enumerated above; no other data crosses the SDK boundary.
  3. Ad networks do not see user identity. The optional accountId field is hashed (SHA-256 of accountId + monthly-rotated salt) before transmission.
  4. No covert influence on model routing, tool dispatch, or subagent selection by sponsors. Sponsor relationships have zero input into the loop.
  5. Ad blocking is not technically prevented. Users who run a content blocker on the dashboard simply see no ads, no degraded experience.

These rules ship with a public threat-model document and are part of the legal page.

Frequency and quality

Telemetry — separate from usage telemetry

Ad event telemetry is its own pipeline, with no opt-out:

Event API

One ingestion module (apps/api/src/ad-events.ts) and one ledger table (ad_event_ledger) serve every client. The CLI/dashboard and the Mac app send the same batch contract; only the path differs so older builds keep working.

PathClientsBody
POST /v1/ads/eventsCLI, dashboardBatch (below), or the legacy single picasso.ad-event.v1 AdEvent
POST /v1/sponsors/eventsMac desktopBatch only

Batch request — { "events": [ ... ] }, 1–100 events. Unknown fields are rejected.

FieldRequiredRule
kindyesimpression \click \dismiss (legacy — current surfaces never send it; ads are not dismissible)
sponsorIdyesId (or exact name) of an approved, disclosure-validated sponsor; house for Picasso's own promos (never billed)
surfaceyesdashboard, cli, studio, thread, threads, goals, memory, extensions, settings, codeWorkspace
atyesISO-8601, within the last 7 days and no more than 5 minutes ahead
impressionIdno[A-Za-z0-9._:-]{1,128}; derived server-side from install/slot/sponsor/creative/kind/time when omitted
creativeIdno[A-Za-z0-9._:-]{1,128}; a placement-shaped id (desktop:<sponsorId>:<position>) must belong to sponsorId
placementIdno`<desktop\cli\dashboard>:<sponsorId>:<position>; derived from a placement-shaped creativeId` when omitted
slotIdno[A-Za-z0-9._-]{1,64}
appVersionno[0-9A-Za-z.+-]{1,32}
anonymousInstallIdwhen signed outRandom UUID the client can reset; one per batch

Attribution. Send Authorization: Bearer <session token> (the token device login issues) to attribute events to the account: the ledger stores the account id for internal revenue reporting and the monthly-salted hash for anything that leaves Picasso (integrity rule 3). Without a bearer header, anonymousInstallId is required and is the only subject stored. An invalid or expired bearer is rejected with 401 rather than silently downgraded to anonymous.

Integrity checks, in order. Shape validation and the admin PII/usage-content gate (emails, tokens, prompt/code/memory keys, raw account ids) → bearer resolution → sponsor/creative/placement validation against the approved sponsor registry and the server category blocklist → per-subject rate limit → insert with on conflict (impression_id, kind) do nothing (replays are counted as duplicates, never double-billed).

Rate limits. Postgres-backed fixed windows (ad_event_rate_limits) shared by every API replica: per account or per install, 120 batches and 600 events per hour by default.

Responses (batch; desktop envelope):

The legacy single-event body keeps its original responses (200 { accepted, impression_id }, 400 { error }) and adds duplicate: true on replays; sponsor must name an approved sponsor unless adapter is house.

Revenue. GET /v1/admin/ads/revenue?from=&to=&account_id=&sponsor_id= (admin usage:read; default the last 30 UTC days, max 366) aggregates the ledger per sponsor and per day/creative/placement: impressions, clicks, dismissals, CTR, and earned revenue from the sponsor's pricing (POST /v1/admin/ads/sponsors/:id/pricing with { model: "cpm" | "cpc", rate_usd, reason }; CPM = impressions / 1000 × rate, CPC = clicks × rate, unpriced sponsors earn 0). The admin Ads page shows the totals and tables; the account detail page shows the same report filtered to that account.

Free-tier access control (sponsor allowance)

Ads fund free managed inference, so the server — never the client — decides who gets it. A free account's managed request (/v1/managed/generate and /v1/managed/generate/stream) spends from a per-account allowance that the API credits when it records a verified ad impression. A script holding only a token has no allowance; a modified client that never shows ads earns nothing. Client fingerprinting is deliberately not the control: a CLI that runs on someone's machine can always be copied, so the metering lives on the server.

Credits (earning).

Spending (gate). In order: sponsor_id check, per-account and per-installation quotas, then one request taken from the allowance atomically. A call that finishes debits its provider cost (actual when the provider reports it); a call whose provider produced nothing returns its request. The dollar budget and the request count both bind, whichever runs out first.

ConditionResult
No / expired / exhausted allowance402 sponsor_allowance_required
sponsor_id not an approved, unfrozen sponsor403 sponsor_unknown
sponsor_id named but no unexpired credit earned from that sponsor402 sponsor_allowance_required
Per-minute, per-day (lower for accounts under 7 days old) or per-installation quota hit429 managed_quota_exceeded
Allowance store unreachable in enforce503 sponsor_allowance_unavailable (fails closed)

Exempt (they spend their own budget, not sponsor allowance): managed-internal, accounts on a paid plan (team/enterprise), accounts holding managed credit, and org-paid requests where the account is a recorded member of the named org. New-account farming is limited by a per-address daily cap on brand-new accounts at device-login verification (429 rate_limited; returning accounts are not counted).

Rollout switch. PICASSO_SPONSOR_ENFORCEMENT=off|shadow|enforce, code default shadow. In shadow every decision is evaluated, allowance is spent when available and credits accrue, but nothing is ever blocked; would-deny outcomes are only counted. Numbers can be tuned with PICASSO_SPONSOR_ENFORCEMENT_CONFIG (JSON of the same shape as the defaults in apps/api/src/ads/sponsor-allowance.ts; invalid or negative values are ignored). The per-address, per-account and per-installation counters use the existing durable auth_rate_limits store.

Visibility. GET /v1/admin/sponsor-enforcement?from=&to= (admin usage:read) returns the live mode, quotas, credit policy and per-day counts of allowed, would_deny, denied, exempt, credited, credit_duplicate, credit_capped by reason code. Counts only: no account ids, sponsor ids or request content.

Flipping to enforce (production change; owner action). Ship in shadow, watch would_deny against real traffic for a few days, confirm legitimate sessions are earning credit (credited rising, would_deny: allowance_* low for the Mac app and CLI), then set PICASSO_SPONSOR_ENFORCEMENT=enforce on the API service and restart. Roll back by setting it to shadow or off. Two things must be true first: the Mac app and CLI must be reporting impressions to /v1/ads/owned/events or /v1/sponsors/events under the signed-in account (otherwise legitimate users earn nothing), and the Genesis owned-ads lane (PICASSO_GENESIS_*, PICASSO_OWNED_ADS_ENABLED) must be live for the higher-trust credit.

Storage. sponsor_allowance_credits (one row per credit id, the replay guard), sponsor_allowance_balances (one row per account) and sponsor_enforcement_counters, created additively by ensurePostgresApiStoreSchema. The daily-cap and spend arithmetic run as single statements so concurrent requests cannot overshoot.

Limits of this control. It meters access; it does not stop an abuser who really does display ads from a real account, and a determined attacker can still earn reported credit by posting events from approved sponsors. That is why reported credit is small and capped, the Genesis path is the higher-trust one, per-installation and new-account quotas apply, and Mac App Attest (PICASSO_APPATTEST_*) separately binds managed requests to the genuine app.

ZeroClick integration

Reference docs at https://zeroclick.ai. Implementation steps:

  1. Account registration with ZeroClick. SDK and tag key issued.
  2. Dashboard pages include the ZeroClick tag in HTML head.
  3. Adapter (createZeroClickAdapter in packages/ads-sdk/src/adapters/http.ts, the shared HTTP adapter it has with TryGravity) implements fetchAd, reportImpression, reportClick against ZeroClick's HTTP API.
  4. Per-slot configuration mapped to ZeroClick zones.

TryGravity integration

Reference docs at https://trygravity.ai. Same pattern as ZeroClick — separate adapter, separate config, separate impression-attribution endpoint. The runtime races both adapters for fill-rate optimization.

Owned ad network (v2)

Adapter #3 — direct-sold inventory — built in v2 as a separate adapter implementing the same AdAdapter interface. No structural changes required. Direct-sold creatives go through the same disclosure rules, the same integrity guarantees, the same quality scoring. The user-facing surface is identical.

The owned-network value proposition: developer-relevant inventory at higher CPMs and higher margins than programmatic. Direct relationships with cloud providers, dev tools, and SaaS that already advertise to developers.

Revenue model

Target: $5M ARR run rate at month 12 post-launch. Detail in 00-overview.md.

Acceptance criteria

Dependencies

Open questions