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:
- Header banner — leaderboard above the dashboard navigation. Standard IAB sizes. ZeroClick or TryGravity served.
- Sidebar units — sticky medium rectangles in session list and session detail pages.
- In-feed units — native-format sponsored cards interleaved every N sessions in the session timeline. Always labeled "Sponsored."
- Interstitial on session replay open — one-time per session view, auto-advances after 5 seconds; not user-dismissible. High CPM, used sparingly.
- Account / settings pages — single sidebar unit only. No interstitials.
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:
- One sponsor at a time, one line maximum.
- Always prefixed with the eyebrow
◇ Sponsoredin--goldAeonik Pro (--font-sans) 11px sentence-case. ASCII fallback<> SPONSORED. Never an emoji. - Always on — there is no dismissal; the slot stays visible for the whole session.
- Shown in every permission mode, including
paranoid. - Refreshed at most once per session (no rotation mid-session).
- Click-through is the URL printed in the slot — the slot itself is not interactive in the terminal; the URL is selectable / copyable.
- First-appearance pulse animation (subtle radial Klein-blue ring at 1.6s, once) is disabled in reduced-motion.
Content rules — what can appear:
- Developer-relevant SaaS, dev tools, hosting providers, cloud, hardware.
- Job listings via partner job networks.
- Open-source project sponsorships (sponsored awareness for non-commercial projects).
Content rules — what cannot appear:
- Crypto / gambling / adult content.
- Competing coding agents (no Claude Code, Codex, Cursor ads — competitive integrity).
- Political content.
- Misleading creative or clickbait.
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:
- The interstitial is in the dashboard, not the terminal.
- "Skip" never disables — fallback to a non-premium model is always available.
- The watch is 15 seconds, not 30, not 60.
- Sponsor branding is visible the entire time but the watch is observational, not interactive (no quizzes, no checkboxes).
- Interstitial frequency capped: one per 8 hours per user per sponsor.
- Sponsored model interstitials only apply to managed-tier users. Users on
subscriptionmode (Claude Pro / ChatGPT Plus / etc.) orbyo-keymode pay for their own inference; the interstitial mechanic does not apply to them. They still see display ads on Surfaces 1 and 2. See17-subscription-passthrough.md.
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:
- 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. - 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 —
/adsin the TUI only reports status and the "why" explanation. - Native-format ads are never indistinguishable from organic content. Visual treatment (border, background tint, or explicit label) sets them apart.
- 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:
- 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 callsfetchAd. - Ad networks do not see user prompts, model outputs, or code. The SlotRequest payload is enumerated above; no other data crosses the SDK boundary.
- Ad networks do not see user identity. The optional
accountIdfield is hashed (SHA-256 of accountId + monthly-rotated salt) before transmission. - No covert influence on model routing, tool dispatch, or subagent selection by sponsors. Sponsor relationships have zero input into the loop.
- 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
- Per-surface frequency caps stored per-account in the API. Caps reset daily.
- Per-creative quality score updated daily based on click-through rate and reported-as-misleading rate. Creatives below a threshold are blocked for 7 days.
- Per-sponsor blocklist (operator-controlled): sponsors are blocked at the registry by the operator; there is no per-user sponsor blocking (
picasso ads blockdoes not exist).
Telemetry — separate from usage telemetry
Ad event telemetry is its own pipeline, with no opt-out:
- Impression and click events keyed by
impressionId(see Event API). The current surfaces never emitdismiss— ads are not dismissible. - Nothing that leaves Picasso carries user identity beyond the hashed accountId; the internal ledger keeps the account id for signed-in events so revenue can be attributed per account.
- Required for revenue attribution; it is part of the free tier (this is the explicit trade-off).
- There is no
picasso ads opt-out: ads are always on and fund free-tier managed inference./adsis status/why only.
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.
| Path | Clients | Body |
|---|---|---|
POST /v1/ads/events | CLI, dashboard | Batch (below), or the legacy single picasso.ad-event.v1 AdEvent |
POST /v1/sponsors/events | Mac desktop | Batch only |
Batch request — { "events": [ ... ] }, 1–100 events. Unknown fields are rejected.
| Field | Required | Rule | ||
|---|---|---|---|---|
kind | yes | impression \ | click \ | dismiss (legacy — current surfaces never send it; ads are not dismissible) |
sponsorId | yes | Id (or exact name) of an approved, disclosure-validated sponsor; house for Picasso's own promos (never billed) | ||
surface | yes | dashboard, cli, studio, thread, threads, goals, memory, extensions, settings, codeWorkspace | ||
at | yes | ISO-8601, within the last 7 days and no more than 5 minutes ahead | ||
impressionId | no | [A-Za-z0-9._:-]{1,128}; derived server-side from install/slot/sponsor/creative/kind/time when omitted | ||
creativeId | no | [A-Za-z0-9._:-]{1,128}; a placement-shaped id (desktop:<sponsorId>:<position>) must belong to sponsorId | ||
placementId | no | `<desktop\ | cli\ | dashboard>:<sponsorId>:<position>; derived from a placement-shaped creativeId` when omitted |
slotId | no | [A-Za-z0-9._-]{1,64} | ||
appVersion | no | [0-9A-Za-z.+-]{1,32} | ||
anonymousInstallId | when signed out | Random 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):
202 { "data": { "accepted": n, "recorded": n, "duplicates": n }, "error": null }400 invalid_ad_events(validation, withvalidationIssues),400 unknown_sponsor(issue message isunknown_sponsor,creative_sponsor_mismatch,unknown_placement, orsponsor_policy_blocked),400 attribution_required,401 unauthorized,429 ad_events_rate_limitedwithretry-after,503 sponsor_registry_unavailable.
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).
genesis: a Genesis-verified impression (POST /v1/ads/owned/events, accepted by Genesis against a signed decision token for this publisher and newly recorded in the ledger) credits 5 requests and $0.05 of provider spend. One credit per decision token across all accounts, so replays (from the same or another account) earn nothing more; fresh client impression ids are bounded by the daily and outstanding caps below.reported: a client-reported billable impression from an approved sponsor (/v1/ads/events,/v1/sponsors/events) credits 2 requests and $0.02. The event body is not signed, so this is lower trust and capped lower;PICASSO_SPONSOR_CREDIT_REPORTED=falseturns it off.- Clicks, dismissals, house promos, nonbillable events and anonymous installs earn nothing.
- Earnings are bounded: 400 (Genesis) / 60 (reported) requests per UTC day, at most 100 requests and $1 outstanding, and every credit expires after 24 hours.
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.
| Condition | Result |
|---|---|
| No / expired / exhausted allowance | 402 sponsor_allowance_required |
sponsor_id not an approved, unfrozen sponsor | 403 sponsor_unknown |
sponsor_id named but no unexpired credit earned from that sponsor | 402 sponsor_allowance_required |
| Per-minute, per-day (lower for accounts under 7 days old) or per-installation quota hit | 429 managed_quota_exceeded |
Allowance store unreachable in enforce | 503 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:
- Account registration with ZeroClick. SDK and tag key issued.
- Dashboard pages include the ZeroClick tag in HTML head.
- Adapter (
createZeroClickAdapterinpackages/ads-sdk/src/adapters/http.ts, the shared HTTP adapter it has with TryGravity) implementsfetchAd,reportImpression,reportClickagainst ZeroClick's HTTP API. - 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
- Programmatic display via ZeroClick + TryGravity: ~70% of revenue at year 1.
- Sponsored model interstitials: ~25% of revenue at year 1.
- Owned ad network (v2): targeted at 50% of revenue by year 2.
Target: $5M ARR run rate at month 12 post-launch. Detail in 00-overview.md.
Acceptance criteria
- Ad SDK conformance test suite passes for ZeroClick and TryGravity adapters.
- Integrity tests (no ad content in model output, no PII to ad networks) pass on every CI run.
- Disclosure rendering correctness verified across all six slot types.
- Frequency caps enforced within one impression of the configured limit.
- Quality-score-based blocking triggers within 24 hours of crossing threshold.
- Ad-blocker detection produces no fingerprinting beyond the user's own request omission.
Dependencies
packages/ads-sdk— the SDK and adapter contracts.apps/dashboard— the primary ad surface.apps/api— impression attribution, frequency caps, quality scoring.packages/telemetry— separate ad-event pipeline.packages/tui— terminal sponsor slot.
Open questions
- Should sponsored model interstitials be available on BYO-key users too? (Current: no — interstitials are the trade for managed free inference. BYO users see only display ads on the dashboard.)
- Should we publish ad-revenue figures? (Current: yes, as a transparency dashboard once revenue is meaningful.)