DOCS LLMs

The Agentic Integration Playbook

The Agentic Integration Playbook

This is the end-to-end recipe for taking a desktop app from unlicensed to licensed, demo-gated, and self-distributing on LicenseSeat — written so that a coding agent can execute it, and a human can audit it.

It is not a tutorial for a toy app. Every recipe, contract, and pitfall here was extracted from a real integration: Clipbasket, a macOS/Windows clipboard manager (Tauri + Rust) that was taken through this exact path — first-run email wall, 14-day full-power demo, hard-paywall decay that stays resident and capturing, paid perpetual license with one year of updates, signed update feeds, and license-gated downloads — with the bulk of the implementation done by coding agents and every load-bearing change human-reviewed. Where this playbook says "do X, not Y", it is because Y was tried and failed in a specific, reproducible way.

The playbook is deliberately prescriptive. An agent implementing a paywall from "it depends" ships a paywall with a hole in it.

This playbook covers the full machine: server model, wire contracts, client engine, the licensing UX layer, distribution, email capture, verification, and operations.


1. The Model

1.1 Three primitives, no new machinery

Everything below composes from three server-side primitives you already have:

Primitive What it is The fields that matter here
License The unit of access. Validates, activates (consumes a seat), heartbeats, gets machine files for offline use, revokes. status, ends_at, metadata, active_entitlements
Plan (LicensePlan) The template a license is issued from. duration_days (absent = perpetual), seat_limit, entitlements (the feature contract — see §2.1)
Entitlement A feature key a license carries, optionally with numeric caps and its own duration. key, cap fields (limit, max_clips, …), duration_type: perpetual | fixed

The single most important consequence: a demo license is just a license. It is issued from a plan the product designates as its demo plan (duration_days: 14, seat_limit: 1, full entitlements). It activates, validates, heartbeats, expires, and gets machine files exactly like a paid license. There is zero demo-specific license machinery to build client-side — the client implements tiers (below), and the server decides which tier a license grants.

1.2 The three-tier funnel

The funnel that shipped, and the state machine that enforces it:

  first launch                demo key activated              demo expires
       │                             │                             │
       ▼                             ▼                             ▼
 ┌──────────┐   email+key    ┌──────────────┐   time passes  ┌──────────┐
 │   WALL   │ ─────────────▶ │    DEMO      │ ─────────────▶ │  DECAY   │
 │ (locked, │                │ (full power, │                │ (paywall,│
 │  selling)│                │  DEMO badge, │                │ +capture,│
 └──────────┘                │  countdown)  │                │  selling)│
       │                     └──────┬───────┘                └────┬─────┘
       │        paid key            │  paid key over demo         │ paid key
       └────────────────────────────┴──────────────┬──────────────┘
                                                   ▼
                                               ENTITLED (paid)
  • Wall — nothing was ever activated on this install. The product surface is closed; one window offers both routes in: request a free demo key by email, or paste a key you already have. The wall captures 100% of activated users' emails, because install-and-open already filtered for intent.
  • Demo — the license grants the full product for 14 days. PRO features work at full power (badge, don't lock), and the visible countdown pill reads "DEMO — N days remaining": DEMO names the free window; PRO stays reserved for the paid tier the PRO-labeled pills advertise (§4.5), so "PRO" never names a temporary state. The trial stays maximally competitive while the tier and the deadline stay visible — and the deadline creates the urgency that capability caps alone never produce.
  • Decay — the demo (or a paid license) expired. Decay is a hard paywall over a still-running app: the popup surface is licensed-only, and every surface — tray icon, tray menu, global shortcut, second launch — routes to the license window, which drops the free-demo offer and leads with buy. Meanwhile capture continues in the background: history keeps accruing to the user's own retention setting and is never deleted, so purchasing restores the full accumulated history — including everything captured while lapsed. Never brick, never delete (§1.3). (This reverses an earlier capped-but-usable decay design: the shipped behavior is clipbasket #9, and licenseseat #51 amended the PRD to match.)
  • Entitled (paid) — perpetual license, full entitlements, plus an updates entitlement with a fixed 12-month window (§5.5).

Two derived rules the client engine must satisfy:

  1. The tier is derived in exactly one place, natively, from one coherent SDK state snapshot. The UI renders a projection of the tier; it never decides the tier.
  2. Capture and surface are separate gates (§4.2), and expiry closes only the surface — retention is never touched (§1.3).

1.3 Hides, never deletes

The single highest-stakes design rule in the funnel, learned the hard way (an early implementation fed the decay cap into the history prune path; a test copy destroyed 29 of 36 rows before the design was caught and fixed, and the fix was locked in with a regression test):

Expiry is a visibility event, not a retention event.

In decay, the entire product surface is hidden (§1.2) while capture keeps retaining to the user's own history setting. Underneath the paywall, the engine's decay caps still exist as its fail-closed floor (§3.5), and they are enforced at the read/admission layer only: existing saved and pinned rows are never unsaved, unpinned, or deleted by a licensing transition, and no cap constant may feed a prune. Re-licensing reopens the surface and reveals the full history byte-identical — plus everything captured while lapsed.

Why this is worth a hard rule and a locked test (history_survives_licensed_decay_copy_relicense_round_trip_byte_identical in Clipbasket's src-tauri/src/licensing.rs):

  • A bricked app gets uninstalled and the captured email goes cold. A resident, still-capturing app stays installed and keeps selling — every muscle-memory shortcut press re-presents the paywall (§4.3).
  • "Unlock the rest" must be literally true — deleting a lapsed customer's data poisons the exact upgrade you are selling.
  • The decay constant must never feed a destructive prune path, because a transient invalid state (network flap, clock issue) resolves through the same tier — and a prune there is irreversible data loss no renewal can undo. (A restore in flight does not even resolve through a tier; it is undecided and applies nothing — §3.5.)

1.4 Flow A vs Flow B: the acquisition posture

The server machinery is identical for both; the difference is seller policy, stored per product as demo_gate: open | first_run | download:

  • Flow A — frictionless-first: download is open, the app runs immediately under tight stage-0 caps, and the email prompt appears at first cap-hit. Email is captured at the moment of demonstrated interest.
  • Flow B — email-gated: the demo license is the entry ticket. Either gated first-run (app downloads freely but launches into the wall — stage 0 is locked, not capped; purely a client posture) or gated download (the site offers no direct binary; the demo email itself carries the download link).

Clipbasket chose Flow B, gated first-run. An agent implementing either flow builds the same tier engine — only the Wall tier's reachability differs.


2. Contracts an Agent Must Know

These are the wire-contract facts that cost real debugging time. Each one looks like a detail until it silently zeroes out a feature.

2.1 Entitlement caps are TOP-LEVEL plan-entry fields

The contract: a plan's entitlements array holds hashes with a "key" and the caps as sibling top-level fields:

[
  { "key": "ocr" },
  { "key": "unlimited_history", "max_clips": 1000 },
  { "key": "saved_items", "limit": -1 },
  { "key": "pins", "limit": 3 }
]

Why: at issuance, every entry field other than key / duration_type / duration is copied into the issued entitlement's metadata. The client then reads caps single-nested at active_entitlements[].metadata.max_clips / .metadata.limit.

The pitfall (hit in the real integration, then fixed by contract): if you write the caps inside an entry-level "metadata" hash — {"key": "pins", "metadata": {"limit": 3}} — they surface double-nested as active_entitlements[].metadata.metadata.limit, and every client cap read silently misses. The E2E harness (§7) caught this; the contract was rewritten to top-level fields and re-proven end to end.

The second pitfall in the same field: entries that are not hashes with a "key" (e.g. bare strings) used to be silently dropped at issuance — the plan looked configured, and every license issued from it carried zero entitlements (downstream symptom: download tokens refused with not_entitled). Since engine 0.7.0 the plan validates entries and rejects malformed ones with a per-entry error. If you are on an older engine, the drop is silent: always verify issued entitlements in a validate response, never trust the plan editor alone.

The convention that falls out: -1 means unlimited, absent means the client's own default. Keep cap semantics (what absent means, what -1 means) in the client's single caps function, not scattered at call sites.

2.2 The license.metadata mirror for plugin clients

The API enriches validate/activate payloads for demo licenses with two top-level fields:

"demo": true,
"upgrade_url": "https://buy.stripe.com/..."

The pitfall: typed SDK clients (the pinned Tauri/Rust plugin, for instance) parse license payloads into structs and drop unknown top-level fields — so the enrichment is unreadable exactly where it is needed to render a native Upgrade button. The fix is a mirror: at demo issuance the server also writes demo: true + upgrade_url into license.metadata, which typed clients do surface. The top-level payload fields remain the source of truth for API consumers; the metadata copy is the plugin-readable mirror.

Snapshot semantics an agent must respect: the metadata upgrade_url is a snapshot taken at issue time — it is not rewritten on resend or refreshed on validate. If the seller changes the payment link later, already-issued demo licenses keep the old URL in metadata (the live top-level enrichment updates). Client rule: prefer the freshest source available to you, and always keep a fallback to your marketing site so a stale or absent URL never strands the buy button.

2.3 Neutral 202: the demo endpoint never confirms anything

POST /api/v1/products/:slug/demo_license with {"email": "[email protected]"} (publishable pk_ key auth) returns:

{ "object": "demo_license_request", "status": "sent" }

Always. Status 202, byte-identical body, for every outcome: fresh issue, idempotent resend, invalid email, missing email, feature disabled, missing demo plan, unauthorized org plan, rate-limited, host-policy refusal. The real reason is logged server-side and visible to the seller — never to the caller.

The reasoning chain an agent should internalize rather than fight:

  • The key is delivered only by email. That is what makes the captured email list real — a faked address gets nothing usable.
  • Because the response carries no secret and no differentiation, the endpoint cannot be used to enumerate which emails exist or hold licenses.
  • Resend is idempotent: an email that already holds an active demo license for the product gets the same key re-delivered, and no second license or customer row is created. No license farming through re-requests.
  • Consequence for client UX: after a 202, the only honest copy is "check your inbox at {email}" — the client cannot know more, by design. Do not build UI that promises "key sent!" vs "you already have one"; the contract forbids distinguishing them.

(Unknown product slugs still 404 like every other product-scoped endpoint — the neutrality protects email state, not product existence.)

2.4 Rate-limit budgets

The demo endpoint enforces fixed-window counters:

Budget Limit Notes
Per IP 5 / hour Refused requests still consume IP budget (abuse guard)
Per email 3 / hour Only counted for well-formed emails

Refusals are externally the same neutral 202 (§2.3). Two operational consequences:

  • A full funnel test spends the budget. One end-to-end run of the demo chain uses most of an IP-hour; plan test runs accordingly (the reference harness restarts the server — memory cache — between same-hour runs).
  • Verify the limits actually engage. The reference integration's E2E caught the rate limiter failing open in the real host (a cache store resolved too early in boot was silently nil, and the limiter's no-store behavior at the time was fail-open — engine 0.9.0 now fails closed when the cache is absent). No unit test caught it because tests configured the store explicitly. The lesson generalizes: an agent should treat "the 4th same-email request is refused, the 6th same-IP request is refused, and the refused bodies are byte-identical to fulfilled ones" as a release-gate assertion (S11 in §7), not an assumption.

2.5 validate semantics: 200 does not mean valid

The trap: POST .../licenses/validate (license key in the request body — canonical since 0.9.0; the old .../licenses/:key/validate path remains as a deprecated compatibility route) returns HTTP 200 for expired, revoked and suspended licenses. Validity lives in the body (valid: false plus a reason code such as expired or revoked). A script that checks only the HTTP status treats "revoked" as "fine".

The bin/licenseseat CLI (§8.1) encodes the distinction in its exit codes, and an agent driving the API directly should reproduce the same ladder:

Exit Meaning Example
0 License valid active license, correct fingerprint
5 License known but not valid (valid: false on a 200) expired demo, revoked key
1 API error unknown key (the API 404s unknown keys before validation — so a bogus key is exit 1, not 5)
3 Refused client-side before any request secret-only operation attempted with a pk_ key
4 Transport failure connection dropped mid-response

Exit 5 is the load-bearing one: it is the difference between "this install should enter Decay" and "this key does not exist". A useful nuance proven in the harness: a hardware-locked license validates as exit 5 before activation — not-yet-activated is a known-but-invalid state, which is exactly what a client sees between issue and first activation.

One more payload nuance for decay UX: an expired demo's validate payload still carries demo: true and upgrade_url even though active_entitlements is empty — precisely the two fields the decay tier needs to render its upsell.

2.6 Key-class ceilings

pk_ publishable keys are safe to compile into shipped apps and can drive validate/activate/deactivate/heartbeat/machine-file/demo-request/download-token. They cannot hold license-create or license-revoke scopes — it is a permissions ceiling, not a granting default. sk_ secret keys drive issuance and revocation and must never appear in a client, a repo, a PR body, or a log. An agent should refuse secret-only operations with a pk_ key before sending the request — a guaranteed 403 teaches nothing.


3. Client Integration (Tauri/Rust Reference)

The reference client is Clipbasket: Tauri v2, Rust core, the LicenseSeat Rust SDK via tauri-plugin-licenseseat, pinned to an exact reviewed git revision. The recipes generalize to any capability-scoped desktop runtime; the pitfalls are Tauri-specific and expensive.

3.1 Plugin config: strict, camelCase, and parsed before your code runs

Tauri strictly deserializes plugins.<name> from the effective config for every registered plugin before its setup hook runs — even when the app passes configuration programmatically (init_with_config). The programmatic values only replace the parsed config inside setup; the strict parse happens first. If the section is absent, null is deserialized into a struct with required fields, and the app aborts at launch:

PluginInitialization("licenseseat", "Error deserializing 'plugins.licenseseat'
  ... invalid type: null, expected struct PluginConfig")

The block that must exist, with camelCase keys (snake_case fails deserialization with a missing-field error):

"plugins": {
  "licenseseat": {
    "apiKey": "pk_test_...",
    "productSlug": "clipbasket",
    "apiBaseUrl": "http://localhost:3990/api/v1"
  }
}

Use a publishable pk_ value even here — never a secret — although in an init_with_config app the block's values are placeholders: they must parse, but the compile-time configuration drives the SDK.

3.2 Dev runs: the local uncommitted edit (and the overlay that doesn't work)

The committed tauri.conf.json deliberately has no plugins.licenseseat block (an architecture test locks that in, so a real key can never be committed by reflex). For development, the block is added as a local, uncommitted edit to src-tauri/tauri.conf.json.

The pitfall, observed directly: pnpm tauri dev -- --config <overlay> does not get overlay content into plugin config — a JSON overlay attempt died with a TOML-parse error, and a TOML overlay was silently not merged. The local edit to tauri.conf.json itself is the only path that reached the plugin in dev. Budget for this: keep the edit out of commits by staging specific files, never git add -A over that file, and end every session with git diff src-tauri/tauri.conf.json showing only that block.

Compile-time values come from env vars read via option_env! (CLIPBASKET_LICENSESEAT_API_KEY, ..._API_BASE_URL, the signing public key and key id) — changing them triggers a rebuild via rerun-if-env-changed. Debug builds accept plain-HTTP localhost API URLs; release guards enforce bounded HTTPS.

3.3 Release builds: script injection + the offline boot test

Because the committed config has no plugin block and dev relies on an uncommitted edit, a release built from a clean checkout is a launch-abort landmine — the exact failure of §3.1, discovered as a launch blocker in the reference integration and fixed as clipbasket #8.

The release recipe:

  1. The release script injects the block into the same --config overlay that already carries the updater public configuration (tauri build --config overlays do merge, unlike dev's). CamelCase keys; no apiBaseUrl entry so the compiled production default applies.
  2. A side-effect-free --print-config mode validates the public release configuration and prints the exact overlay JSON, exiting before any signing, build, or upload — so a test can exercise the real script.
  3. An offline boot test red-proofs the landmine. The test merges the printed overlay over the committed config using the same RFC 7396 merge semantics Tauri uses, extracts plugins.licenseseat exactly like tauri::plugin does, and deserializes it into the real pinned plugin's PluginConfig — the precise operation that aborts a packaged app. Written before the fix, it failed with the exact production panic; after the fix it passes, and it also asserts that the committed config still fails (documenting the landmine) and that snake_case keys fail (locking the serde contract).

The general pattern is the point: when a failure mode only exists in a packaged build, reproduce its exact mechanism as an offline test — merged-config deserialization against the real struct — instead of hoping a human remembers to launch a staging build. (Still do launch the signed staging build once per release; the offline test covers the config mechanism, not code-signing or notarization.)

3.4 Profile isolation: your data-dir override does not cover the SDK

Debug builds honor a data-dir override (CLIPBASKET_DATA_DIR=/tmp/clipbasket-dev) so dev runs never touch the production profile. Two pitfalls inside that sentence, both hit:

  • The SDK resolves its own storage path (platform app-data dir) and knows nothing about your override. Without explicitly deriving the SDK's storage_path from the same override, dev license state lands in the production profile: wiping the dev dir no longer resets to the Wall, and a dev activation can disturb the license state of the app the developer uses daily. Pass the SDK a storage path under the override in debug; leave it default in release.
  • The single-instance guard is keyed on the bundle identifier, which dev and production share — a running production app silently swallows the dev launch. Quit the real app first.

The isolated-run one-liner that results:

CLIPBASKET_DATA_DIR=/tmp/clipbasket-dev pnpm tauri dev

Unconfigured debug builds (no compiled key) deliberately run fail-closed: unconfigured state, no clipboard handle, no activation input, and an isolated cache prefix so they can never read or submit a real cached license.

3.5 The tier engine: one snapshot, one projection

The client core that makes §1 real, as shipped in clipbasket #7:

  • One resolution function maps a coherent SDK snapshot to ProductTier { Entitled, Decay, Wall, Locked }: authoritative grant → Entitled; no grant but a license was seen on this install → Decay; no grant and no license ever → Wall; unconfigured build → Locked.
  • A restore-in-progress is not a verdict, so nothing may act on it. (This supersedes an earlier draft of this section, which said restore ran as Decay. clipbasket #11 proved that wrong in both directions: a licensed user reaching for the shortcut during the sub-second restore is met by the paywall, and the reconciliation tears down the popup it is about to need.) Restoring is undecided — a distinct predicate from any tier — and every seam that could act on a tier has to agree to sit still until the SDK decides: the window coordinator applies nothing, and the activation-surface presenter returns without presenting, because the coordinator will present the right surface a moment later once the restore resolves.
  • Diff against what was last APPLIED, not against the last state observed. This is the non-obvious half, and skipping it silently reintroduces the bug: if a skipped restore is recorded as the new baseline, it swallows the very transition it was hiding. Entitled → restore → Decay then leaves a live popup in Decay, because the restore already looked like the destination. One pure function (applied, next, undecided) → Option<Lifecycle> makes the whole sequence testable without a running app; the state machine is where the bug lives, not the window code.
  • Feature gates read active_entitlements from the snapshot — only the Entitled tier can grant, and only keys the plan actually carries. A granted license whose plan carries no feature keys runs at decay caps: the client never invents entitlement names or infers power from a plan label. (Operational consequence for sellers: configure the production plans' entitlements before shipping the client — this is fail-closed by design, and an entitlement-less paid plan would run paying customers at decay caps.)
  • Caps are one pure function: history = min(user setting, unlimited_history.metadata.max_clips); pins/saved from their entitlements' metadata.limit (−1 = unlimited); Decay/Wall/Locked → the compiled fallback constants (5 / 0 / 1). Enforced at admission and read sites — never as a downgrade-edge prune (§1.3). (Since clipbasket #9 the Decay tier no longer renders the popup at all — §1.2 — so the decay constants are the engine's fail-closed floor, not a user-visible mode.)
  • The UI gets a bounded scalar projection, not the SDK state: tier booleans, a redacted key hint (§4.4), timestamps, entitlement keys with small scalar metadata, a validated HTTPS upgrade_url. Never the full key, fingerprint, activation id, or raw API responses. License-mutating commands stay confined to the settings and license windows (§3.8 pins this); the popup gets a read-only projection; the runtime re-checks tier natively at every commit/export boundary (the renderer is presentation, not policy).
  • Offline parity comes free: machine files carry active_entitlements (with expires_at) too, so the tier engine and the demo countdown work identically from a signed offline artifact — no special offline tier logic. The demo badge countdown renders from the license's own expiry, known locally.

3.6 The state event beats the command response

Symptom: "I activate and the window just disappears" — or a redirect fires before the activation confirmation ever renders.

The SDK emits its license-state change event as soon as activation commits, and that event can reach the UI before the activate() call itself resolves. Any window with an "auto-close when access is granted" rule — a good rule, because it handles activation happening from another window, or a restore landing — will fire that rule during its own activation and close the window out from under the confirmation it was about to show.

Fix: claim the outcome before awaiting, not after:

ownsActivationRef.current = true;      // BEFORE await
try {
  const next = await activate(key);
  if (next.accessAllowed) { setConfirmed(next); return; }
  ownsActivationRef.current = false;   // failed: release the claim
} catch { ownsActivationRef.current = false; }

The auto-close rule then ignores events it caused and still closes for events it did not. Pin it with a test that reproduces the real ordering — emit the state event inside the mocked command, before it resolves — and red-proof it (§3.9): the reference fix was verified by reverting it and watching the test fail (clipbasket #9).

3.7 A menubar app must outlive its windows

Desktop frameworks commonly exit when the last window closes. A tray app never notices — until some tier stops keeping a window alive. In the reference integration the bug arrived exactly when decay went hard-paywall (§1.2): the popup was torn down in Decay, the license window became the only window, and closing it ended the process. Users report this as "the app crashed."

Fix: intercept exit and honor only deliberate ones:

.run(|_app, event| {
    if let RunEvent::ExitRequested { code, api, .. } = event && code.is_none() {
        api.prevent_exit();   // a window closed; the tray keeps the app alive
    }
});

An explicit app.exit(0) carries a code and passes through, so the tray's Quit item still works. This interception is also what makes the no-exit paywall honest (§4.3): closing the paywall leaves the app resident and capturing.

3.8 A granted capability still fails until the native guard agrees

Symptom: Window 'x' is not allowed to call this commandafter you already granted that window the command in the capability JSON.

In Tauri (and any capability-scoped runtime), the capability grant is only half the gate. If the command also re-checks the calling window natively — which it should: the renderer is presentation, not policy (§3.5) — then the permission system and the native guard must both agree before the call succeeds:

// capability JSON says the license window MAY call this…
#[tauri::command]
pub fn activate_product_license(window: Window, ...) -> Result<...> {
    // …and this must agree, or it fails anyway.
    ensure_window_allowed(&window, &[SETTINGS_LABEL, LICENSE_LABEL])?;

Guardrail: a source-reading architecture test that asserts the two layers match (§3.9). Crude and effective — it turns "fails in a user's hands" into "fails in CI" when an agent adds a window or a command and updates only one layer (clipbasket #9).

3.9 Agent working style that made this integration reviewable

  • Pin the SDK to an exact reviewed revision (git rev, later a released version). Reproducible builds; no unreviewed branch drift enters a release.
  • Source-reading architecture tests as invariants an agent cannot silently break: the capability/guard agreement of §3.8; the committed config must have no plugin block; seat-changing commands stay confined to their allowed windows.
  • Red-proof every regression test: mutate the fix out, record the failure, restore. In the tier-engine PR, two first-draft tests survived their mutations (realistic snapshots carried no stale entitlements) and were strengthened with adversarial cases — the discipline catches weak tests, not just missing ones.

4. The Licensing UX Layer

The funnel of §1 is won or lost in a handful of client UX decisions. These are the ones that shipped in the reference integration (clipbasket #9), as recipes.

4.1 Upgrading must never pass through deactivation

The single highest-value recipe in this layer.

The naive upgrade flow — user buys, deactivates the demo, activates the paid key — surrenders the seat first. A typo in the new key then drops a paying customer into decay (§1.2: a hard paywall), seconds after they paid. It is the worst possible moment for the worst possible experience.

Instead, verify two properties of your SDK, then let users paste the paid key straight over the running demo:

  1. Activation replaces in place. A successful activate() commits the new license and swaps the runtime state. Capture never stops.
  2. Activation failure is inert. A rejected key emits an error and leaves the existing license untouched.

Both hold in the LicenseSeat SDKs, and the reference client pins both outcomes with tests: upgrade-over-demo swaps without a deactivation, and a rejected key leaves the demo running. The upgrade UI is then simply a key box visible during the demo, labeled for what it is:

Already bought a license? Paste your key No data is lost, and you keep using the app as normal.

The old seat on the demo license stays activated server-side. That is fine — it is a different license and it expires on its own. If it bothers you, reap it in a background job; never make the user's upgrade depend on it.

4.2 Capture and surface are separate gates

Whether the app keeps recording is one question; whether the user can see and use it is another. Conflating them produces either a paywall that quietly destroys value ("nothing was saved while you were lapsed") or a trial that never converts ("the free version is good enough").

The reference client keeps them as two named predicates over the tier: clipboard_runtime_allowed_for_tier (Entitled + Decay) governs capture, and popup_surface_allowed_for_tier (Entitled only) governs the UI. That pairing is what makes the §1.2 decay design work: a lapsed user's history keeps filling up behind the paywall, so buying restores a fuller history than the demo ended with — a conversion argument and a data-safety guarantee in the same mechanism.

4.3 Give the paywall no exit

If the app is unusable without a license, a "Not now" button is a lie — it leads to an inert tray icon. The license window offers only routes that resolve the state (request a demo key, activate a key, buy) and no decline affordance.

Closing the window from the titlebar is still allowed — and leaves the app resident and still capturing (§4.2, §3.7), so the next shortcut press re-presents the paywall. That is the inventory argument: the paywall needs no exit because the app never really leaves, and every muscle-memory shortcut press is another visit to the sales surface.

4.4 Redact the key, keep the brand

•••• 1234 tells the user nothing about which license is installed. If keys carry a branded prefix, keep the first group and the last four and mask the middle:

DEMO-••••-••••-1234

Guard the branded form: use it only when the leading group is short enough to be a brand (≤8 chars), there are at least three groups, and enough of the key stays masked — otherwise fall back to tail-only, or a short or odd-shaped key reveals more than intended. And the dependency runs server-side: whether keys are branded at all is a key-generation-time choice the client can only reflect.

4.5 Copy rules: the tier vocabulary is a SSOT

  • Two tier words, never conflated. PRO names what the paid license grants — the tier the PRO-labeled pills advertise. DEMO names how long you have it free: the countdown pill reads DEMO — 14 days remaining. One word for both makes "PRO — free for 14 more days" read as a permanent state.

  • Define every user-facing licensing string once — tier name, price, buy labels, gate messages, countdown format, site URL — and test that every surface builds from the definitions:

    expect(BUY_LABEL).toContain(PRICE_LABEL);
    expect(proFeatureTooltip("Copy Markdown")).toBe(`Copy Markdown — ${PRO_TIER_NAME}`);
  • Never hardcode the payment URL in the client. It arrives as license metadata (upgrade_url, §2.2) with a marketing-site fallback — changing where people pay must not require a desktop release.

  • Say what changed, not what happened internally. "Your demo is now a full license," not "License activated." And reassure in one sentence — a paragraph about coverage gaps creates the fear it is trying to prevent.

  • Never promise behavior the default install does not have, and when you delete an affordance, grep the copy for its name — licensing copy outlives controls, and a message pointing at a deleted button sends users hunting for it.

4.6 The empty state is onboarding

"Your clipboard is empty" is a status report. Three lines — what fills it, how to summon it again, how to search it — turn the same pixels into a tutorial that costs nothing. The reference popup's teaching empty state is test-pinned, including a test that it never promises an interaction the default install cannot deliver (a paste-on-Enter tip that is opt-in and needs an OS permission would simply not happen on a fresh install).


5. Distribution

Licensing without distribution is half a product: the same server that knows whether a license is valid should decide who gets which binary and which update. This section is the Distribution v2 surface (license_seat #10, host wiring in licenseseat #33), as proven end to end by the harness in §7.

5.1 Releases and artifacts: the publish pipeline

A Release (product + version + channel) with ReleaseArtifacts (one per platform/arch file) is the single source of truth; every update feed is a serializer over it. The agent-drivable publish pipeline, pure public API with an sk_ key:

  1. Create a draft release (version, channel, notes).
  2. Register an artifact (filename, platform/arch, declared digest) → the response carries direct-upload instructions.
  3. Upload the bytes (presigned PUT).
  4. Finalize → the server re-computes the digest by streaming (must match the declared one) and applies managed signatures.
  5. Publish → the release goes live in every feed at once.

Two sharp edges measured in the harness: artifact filenames are unique per release — re-registering the same filename (or re-creating the same version) surfaced as a 500 in earlier engines; 0.9.0 renders a clean 409 conflict. Still make publish scripts idempotent by checking before creating, and use a fresh version per test run.

5.2 Signing keys: custody, or byte-identical interop

Per-product SigningKeys hold Sparkle Ed25519 seeds and Tauri minisign keypairs, encrypted at rest, with generate / import / retire lifecycles — imports accept the real tools' formats (Sparkle generate_keys -x seeds; minisign / rsign2 / tauri signer documents, including encrypted ones). The trust claims are anchored to the real consumers, not to a reimplementation's opinion: server-produced Sparkle signatures are byte-identical to sign_update output (golden fixture in CI), Tauri feeds verify against the actual updater plugin's deserializer + minisign-verify, and electron-updater channel files round-trip through real electron-updater provider code.

Client-side custody rule (from the reference integration's release checklist): the app pins the signing public key + key id at compile time; rotating the key means shipping a new build while the old trust path still works — never rotate server-side first and strand installed clients.

5.3 Update feeds: one upload, every updater

Published releases are served simultaneously as:

Feed Endpoint Notes
Sparkle appcast /p/:slug/appcast.xml channels, phased rollout, critical updates, deltas, edSignature
Tauri static /p/:slug/tauri/latest.json {os}-{arch} keys; universal macOS artifacts published under both arches
Tauri dynamic /p/:slug/tauri/:target/:arch/:current_version 200 {version,url,signature,notes,pub_date} when an update exists; 204 = up to date
electron-updater /p/:slug/electron/latest[-mac|-linux].yml plus relative artifact/blockmap serving
Generic /p/:slug/latest?channel=... JSON with artifact url + sha512 for custom in-app checks

The dynamic-endpoint contract matters for clients: 204 means "no update", including when the feed policy gates you out — a gated updater sees a quiet no-op, never a scary 4xx.

5.4 Download tokens: license-gated bytes

POST /api/v1/products/:slug/releases/:version/download_token   (pk_ + license_key)
  → { "token": "...", "expires_at": "<+5 minutes>" }
GET /d/:artifact_id/:filename?token=...
  → 302 to signed storage URL → bytes

Gating semantics proven in the harness: the license must carry the download_artifacts entitlement; with update_feed_policy: "licensed" a tokenless download is 403; after a revocation, token minting fails 422 license_invalid and an already-gated download with the revoked key is 403. Tokens are deliberately short-lived (~5 minutes): mint at click time, never embed in emails or pages.

5.5 "One year of updates": the version-cutoff model

The paid model that closes the loop — perpetual license + 12 months of updates (the Sketch model), enforceable because feeds and downloads are license-aware:

  • Plan shape: the paid plan carries all feature entitlements as perpetual, plus an updates entitlement with duration_type: fixed, 12 months. The app works forever; only update access lapses. The client already knows its window offline (active_entitlements[].expires_at ships in validate responses and machine files) and can render "renew" natively with zero extra requests.
  • Version-cutoff semantics (live today): the distribution gate compares each release's published_at against the license's updates window. Releases published inside the window stay downloadable forever — you keep your year's versions, including re-downloads and reinstalls. Releases published after the window are refused with a distinguishable 403 code (version_not_entitled / not_entitled, not a bare 403) so clients can render "renew" instead of erroring. Feed reads keep serving a lapsed license its last entitled version rather than an error.
  • Launch posture: ship with update_feed_policy: open while the entire first cohort is inside its paid window anyway; flip entitlement-gated enforcement on before the first cohort's window closes (~month 11). Gating machinery first, policy flip later — the flip is config, not a release.

6. Email and Demo Capture

6.1 The demo plan setup recipe (canonical shapes)

This is the exact server-side configuration the reference integration seeds and the E2E harness (§7, S11) verifies — use it as the template:

Demo plan (demo-14d): duration_days: 14, seat_limit: 1, entitlements = every feature key the product gates, caps as top-level fields (§2.1):

[
  { "key": "ocr" },
  { "key": "markdown_copy" },
  { "key": "file_path_variants" },
  { "key": "link_previews" },
  { "key": "unlimited_history", "max_clips": 1000 },
  { "key": "saved_items", "limit": -1 },
  { "key": "pins", "limit": 3 }
]

The 14-day clock is the license's own expiry (duration_days), not the entitlements' — the entitlements simply vanish with the license's validity, which is what makes "full power, then decay" one mechanism.

Paid plan (pro-lifetime): no duration_days (perpetual), the same feature keys as perpetual entitlements, plus { "key": "updates", "duration_type": "fixed", ... 12 months } (§5.5).

Product configuration (dashboard, "Demo licenses" card): enable toggle · demo-plan picker · upgrade_url (the product's payment link — this feeds the §2.2 enrichment/mirror) · demo_gate posture flag (§1.4). Demo licenses are a paid-platform feature (org plan gating), and the demo email delivers regardless of the org's customer-email toggle — it is the feature's delivery channel, not a courtesy copy.

Conversion is free: the purchase webhook's find-or-create-by-email lands the paid license on the same Customer as the demo license, the product's demo-conversion counter increments, and the paid validate payload carries no demo enrichment. The seller's remarketing list is the Customers page filtered to the demo plan; export is the ESP boundary (LicenseSeat is not an email marketing tool).

6.2 Email quality: spend email only where it can land

Because the demo key travels only by email, an undeliverable address makes the whole issuance pointless — and disposable inboxes are the license-farming vector. The platform runs a layered, fail-open verdict on every address it spends email (or a license) on (licenseseat #49):

  1. format — can this shape be transported at all;
  2. domain resolves — DNS MX-then-A with bounded timeout, cached (positive 1h, negative 24h);
  3. disposable — throwaway-inbox detection (nondisposable 0.3.0: bundled seed list so a fresh install isn't blind, registrable-domain matching so x.tempmail.example can't evade a tempmail.example entry, configurable failure mode);
  4. suppressed — did this address already hard-bounce or complain (§6.3).

Policy is per surface, and the demo endpoint's refusal rides the same neutral 202 (§2.3) — quality gating never becomes a new enumeration oracle. Webhook-sourced purchases are never blocked: a paying customer with a disposable email is still a paying customer; the verdict is stored as advisory flags on the Customer instead. And the whole stack fails open: a DNS outage or a broken table degrades to "send it" with a loud log — only definitive negatives ever refuse.

6.3 The suppression loop: protect the sender reputation you share

Bounces and complaints feed back automatically (licenseseat #48): SES → SNS → webhook (signature-verified, auto-confirming) → a suppression table → an app-wide mailer interceptor that silently drops suppressed recipients from every future email. Permanent bounces and complaints suppress immediately; transient bounces suppress after 3 within 30 days. Proven in production with the SES mailbox simulator: a simulated hard bounce became a suppression row in 25 seconds. Sellers get this for free — it is platform behavior, not per-product setup.

6.4 Sellers configure; agents automate

The honest division of labor for the capture funnel:

Sellers configure (dashboard / accounts they own) Agents automate
Product + plans + the demo card (§6.1) Client wall/demo/decay tiers (§3)
The payment link (Stripe et al.) and its product mapping The in-app demo request (native POST with the compiled pk_)
upgrade_url, demo_gate, feed policy The full verification chain (§7)
Whitelabel email branding Release publishing (§5.1) via sk_ in CI

One current boundary worth knowing: browser-side calls to the demo endpoint from a static site are blocked (no CORS on the API yet — a known, tracked item), so Flow B on a static site means either the in-app wall (the reference choice), a server-side form handler, or the hosted demo-request page when it ships.


7. The Verification Kit

The reference integration's release gate is an E2E harness — eleven scripted checkpoints (S1–S11) that drive the entire surface of this playbook against a locally-booted server with the real gem, real crypto, and a real mail catcher. It exists because unit suites kept passing while three different launch-relevant paths were dead (an unwired signing provider, a silently lossy entitlement shape, a fail-open rate limiter — all found by the harness, all fixed and red-proofed). An agent should run — or rebuild — this chain before calling any integration release-ready.

Checkpoint Proves The expectations that catch real bugs
S1 health server up /api/v1/health 200
S2 seed org/product/plans exist plans carry §6.1 entitlement shapes (top-level cap fields)
S3 keys key model pk_/sk_ minted; raw values only at creation
S4 issue issuance path license-create with sk_ → 201 + key
S5 lifecycle seat + validate semantics pre-activation validate exit 5 (known-but-invalid); activate → exit 0; heartbeat lands server-side; deactivate; seat count 0→1→0; bogus key exits 1, not 5
S6 offline machine-file trust chain Ed25519 signature verifies fully offline with only the fetched public key; a tampered file is rejected (negative control required)
S7 publish the §5.1 pipeline create → register → upload → finalize (server digest matches declared) → publish; managed signatures applied
S8 feeds every updater format appcast well-formed with edSignature; Tauri static carries universal under both arches; dynamic returns 200 with update, 204 when current; generic latest carries url + sha512
S9 download gated bytes token (pk_ + license) → 302 → downloaded bytes byte-identical to upload
S10 revocation gating end-to-end update_feed_policy: licensed → tokenless 403; after revoke: validate exit 5, token mint 422 license_invalid, gated download 403
S11 demo chain the whole §2/§6 contract request → neutral 202; key extracted from the delivered email drives the rest (proves the email carries a working key); activate shows demo: true; validate carries all demo entitlements single-nested (assert no metadata.metadata) + the license.metadata mirror; re-request → same 202, same key, still one license/customer; 4th same-email and 6th same-IP requests refused with byte-identical bodies; demo window exactly 14 days; expired validate → exit 5, code: "expired", still demo: true + upgrade_url, empty entitlements; paid issue on the same customer → conversion counter increments, no demo enrichment

Three habits that made the harness worth more than its runtime:

  • Chain from real artifacts, not fixtures. S11 parses the demo key out of the actual delivered email body; S9 compares actual downloaded bytes. If the email template breaks or storage serves the wrong file, the chain fails even though every unit test passes.
  • Negative controls are mandatory. The tampered machine file (S6), the tokenless download (S10), the bogus key (S5) — a gate that has never been seen refusing is not a gate.
  • Budget for the rate limiter (§2.4): one full S11 spends the IP-hour; restart between same-hour runs.

8. Ops for Agents

8.1 bin/licenseseat: the CLI an agent drives

The platform ships a stdlib-only CLI over the public API (licenseseat #36) — 15 API subcommands plus an mcp server mode:

health  auth-test  plans  signing-key  validate  activate  deactivate
machine-file  heartbeat  license-create  license-revoke  releases  latest
download-token  demo-license  mcp

Design decisions that exist specifically because agents (and shell scripts) are the callers:

  • One JSON envelope on STDOUT, always — human commentary goes to STDERR. All three of the API's error-envelope shapes (nested engine errors, flat auth errors, JSON:API machine-file errors) are normalized into one error.code / error.message.
  • The exit-code ladder of §2.5, including exit 5 for valid: false — the 200-but-revoked trap closed at the tool layer.
  • Secret-only operations are refused client-side with a pk_ key (exit 3, zero bytes sent) with a sentence explaining the key model, instead of a spent round trip and a bare 403.
  • The key comes from the environment only (LICENSESEAT_API_KEY) — no --api-key flag, so keys cannot land in shell history or ps output.
  • Every --help states when to call the command, which key class and scope it needs, and its exit codes — the six things integrators consistently get wrong (offline is activate-then-machine-file; machine-file is hyphenated; test keys are rejected in production; …) are written where the failure happens.

8.2 The MCP server: the same surface for model callers

bin/licenseseat mcp serves 14 of those operations as MCP tools (no demo tool; spec revision 2025-06-18, stdio): protocol errors vs in-band isError execution failures split per spec, mutating tools declared via ToolAnnotations (destructiveHint) so a host can gate them mechanically, one JSON object per line and nothing else on STDOUT, and the API key masked everywhere including the startup banner. An agent host connects it with an env-scoped key and gets the full §2 contract — exit-code semantics included in the envelopes — without shell plumbing.

8.3 Cloud configuration as idempotent scripts

The pattern to copy for any cloud-side setup an integration needs, as exemplified by the platform's SES feedback wiring (bin/ses-setup, licenseseat #48): no console clicking — a script that (a) reads current state first and skips every mutation that already matches (safe to re-run, always), (b) offers --dry-run printing the exact mutating commands instead of executing them, (c) encodes ordering constraints that are otherwise tribal knowledge (SES refuses to disable feedback forwarding until both bounce and complaint topics are set — the script does topics first, forwarding last), and (d) separates the phases that can run any time from the one that needs the deployed endpoint live (SNS subscription confirmation). The production rollout of that script — prepare, deploy, subscribe, then a simulator-verified bounce-to-suppression-row in 25 seconds — was executed agent-side end to end, human-authorized.

8.4 What still needs a human — the honest boundary

An agent can execute everything in §§2–7 autonomously. As of today, these remain human taps, and pretending otherwise produces stuck automation:

Human-only step Why
Creating the product, plans, and API keys on the LicenseSeat dashboard No management REST API exists (deliberate: the blast radius of an sk_ that can create products is a real product decision, not an oversight). The 15-minute path starts with two dashboard steps; everything after is agent-drivable.
Stripe (or other PSP) payment links and their account The seller's money lands in the seller's PSP account; creating links and connecting the account is theirs. Once the link exists, mapping → issuance → email is automatic.
Apple Developer ID certificates, code signing, notarization (and their Windows equivalents) Certificates are bound to the seller's developer account and hardware keychain. The release script is agent-runnable; the credentials are not agent-mintable.
Package-registry publishes behind MFA (RubyGems, npm, crates.io…) MFA is the point. Agents prepare the artifact and the exact command; the human runs it (or explicitly delegates a scoped token).
Production deploy authorization Policy, not capability — deploys in the reference project are agent-executed but human-authorized, per session.

Treat this table as a checklist to clear early: an agent that requests the dashboard seed, the payment link, and the signing certificate on day one is never blocked on them on ship day.


Appendix: The Reference Implementation's Receipts

Every recipe above traces to reviewed, merged code. The trail, for auditors (several repos are private; the links are provenance for the team rather than public reading):

Claim area Where it landed
Demo endpoint, neutral 202, rate limits, plan validation, payload enrichment (engine 0.7.0) license_seat #11
Demo product card, plan gating, customer-aware issuance, metadata mirror (host) licenseseat #44
Demo-licenses PRD (funnel, contracts, §4.5 tier inventory, Phase-2 renewals) licenseseat #43
Distribution v2 engine: releases/artifacts/signing keys/feeds, byte-level interop proofs license_seat #9, #10
dv2 host wiring + PRD + proof scripts licenseseat #33
Download-token signing provider fix (found by the harness: endpoint dead, suite green) licenseseat #42
CLI + MCP server + skill licenseseat #36
Email quality layers + policy + security fixes licenseseat #49
SES suppression loop + bin/ses-setup licenseseat #48
Disposable-email hardening (0.3.0) nondisposable #3
Client: production licensing integration + release hardening clipbasket #1, #2
Client: tier engine, caps, gates, popup lifecycle clipbasket #7
Client: release-config injection + offline boot test clipbasket #8
Client: license-window UX — hard-paywall decay (resident + capturing, §1.2), one paywall surface with no exit (§4.3), upgrade-over-demo without deactivation (§4.1), activation-race fix (§3.6), ExitRequested interception (§3.7), capability/guard pinning (§3.8), SSOT tier vocabulary + DEMO countdown copy (§4.5), branded key redaction (§4.4), teaching empty state (§4.6) clipbasket #9
PRD amendment: decay = resident-capturing hard paywall (adjudicated to match clipbasket #9) licenseseat #51
Concealed-clipboard privacy (never paywall privacy) clipbasket #6

Next Steps