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
updatesentitlement with a fixed 12-month window (§5.5).
Two derived rules the client engine must satisfy:
- 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.
- 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:
- The release script injects the block into the same
--configoverlay that already carries the updater public configuration (tauri build --configoverlays do merge, unlike dev's). CamelCase keys; noapiBaseUrlentry so the compiled production default applies. - A side-effect-free
--print-configmode 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. - 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.licenseseatexactly liketauri::plugindoes, and deserializes it into the real pinned plugin'sPluginConfig— 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_pathfrom 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_entitlementsfrom 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(withexpires_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 command — after 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:
- Activation replaces in place. A successful
activate()commits the new license and swaps the runtime state. Capture never stops. - 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:
- Create a draft release (
version,channel, notes). - Register an artifact (filename, platform/arch, declared digest) → the response carries direct-upload instructions.
- Upload the bytes (presigned PUT).
- Finalize → the server re-computes the digest by streaming (must match the declared one) and applies managed signatures.
- 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 anupdatesentitlement withduration_type: fixed, 12 months. The app works forever; only update access lapses. The client already knows its window offline (active_entitlements[].expires_atships 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_atagainst the license'supdateswindow. 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: openwhile 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):
- format — can this shape be transported at all;
- domain resolves — DNS MX-then-A with bounded timeout, cached (positive 1h, negative 24h);
- disposable — throwaway-inbox detection
(nondisposable 0.3.0:
bundled seed list so a fresh install isn't blind, registrable-domain
matching so
x.tempmail.examplecan't evade atempmail.exampleentry, configurable failure mode); - 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-keyflag, so keys cannot land in shell history orpsoutput. - Every
--helpstates 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-fileis 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
- Offline Licensing — machine files in depth
- Entitlements — the entitlement model reference
- Download Tokens and Signing Keys — the distribution API surface