@sprid/cli 1.2.6 → 1.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,11 @@
3
3
  sprid follows semver: a breaking change to a command's arguments, its output
4
4
  shape or its exit codes is a major release.
5
5
 
6
+ ## 1.2.7 - 2026-10-19
7
+
8
+ - Marketing review guidance reuses saved goals and supports app, shop and service outcomes, with concrete actions tied to the available evidence.
9
+ - Update content, advertising and analytics guides to follow the customer's evidence and voice. Clarify background data refresh and local media requirements.
10
+
6
11
  ## 1.2.6 - 2026-10-14
7
12
 
8
13
  - Conversion campaign guidance covers preflight checks, attribution, placement assets and app approval. Meta connection guidance covers sharing an ad account across apps, shared spending limits and removing access.
@@ -269,8 +274,8 @@ shape or its exit codes is a major release.
269
274
 
270
275
  ## 0.1.3 - 2026-09-23
271
276
 
272
- - Posts and reviews have refs: `BND-78` is post 78 of the account keyed BND,
273
- `BND-R4` is review 4 of the app keyed BND. Every command that takes a post id
277
+ - Posts and reviews have refs: `APP-78` is post 78 of the account keyed APP,
278
+ `APP-R4` is review 4 of the app keyed APP. Every command that takes a post id
274
279
  takes its ref, and `sprid reviews draft|reply` take a review ref. `sprid
275
280
  status` and the review mail print refs, which older versions refuse.
276
281
  - `sprid reviews reply` reads the one review it replies to, so a review
package/README-post.md CHANGED
@@ -156,61 +156,25 @@ the speed, the background - none of it needs the app driven again. `cut.json`
156
156
  next to the recording holds what a re-cut needs: the timeline, the second
157
157
  clapper's wall time and the device's pixel size.
158
158
 
159
- ### Five things it does that are not obvious
160
-
161
- **`settle` waits for the screen to become something else, not to stop moving.**
162
- Waiting only for quiet cannot tell *the transition has finished* from *it has not
163
- started*, and a Debug build behind Metro can run seconds behind the script - and
164
- while it is behind, the screen it is still showing is perfectly quiet. Every
165
- settle then returns on a stale frame and the next tap lands on a screen that is
166
- about to be replaced. It compares against a frame captured before the gesture
167
- that caused the change.
168
-
169
- **Two clapperboards, because the recorder's clock is not the wall clock.**
170
- `simctl io recordVideo` reports nothing about when it opened the stream, so the
171
- run flashes the springboard dark before the app comes up and again after the
172
- script, and solves `videoSeconds = v0 + k · wallMs`. Measured `k` here ranged
173
- 0.76 to 1.00 across runs. One flash cannot see a rate error - the first version
174
- had one, and its last caption was four seconds off the screen it described.
175
-
176
- **Full-resolution probes starve the recorder.** `settle` screenshots twice a
177
- second while recording. As PNG, one heavier app came back holding 76% of the
178
- elapsed time; the probes now run through `baguette screenshot --scale 4`, a
179
- twentieth of the pixels for ffmpeg to decode. A recording that solves below
180
- **0.9x wall is treated as spoiled and fails**: the solve keeps the captions in
181
- sync but the missing time cannot be put back, so the app's own motion plays fast
182
- on top of whatever `speed` asked for.
183
-
184
- **Scratch goes outside your repo** (`$TMPDIR/sprid/<repo>/<slug>`) unless a
185
- lane sets `tmp`. Inside it, Metro's watcher sees every probe screenshot, sends a
186
- Fast Refresh, and RN drops its blue "Refreshing…" banner over the frame.
187
-
188
- **Input goes through `baguette`, which does not touch the machine.** It injects
189
- through SimulatorKit's private symbols with the iOS-26 calling conventions: the
190
- pointer never moves, the Simulator never comes to the front, and you can keep
191
- working through a run. Its `input` command streams newline-delimited JSON
192
- gestures, which is the part that matters - **we keep our own velocity curve**,
193
- where a canned `swipe --duration` would be the linear interpolation that makes a
194
- scripted scroll read as scripted.
195
-
196
- Two details inside that, both of which cost a take:
197
-
198
- - **Swipes stream, taps do not.** The streaming path is `IOHIDDigitizerDispatch`,
199
- built for continuous gestures. React Native surfaces can accept a
200
- streamed tap and the one UIKit surface, the tab bar, silently did not.
201
- `baguette tap` lands on it every time, and a tap has no timing worth owning.
202
- - **A tap must not move.** An earlier version jittered the touch a pixel or two
203
- during the hold, on the theory that a finger is never still. Nothing can see it
204
- - a screen recording has no cursor in it - and UIKit reads those moves as the
205
- start of a drag and cancels the tap.
206
-
207
- Without baguette it falls back to posting CGEvents through a compiled Swift
208
- helper (`src/screen/simkit.swift`), and **that path owns the pointer for the
209
- length of the run**. It is a fallback, not a preference: the Simulator ignores
210
- events posted to its process, tested both plain and with the window-number
211
- routing trick, so the global HID tap is the only other way in. simkit stays
212
- either way, because it also rasterises the caption cards through CoreText -
213
- Homebrew's ffmpeg has neither `drawtext` nor `subtitles`.
159
+ ### Recording behavior
160
+
161
+ `settle` compares the screen with the frame before the preceding gesture. This
162
+ helps avoid tapping the old screen while a transition has not started yet.
163
+
164
+ The recording uses markers before and after the script to align captions with
165
+ the video's clock. Low-resolution probes reduce recording overhead. Recordings
166
+ below the timing check's accepted rate fail rather than producing an accelerated
167
+ capture. Inspect the reported failure before repeating a run.
168
+
169
+ Scratch files go under `$TMPDIR/sprid/<repo>/<slug>` unless a lane sets `tmp`.
170
+ Keeping them outside the repo prevents development file watchers from refreshing
171
+ the app during capture.
172
+
173
+ With `baguette`, input goes to the selected simulator without moving the desktop
174
+ pointer. Swipes use timed gesture samples; taps use its dedicated tap command.
175
+ Without it, the Swift CGEvent fallback requires Accessibility permission and
176
+ uses the desktop pointer for the duration of the run. Avoid using the pointer
177
+ while that fallback is recording.
214
178
 
215
179
  ### What it needs
216
180
 
@@ -220,12 +184,9 @@ still runs, on the CGEvent fallback, which needs the terminal to hold macOS
220
184
  Accessibility permission and **takes over the pointer for the length of the
221
185
  run**.
222
186
 
223
- The status bar is dressed for a reel rather than for the store: the **host
224
- clock**, not 9:41, and a battery in the seventies rather than 100% and charging.
225
- 9:41 reads as an advertisement, and the app's own content gives the real time
226
- away anyway - a row logged during the recording carries the wall clock, so a
227
- 9:41 status bar above it is a tell. `statusBarTime` and `battery` in the spec
228
- override both.
187
+ The default status bar uses the host clock and a partly charged battery.
188
+ Set `statusBarTime` and `battery` in the spec to match the product and the intended
189
+ presentation. Check that visible app content and status bar values are consistent.
229
190
 
230
191
  ## Two kinds of post
231
192
 
package/README.md CHANGED
@@ -3,8 +3,8 @@
3
3
  The Sprid command line. Log in once, connect the stores and the channels, and see
4
4
  what needs you. Node 18+. Screenshot composition and releases also need Bun.
5
5
 
6
- Sprid runs the marketing loop for an app: the agent does the work, the server
7
- remembers, watches and publishes, the person approves. Connected workflows share
6
+ Sprid stores marketing plans and results, schedules approved content and gives
7
+ your agent tools to carry out the work you request. Connected workflows share
8
8
  server records across REST, Sprid MCP and the dashboard. The CLI adds terminal access
9
9
  and local media production. Credentials stay out of conversations: CLI key-file
10
10
  commands and secure Sprid forms handle them.
@@ -80,10 +80,7 @@ $ sprid status
80
80
  ```
81
81
 
82
82
  Connections are grouped one line per account (channels) and one per App Profile
83
- (sensors): `✓` ok, `~` expiring, `✗` revoked or the last pull failed, `–` key missing. The
84
- PLAN line ends with `· BYOK anthropic ✓` or `· BYOK –` once the server reports the
85
- workspace's model key. When the token sees
86
- several workspaces, the first line names the one you are reading.
83
+ (sensors): `✓` ok, `~` expiring, `✗` revoked or the last pull failed, `–` key missing. When the token sees several workspaces, the first line names the one you are reading.
87
84
 
88
85
  Every line of `status` is one field of the server's `GET /api/status`. The web Home
89
86
  card, the Monday mail and the plugin's closing message print the same list.
@@ -197,7 +194,7 @@ claude mcp add sprid -- sprid mcp
197
194
  ```
198
195
 
199
196
  It uses the `sprid login` credentials (or `SPRID_PAT`). `--surface all` lists the full
200
- catalog instead of the ~25 core verbs.
197
+ catalog instead of the core tools.
201
198
 
202
199
  ## What it touches
203
200
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprid/cli",
3
- "version": "1.2.6",
3
+ "version": "1.2.7",
4
4
  "description": "The Sprid command line: connect your app, review results, prepare posts and store screenshots, and release mobile apps.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -29,7 +29,7 @@ export const COMMAND_GROUPS = [
29
29
  ['media', 'sprid media job <UUID>', 'Read saved upload results or creation progress without repeating the operation.'],
30
30
  ['media', 'sprid media init|build|register|check|push|preview …', 'Run the existing local media pipeline. Historical sprid post invocations remain supported.'],
31
31
  ['post', 'sprid post create --file <draft.json>', 'Create a shared draft from ordered asset references and a stable requestId. With "status": "idea" it saves an idea instead: notes, a sourceUrl and any gathered assets, before layout. Never publishes.'],
32
- ['post', 'sprid post get <postId>', 'Read the shared post and its slides. Every <postId> here takes the UUID or the post\'s ref, such as BND-142.'],
32
+ ['post', 'sprid post get <postId>', 'Read the shared post and its slides. Every <postId> here takes the UUID or the post\'s ref, such as APP-142.'],
33
33
  ['post', 'sprid post update <postId> --file <changes.json>', 'Apply the existing post-update contract to a shared draft.'],
34
34
  ['post', 'sprid post preview --id <postId>', 'Preview a shared post. Without --id, historical local preview arguments are unchanged.'],
35
35
  ['post', 'sprid post deliveries <postId>', 'Read scheduled, failed and delivered publications with platform receipts.'],
@@ -95,7 +95,7 @@ export const COMMAND_GROUPS = [
95
95
  ['status', 'sprid status', 'Show what needs you, your connections and the publishing queue.'],
96
96
  ['next', 'sprid next [--limit N]', 'List the next actions and how to complete them.'],
97
97
  ['marketing-review', 'sprid marketing-review [--app <slug>] [--days N|--since YYYY-MM-DD] [--end YYYY-MM-DD] [--check]', 'Read connected analytics and a versioned metric dataset for two equal periods. End is exclusive. Validated results retain blocked reasons; complete packets are saved immutably under marketing-reports/evidence. --check reads setup only.'],
98
- ['marketing-review', 'sprid marketing-review --view summary|full [--sources gsc,posthog] [--refresh|--cached-only]', 'Summary is a compact entry point; full retains evidence. Exact-window snapshots name freshness. Refresh forces provider reads; cached-only makes none. Daily collection refreshes connected sources automatically for active plans.'],
98
+ ['marketing-review', 'sprid marketing-review --view summary|full [--sources gsc,posthog] [--refresh|--cached-only]', 'Summary is a compact entry point; full retains evidence. Exact-window snapshots name freshness. Refresh queues background collection; inspect progress and freshness. Cached-only makes no provider calls. Daily collection refreshes connected sources automatically for active plans.'],
99
99
  ['marketing-review', 'sprid marketing-review evidence --app <slug> --id <evidence.id>', 'Read the exact app-scoped source snapshot without provider calls, including after key rotation. Saved privately for 90 days; this command also archives the receipt locally.'],
100
100
  ['marketing-review', 'sprid marketing-review context --app <slug> [--file <context.json>]', 'Read shared review memory, or save {baseRevision,context}. Context holds definitions, investigations, decisions, corrections and release references. Concurrent changes return a conflict without overwriting. Explicitly select context to share; never upload keys or raw customer records.'],
101
101
  ['marketing-review', 'sprid marketing-review save --app <slug> --file <review.json>', 'Save a finished review to the app: {verdict, agenda:[{title, why}], period:{start,end}, reportPath}. Verdict and agenda only; the full report stays in the repo. A second save the same day replaces that day’s review.'],
@@ -360,7 +360,7 @@ export const GUIDES = [
360
360
  "id": "metric-definitions",
361
361
  "title": "Metric definitions",
362
362
  "kind": "detail",
363
- "markdown": "In **Settings → Apps → your app → PostHog**, set the account-created event and the first UTC date from which tracking is complete.\n\n| Metric | What counts |\n|---|---|\n| Signups | First `sprid_signup` event per PostHog person, across all surfaces |\n| New product users | First product activity, including anonymous people |\n| Active product users | Distinct people with product activity in the period |\n| Web visitors | Distinct pageview visitors on the saved website hostname, minus your product |\n\n**Signups.** Send `sprid_signup` from your server after the account is created, with the account id as `distinct_id`, and identify that id in your clients. Never fire it on login, page load or install. An existing `posthogEvents.signup` mapping also works; `posthogConfig.registration.event` wins over it. With no tracking start date, no matching event history reads as unavailable; with one, it reads as zero signups. A period that starts before the tracking date is counted from that date and says so. Comparisons crossing it, or with no confirmed start, are withheld. Backfill with the original creation timestamps.\n\n**Every signup surface has to send it.** An event your app sends after sign-up misses accounts made on your website, and the other way round: one app's in-app event missed 17% of its signups, which happened on its website. A server event covers every surface at once. Without one, list each surface's account-created event in `registration.events`; the first of any of them per identity counts, so an existing account signing in elsewhere is not a new signup. Never add a sign-in event that existing accounts also send. The marketing review flags a signup-like event on a surface where your registration event never fires.\n\nWhen an app reports signups, they are its user count on Home and in Insights. First product activity counts anonymous devices, so it moves into that card's detail.\n\n### What counts as your product\n\nSet this under **What counts as your product** on the same screen. It decides who is an active person, and it is the single setting most likely to make the people card wrong.\n\n| Your product is | Choose | Also give |\n|---|---|---|\n| An iOS or Android app | **A phone app** | Nothing. This is the default |\n| A web app on its own subdomain | **A website or web app** | The hostnames, such as `app.example.com` |\n| A web app under a path on your marketing domain | **A website or web app** | The paths, such as `/app`, `/dashboard` |\n| A phone app with a web client | **Both** | The web hostnames or paths |\n\n**A web product must say where it lives.** Your marketing pages and your product are the same PostHog events; without a hostname or a path there is nothing to tell them apart, and every visitor would be counted as someone using the product. Sprid refuses that configuration rather than reporting it.\n\nWhatever you name here is **subtracted from your website visitor numbers**, so a page inside the product is never also counted as a visit.\n\n**A phone app** needs `posthog-react-native` on iOS, iPadOS or Android and rejects explicit web surfaces: set `app_surface: 'web'` on Expo web and `'native'` on devices. Web counts need a browser and exclude reported bots and crawler user agents. Every metric excludes events or people with `is_internal`, `is_test` or `sprid_test = true`. What remains is observed identities, not guaranteed humans.\n\n### Keep your own testing out\n\nOn a new app, your own phone, simulators and store reviewers can outnumber real people. Sprid already drops Google Play's pre-launch test devices, events reporting `$is_emulator`, reported bots, and anything flagged `is_internal`, `is_test` or `sprid_test`. Two things are yours to add:\n\n- **Flag your own accounts.** After sign-in, register `is_internal: true` when the account's email is on your own domain, store-review accounts included, and set it on the person if you identify. A web client that never identifies has to put it on every event, before the first pageview. Do the same on the server event that records a signup.\n- **List your own devices** under `exclude`, for what happens before sign-in, such as a fresh install. Open your app, then read the newest events in PostHog for the `$device_name` values:\n\n```json\n{\"exclude\":[{\"scope\":\"event\",\"property\":\"$device_name\",\"operator\":\"in\",\"values\":[\"Pixel 8\",\"Simulator iOS\",\"sdk_gphone64_arm64\"]}]}\n```\n\nApple's review devices have reported `$device_name` `iPhone99,7`, with locale `en-US` and time zone `US/Pacific`, in every app we checked (September 2026). Add it if it shows up in yours.\n\nTwo more Apple populations pass every default rule, because they are real iPhones: the device that checks each uploaded build (a fresh US person per build, often Cupertino, locale a bare `en` where real phones send `en-US`) and App Review (US, locale `zh-Hans`, about one a day). Both are active for one day and never reach a paywall: one app counted 14 of them as installs in two months. The marketing review flags them with their counts. Exclude a locale only after checking none of your real users send it.\n\nGoogle Play's pre-launch devices are covered by default: in the app we checked, every `OnePlus8Pro` event carried the fleet's screen width and every `sdk_gphone` event carried `$is_emulator` (2026-10-03). If yours sends neither property, list the device names under `exclude`.\n\n### Custom properties\n\nAdvanced rules go under **Custom properties**:\n\n```json\n{\"registration\":{\"identity\":{\"scope\":\"event\",\"property\":\"account_id\"}},\"app\":{\"kind\":\"web\",\"hosts\":[\"app.example.com\"]},\"exclude\":[{\"scope\":\"person\",\"property\":\"staff\",\"operator\":\"in\",\"values\":[true]}]}\n```\n\n- `app.kind` is `native`, `web` or `both`, with `app.hosts` and `app.pathPrefixes` saying where a web product lives. This is what the screen above writes.\n- `app.filters` replaces the whole definition with your own rule; all filters must match. Choosing it shows as **Custom rules** on the screen, and the rule is subtracted from web visitors the same way.\n- `exclude` removes matching traffic from every metric.\n- `registration.filters` narrows the registration event, for example `result = success`.\n- `registration.events` adds account-created events from other surfaces, counted with `registration.event` (see Signups above).\n- `entryUrls` lists the pages a plan, campaign or bio link sends people to, such as a web funnel's first step. The review flags one with almost no pageviews in 14 days, which usually means a link stopped pointing at it.\n- `registration.accounts: false` says the app has no accounts (a local-first app, or one that identifies by device). Registrations then read as not applicable instead of a missing event, and the weekly email stops listing them as unread.\n- `registration.identity` defaults to `person_id`. A configured property must be present and should never change, because it counts accounts.\n- Each rule takes `scope: event|person`, `operator: in|not_in` and string, number or boolean `values`. A missing property fails `in` and passes `not_in`. Property names are literal keys, dots included.\n\nThe same object is `posthogConfig` in REST `PATCH /api/app-profiles/:id`, MCP `upsert_app_profile` and the App Profile JSON used by `sprid init`, where `registration.event` and `registration.since` also live. No credentials or SQL belong in it. After saving, fetch Insights and check its registration notes and totals against your database before trusting a funnel.\n\n### Paywall and purchase events\n\nA paywall event that says only that a paywall appeared cannot tell a price change from a paywall that failed to load: both read as fewer sales. One app's Android paywall had no purchasable product for five months and its revenue view read as zero demand. Send at least these, and map the events under `posthogEvents` as `paywallShown` and `purchase`:\n\n| Event | Properties |\n|---|---|\n| Paywall shown | `price`, `currency`, `product_id`, `offering_id`, `offering_loaded` (true once the store returned a product), `has_free_phase`, `store_country` |\n| Purchase or trial started, after the store confirms it | `price`, `currency`, `product_id`, `has_free_phase` |\n| Purchase failed | `product_id`, the store's error code |\n\nReport a billing error to your error tracker too, never only to the console. The marketing review flags a platform that shows the paywall to many people and never sells, and paywall events that carry none of these properties."
363
+ "markdown": "In **Settings → Apps → your app → PostHog**, set the account-created event and the first UTC date from which tracking is complete.\n\n| Metric | What counts |\n|---|---|\n| Signups | First `sprid_signup` event per PostHog person, across all surfaces |\n| New product users | First product activity, including anonymous people |\n| Active product users | Distinct people with product activity in the period |\n| Web visitors | Distinct pageview visitors on the saved website hostname, minus your product |\n\n**Signups.** Send `sprid_signup` from your server after the account is created, with the account id as `distinct_id`, and identify that id in your clients. Never fire it on login, page load or install. An existing `posthogEvents.signup` mapping also works; `posthogConfig.registration.event` wins over it. With no tracking start date, no matching event history reads as unavailable; with one, it reads as zero signups. A period that starts before the tracking date is counted from that date and says so. Comparisons crossing it, or with no confirmed start, are withheld. Backfill with the original creation timestamps.\n\n**Every signup surface has to send it.** An event your app sends after sign-up misses accounts made on your website, and the other way round: one app's in-app event missed 17% of its signups, which happened on its website. A server event covers every surface at once. Without one, list each surface's account-created event in `registration.events`; the first of any of them per identity counts, so an existing account signing in elsewhere is not a new signup. Never add a sign-in event that existing accounts also send. The marketing review flags a signup-like event on a surface where your registration event never fires.\n\nWhen an app reports signups, they are its user count on Home and in Insights. First product activity counts anonymous devices, so it moves into that card's detail.\n\n### What counts as your product\n\nSet this under **What counts as your product** on the same screen. It decides who is an active person, and it is the single setting most likely to make the people card wrong.\n\n| Your product is | Choose | Also give |\n|---|---|---|\n| An iOS or Android app | **A phone app** | Nothing. This is the default |\n| A web app on its own subdomain | **A website or web app** | The hostnames, such as `app.example.com` |\n| A web app under a path on your marketing domain | **A website or web app** | The paths, such as `/app`, `/dashboard` |\n| A phone app with a web client | **Both** | The web hostnames or paths |\n\n**A web product must say where it lives.** Your marketing pages and your product are the same PostHog events; without a hostname or a path there is nothing to tell them apart, and every visitor would be counted as someone using the product. Sprid refuses that configuration rather than reporting it.\n\nWhatever you name here is **subtracted from your website visitor numbers**, so a page inside the product is never also counted as a visit.\n\n**A phone app** needs `posthog-react-native` on iOS, iPadOS or Android and rejects explicit web surfaces: set `app_surface: 'web'` on Expo web and `'native'` on devices. Web counts need a browser and exclude reported bots and crawler user agents. Every metric excludes events or people with `is_internal`, `is_test` or `sprid_test = true`. What remains is observed identities, not guaranteed humans.\n\n### Keep your own testing out\n\nKeep known staff and test activity separate from customer metrics. Sprid applies\nits built-in exclusions and flags including `is_internal`, `is_test` and\n`sprid_test`; inspect the reading's filter notes to see what was excluded.\n\n- Flag verified staff or test identities on the events and person records that\n your queries use. Include anonymous test sessions when they can be identified\n safely, and apply the same convention to server events.\n- Use custom exclusions only for properties that reliably identify testing in\n this product. A common phone model, country or language also describes customers.\n\nFor example, a dedicated person property can identify verified staff accounts:\n\n```json\n{\"exclude\":[{\"scope\":\"person\",\"property\":\"staff\",\"operator\":\"in\",\"values\":[true]}]}\n```\n\nPossible store-review activity needs corroboration from release timing or known\ntest records. A measurement warning is a reason to investigate, not permission\nto exclude everyone sharing that device name, locale or geography.\n\n### Custom properties\n\nAdvanced rules go under **Custom properties**:\n\n```json\n{\"registration\":{\"identity\":{\"scope\":\"event\",\"property\":\"account_id\"}},\"app\":{\"kind\":\"web\",\"hosts\":[\"app.example.com\"]},\"exclude\":[{\"scope\":\"person\",\"property\":\"staff\",\"operator\":\"in\",\"values\":[true]}]}\n```\n\n- `app.kind` is `native`, `web` or `both`, with `app.hosts` and `app.pathPrefixes` saying where a web product lives. This is what the screen above writes.\n- `app.filters` replaces the whole definition with your own rule; all filters must match. Choosing it shows as **Custom rules** on the screen, and the rule is subtracted from web visitors the same way.\n- `exclude` removes matching traffic from every metric.\n- `registration.filters` narrows the registration event, for example `result = success`.\n- `registration.events` adds account-created events from other surfaces, counted with `registration.event` (see Signups above).\n- `entryUrls` lists the pages a plan, campaign or bio link sends people to, such as a web funnel's first step. The review flags one with almost no pageviews in 14 days, which usually means a link stopped pointing at it.\n- `registration.accounts: false` says the app has no accounts (a local-first app, or one that identifies by device). Registrations then read as not applicable instead of a missing event, and the weekly email stops listing them as unread.\n- `registration.identity` defaults to `person_id`. A configured property must be present and should never change, because it counts accounts.\n- Each rule takes `scope: event|person`, `operator: in|not_in` and string, number or boolean `values`. A missing property fails `in` and passes `not_in`. Property names are literal keys, dots included.\n\nThe same object is `posthogConfig` in REST `PATCH /api/app-profiles/:id`, MCP `upsert_app_profile` and the App Profile JSON used by `sprid init`, where `registration.event` and `registration.since` also live. No credentials or SQL belong in it. After saving, fetch Insights and check its registration notes and totals against your database before trusting a funnel.\n\n### Paywall and purchase events\n\nA paywall event that says only that a paywall appeared cannot tell a price change from a paywall that failed to load: both read as fewer sales. One app's Android paywall had no purchasable product for five months and its revenue view read as zero demand. Send at least these, and map the events under `posthogEvents` as `paywallShown` and `purchase`:\n\n| Event | Properties |\n|---|---|\n| Paywall shown | `price`, `currency`, `product_id`, `offering_id`, `offering_loaded` (true once the store returned a product), `has_free_phase`, `store_country` |\n| Purchase or trial started, after the store confirms it | `price`, `currency`, `product_id`, `has_free_phase` |\n| Purchase failed | `product_id`, the store's error code |\n\nReport a billing error to your error tracker too, never only to the console. The marketing review flags a platform that shows the paywall to many people and never sells, and paywall events that carry none of these properties."
364
364
  },
365
365
  {
366
366
  "id": "countries-sources-and-entry-pages",
@@ -399,8 +399,8 @@ export const GUIDES = [
399
399
  "markdown": "Setup and live reads checked 2026-09-09.\n\n- [PostHog personal API keys](https://posthog.com/docs/api/personal-api-keys)\n- [Project identity endpoint](https://posthog.com/docs/api/projects)\n- [HogQL query endpoint](https://posthog.com/docs/api/query)\n- [SDK and framework guides](https://posthog.com/docs/libraries)\n- [React Native screen tracking](https://posthog.com/docs/libraries/react-native)\n- [Identifying users](https://posthog.com/docs/product-analytics/identify)\n- [Funnels](https://posthog.com/docs/product-analytics/funnels)"
400
400
  }
401
401
  ],
402
- "markdown": "# PostHog\n\n**What Sprid does with this:** See how people use your app and where they stop.\n\n## You need\n\nAccess to your app’s PostHog project and permission to create a **personal API key**. Your app must already send events to PostHog; connecting Sprid adds no tracking. The public key inside your app does not work here.\n\n## Click path (us.posthog.com or eu.posthog.com)\n\n1. **Settings → Account → Personal API keys → Create personal API key**, named `Sprid <app name>`.\n2. Under access, select **Projects** and choose your app’s project. Leave **All access** off.\n3. Select **Query → Read** and **Project → Read**. Leave write access off.\n4. Create the key and keep the dialog open: PostHog shows it once. Use a separate key per app.\n5. Copy the key and leave it on your clipboard.\n\n## Project id and host\n\n- **Project id:** the number after `/project/` in your PostHog address, such as `12345`.\n- **Host:** `eu` for `eu.posthog.com`, `us` for `us.posthog.com`, or your full self-hosted address, whichever you open the dashboard on.\n\n## Then run\n\n```sh\nsprid connect posthog --app myapp --key-from-clipboard --project 12345 --host eu\n```\n\nUse your own app slug, project id and host. Add `--workspace <slug>` if needed. Keep the key out of chat.\n\nSprid reads the key from your clipboard, saves it and clears the clipboard, so it never shows on screen or lands in a file. Working with an agent? Tell it the key is copied and it runs this for you. Typing it yourself, copy the key last: paste the command into your terminal first, then copy the key, then press Enter. If the clipboard still holds the command, Sprid refuses it; copy the key and run it again. Without clipboard access (a remote shell), save the key to a file and pass `--key <file>` instead.\n\n## How to check it worked\n\nAsk your agent: “Check that Sprid can read recent PostHog events for this app, and confirm the project is correct.” The check reports recent activity or explains why there is none; a saved connection alone does not prove events arrive.\n\n## Metric definitions\n\nIn **Settings → Apps → your app → PostHog**, set the account-created event and the first UTC date from which tracking is complete.\n\n| Metric | What counts |\n|---|---|\n| Signups | First `sprid_signup` event per PostHog person, across all surfaces |\n| New product users | First product activity, including anonymous people |\n| Active product users | Distinct people with product activity in the period |\n| Web visitors | Distinct pageview visitors on the saved website hostname, minus your product |\n\n**Signups.** Send `sprid_signup` from your server after the account is created, with the account id as `distinct_id`, and identify that id in your clients. Never fire it on login, page load or install. An existing `posthogEvents.signup` mapping also works; `posthogConfig.registration.event` wins over it. With no tracking start date, no matching event history reads as unavailable; with one, it reads as zero signups. A period that starts before the tracking date is counted from that date and says so. Comparisons crossing it, or with no confirmed start, are withheld. Backfill with the original creation timestamps.\n\n**Every signup surface has to send it.** An event your app sends after sign-up misses accounts made on your website, and the other way round: one app's in-app event missed 17% of its signups, which happened on its website. A server event covers every surface at once. Without one, list each surface's account-created event in `registration.events`; the first of any of them per identity counts, so an existing account signing in elsewhere is not a new signup. Never add a sign-in event that existing accounts also send. The marketing review flags a signup-like event on a surface where your registration event never fires.\n\nWhen an app reports signups, they are its user count on Home and in Insights. First product activity counts anonymous devices, so it moves into that card's detail.\n\n### What counts as your product\n\nSet this under **What counts as your product** on the same screen. It decides who is an active person, and it is the single setting most likely to make the people card wrong.\n\n| Your product is | Choose | Also give |\n|---|---|---|\n| An iOS or Android app | **A phone app** | Nothing. This is the default |\n| A web app on its own subdomain | **A website or web app** | The hostnames, such as `app.example.com` |\n| A web app under a path on your marketing domain | **A website or web app** | The paths, such as `/app`, `/dashboard` |\n| A phone app with a web client | **Both** | The web hostnames or paths |\n\n**A web product must say where it lives.** Your marketing pages and your product are the same PostHog events; without a hostname or a path there is nothing to tell them apart, and every visitor would be counted as someone using the product. Sprid refuses that configuration rather than reporting it.\n\nWhatever you name here is **subtracted from your website visitor numbers**, so a page inside the product is never also counted as a visit.\n\n**A phone app** needs `posthog-react-native` on iOS, iPadOS or Android and rejects explicit web surfaces: set `app_surface: 'web'` on Expo web and `'native'` on devices. Web counts need a browser and exclude reported bots and crawler user agents. Every metric excludes events or people with `is_internal`, `is_test` or `sprid_test = true`. What remains is observed identities, not guaranteed humans.\n\n### Keep your own testing out\n\nOn a new app, your own phone, simulators and store reviewers can outnumber real people. Sprid already drops Google Play's pre-launch test devices, events reporting `$is_emulator`, reported bots, and anything flagged `is_internal`, `is_test` or `sprid_test`. Two things are yours to add:\n\n- **Flag your own accounts.** After sign-in, register `is_internal: true` when the account's email is on your own domain, store-review accounts included, and set it on the person if you identify. A web client that never identifies has to put it on every event, before the first pageview. Do the same on the server event that records a signup.\n- **List your own devices** under `exclude`, for what happens before sign-in, such as a fresh install. Open your app, then read the newest events in PostHog for the `$device_name` values:\n\n```json\n{\"exclude\":[{\"scope\":\"event\",\"property\":\"$device_name\",\"operator\":\"in\",\"values\":[\"Pixel 8\",\"Simulator iOS\",\"sdk_gphone64_arm64\"]}]}\n```\n\nApple's review devices have reported `$device_name` `iPhone99,7`, with locale `en-US` and time zone `US/Pacific`, in every app we checked (September 2026). Add it if it shows up in yours.\n\nTwo more Apple populations pass every default rule, because they are real iPhones: the device that checks each uploaded build (a fresh US person per build, often Cupertino, locale a bare `en` where real phones send `en-US`) and App Review (US, locale `zh-Hans`, about one a day). Both are active for one day and never reach a paywall: one app counted 14 of them as installs in two months. The marketing review flags them with their counts. Exclude a locale only after checking none of your real users send it.\n\nGoogle Play's pre-launch devices are covered by default: in the app we checked, every `OnePlus8Pro` event carried the fleet's screen width and every `sdk_gphone` event carried `$is_emulator` (2026-10-03). If yours sends neither property, list the device names under `exclude`.\n\n### Custom properties\n\nAdvanced rules go under **Custom properties**:\n\n```json\n{\"registration\":{\"identity\":{\"scope\":\"event\",\"property\":\"account_id\"}},\"app\":{\"kind\":\"web\",\"hosts\":[\"app.example.com\"]},\"exclude\":[{\"scope\":\"person\",\"property\":\"staff\",\"operator\":\"in\",\"values\":[true]}]}\n```\n\n- `app.kind` is `native`, `web` or `both`, with `app.hosts` and `app.pathPrefixes` saying where a web product lives. This is what the screen above writes.\n- `app.filters` replaces the whole definition with your own rule; all filters must match. Choosing it shows as **Custom rules** on the screen, and the rule is subtracted from web visitors the same way.\n- `exclude` removes matching traffic from every metric.\n- `registration.filters` narrows the registration event, for example `result = success`.\n- `registration.events` adds account-created events from other surfaces, counted with `registration.event` (see Signups above).\n- `entryUrls` lists the pages a plan, campaign or bio link sends people to, such as a web funnel's first step. The review flags one with almost no pageviews in 14 days, which usually means a link stopped pointing at it.\n- `registration.accounts: false` says the app has no accounts (a local-first app, or one that identifies by device). Registrations then read as not applicable instead of a missing event, and the weekly email stops listing them as unread.\n- `registration.identity` defaults to `person_id`. A configured property must be present and should never change, because it counts accounts.\n- Each rule takes `scope: event|person`, `operator: in|not_in` and string, number or boolean `values`. A missing property fails `in` and passes `not_in`. Property names are literal keys, dots included.\n\nThe same object is `posthogConfig` in REST `PATCH /api/app-profiles/:id`, MCP `upsert_app_profile` and the App Profile JSON used by `sprid init`, where `registration.event` and `registration.since` also live. No credentials or SQL belong in it. After saving, fetch Insights and check its registration notes and totals against your database before trusting a funnel.\n\n### Paywall and purchase events\n\nA paywall event that says only that a paywall appeared cannot tell a price change from a paywall that failed to load: both read as fewer sales. One app's Android paywall had no purchasable product for five months and its revenue view read as zero demand. Send at least these, and map the events under `posthogEvents` as `paywallShown` and `purchase`:\n\n| Event | Properties |\n|---|---|\n| Paywall shown | `price`, `currency`, `product_id`, `offering_id`, `offering_loaded` (true once the store returned a product), `has_free_phase`, `store_country` |\n| Purchase or trial started, after the store confirms it | `price`, `currency`, `product_id`, `has_free_phase` |\n| Purchase failed | `product_id`, the store's error code |\n\nReport a billing error to your error tracker too, never only to the console. The marketing review flags a platform that shows the paywall to many people and never sells, and paywall events that carry none of these properties.\n\n## Countries, sources and entry pages\n\nEach website breakdown is one PostHog property on each `$pageview`. posthog-js fills them all itself, so a site that loads it with default settings needs nothing here. They go missing when something else sends the events, and the breakdown then reads **Unknown**. Insights and `get_insights` (`traffic.gaps`) name which one is missing, why, and the fix.\n\n| Breakdown | Property Sprid reads | posthog-js sets it from |\n|---|---|---|\n| Country, region, city | `$geoip_country_code`, `$geoip_subdivision_1_name`, `$geoip_city_name` | PostHog’s GeoIP lookup of the visitor’s IP |\n| Source | `$referring_domain` | The referrer’s hostname, or `$direct` when there is none, on every page of the visit |\n| Entry and exit page, sessions, bounce, time on site | `$session_id` | One id per visit |\n| Browser, OS, device | `$browser`, `$os`, `$device_type` | The user agent |\n| Pages, hostname | `$pathname`, `$host` | The page address |\n\nWebsite events also need `$lib: \"web\"`; Sprid tells your site apart from your app by it.\n\nThree setups lose the country, and each has its own fix:\n\n- **Cookieless mode** (`cookieless_mode: \"always\"`): PostHog does not geolocate cookieless events, so every country is Unknown. Register the country yourself before the first pageview, from a value your host already has. On Cloudflare that is `request.cf.country`, the `CF-IPCountry` header, or `loc=` in `/cdn-cgi/trace` on your own domain: `posthog.register({ $geoip_country_code: \"SE\" })`. Or turn cookieless mode off if your privacy policy allows it.\n- **Your own relay or server** (a Worker, an API route or a proxy that calls PostHog’s capture endpoint): PostHog sees the server’s IP, or none if you set `$geoip_disable: true`. Set `$geoip_country_code` in the relay. A property called `country` does not count: nothing in PostHog or Sprid reads it.\n- **\"Discard client IP data\"** under Project settings, or the GeoIP transformation switched off under Data pipelines: no event in the project gets a location. Turn it back on, or supply the country as above.\n\nA relay also has to send what posthog-js would have: `$referring_domain` on every pageview (`$direct` when the visit had no referrer, and the landing page’s referrer on every later page of the same visit), and a `$session_id` that stays the same for the whole visit. For example, in a Cloudflare Worker:\n\n```js\nproperties: {\n ...props,\n $lib: \"web\",\n $host: \"example.com\",\n $pathname: path,\n $session_id: visitId,\n $referring_domain: visitReferrer || \"$direct\",\n $geoip_disable: true, // PostHog never sees the IP\n $geoip_country_code: request.cf?.country ?? null,\n}\n```\n\nA visit whose source is `localhost` is someone on a development build clicking through to your live site. Stop capturing on development hosts, or exclude it as below.\n\n## Review suspected automated traffic\n\nRead [Check whether a traffic spike is real](https://sprid.studio/docs/traffic) (`sprid docs traffic`) before saving a rule: a spike or a shared fingerprint alone does not prove bots.\n\nNot every crawler arrives as a spike. One site's September was 84% people on desktop, with no referrer, from one country, most of them for one pageview, with a normal Chrome user agent that no bot filter catches. The marketing review flags a desktop, direct, single-country group above half of website people; confirm it here before excluding anything.\n\nOpen **Web visitors → Review traffic exclusions**, or **Settings → Apps → your app → PostHog → Review traffic**. Pick a UTC date range and a traffic group, preview the effect, then save. Agents use MCP `review_traffic` or `POST /api/app-profiles/:ref/traffic-review?workspaceId=…` with `{\"start\":\"2026-09-18\",\"end\":\"2026-09-19\"}`: end dates exclusive, UTC, at most 31 days. An optional `exclusion` previews a rule against this period and the one before. Save approved rules in `posthogConfig.trafficExclusions` through `upsert_app_profile` or the profile PATCH route, keeping the rest of the configuration. Sprid records who edited it and when.\n\nHow exclusions apply:\n\n- App-specific and date-bounded. Properties inside a rule are AND; enabled rules are OR.\n- Applied to website totals, charts, breakdowns, social attribution and weekly website counts, comparison period included. Remaining visitors are recounted as distinct people, never subtracted.\n- Native app activity and registrations are unchanged, and PostHog’s data is never changed or deleted. **Restore this traffic** disables a rule.\n- Raw connected queries and the marketing-review event inventory stay unfiltered as evidence; Insights shows the corrected figures.\n- Only PostHog is supported today.\n\n## Social traffic and clip comparisons\n\nSave the app’s website URL on its App Profile as well as connecting PostHog. Insights then compares completed-day website sessions with recorded social view gains. Shared bio traffic stays at channel level; only a dedicated tagged link identifies one publish. Older clips with metric activity stay candidates.\n\nCopy the stable bio link and dedicated clip links from Insights. Keep `utm_source`, `utm_medium`, `sprid_account` and, on dedicated links only, `sprid_publish` through any redirects. Never repoint the shared bio link to the newest publish. The optional bio-page HTML export records selections separately and keeps a direct app link; host it on the saved hostname with your existing PostHog setup.\n\nTo capture store-link clicks and website outcomes, add after your PostHog initialization:\n\n```html\n<script src=\"https://sprid.studio/sprid-attribution.js\" defer></script>\n```\n\nIt uses your PostHog client and consent state and sends nothing to Sprid. Call `window.spridAttribution?.track('signup')` or `.track('activation')` only after that action succeeds. `posthogEvents.signup` and `posthogEvents.activation` mappings also count when the event shares the arriving website session. It does not track a native app or join visits to purchases, and a store click is not an install. Missing outcomes stay unmeasured. Redirect-only flows need a beacon before navigating; redirect events are counted apart from sessions. `get_insights` and `sprid insights` return the same evidence as the dashboard.\n\n## If it fails\n\n- **Access denied:** check the key is active, grants your project and has both Read permissions. Reconnect with a corrected key file.\n- **Project not found:** check project number and host together. A US project needs the US host.\n- **Connected but no events:** check the dates and project, then ask your agent to check your app’s tracking is sending events.\n\n## Investigate with this connection\n\nReads `query` (HogQL), `events` and `properties` for the pinned project. Check instrumentation before interpreting events. Event-definition discovery also needs `event_definition:read`, property-definition discovery `property_definition:read`, both restricted to the same project.\n\n```sh\nsprid marketing-review capabilities --app <slug> --source posthog --json\n```\n\nSee [connected queries](https://sprid.studio/docs/queries).\n\n## Sources and verification\n\nSetup and live reads checked 2026-09-09.\n\n- [PostHog personal API keys](https://posthog.com/docs/api/personal-api-keys)\n- [Project identity endpoint](https://posthog.com/docs/api/projects)\n- [HogQL query endpoint](https://posthog.com/docs/api/query)\n- [SDK and framework guides](https://posthog.com/docs/libraries)\n- [React Native screen tracking](https://posthog.com/docs/libraries/react-native)\n- [Identifying users](https://posthog.com/docs/product-analytics/identify)\n- [Funnels](https://posthog.com/docs/product-analytics/funnels)\n",
403
- "revision": "a16dcc4e464cc8c2"
402
+ "markdown": "# PostHog\n\n**What Sprid does with this:** See how people use your app and where they stop.\n\n## You need\n\nAccess to your app’s PostHog project and permission to create a **personal API key**. Your app must already send events to PostHog; connecting Sprid adds no tracking. The public key inside your app does not work here.\n\n## Click path (us.posthog.com or eu.posthog.com)\n\n1. **Settings → Account → Personal API keys → Create personal API key**, named `Sprid <app name>`.\n2. Under access, select **Projects** and choose your app’s project. Leave **All access** off.\n3. Select **Query → Read** and **Project → Read**. Leave write access off.\n4. Create the key and keep the dialog open: PostHog shows it once. Use a separate key per app.\n5. Copy the key and leave it on your clipboard.\n\n## Project id and host\n\n- **Project id:** the number after `/project/` in your PostHog address, such as `12345`.\n- **Host:** `eu` for `eu.posthog.com`, `us` for `us.posthog.com`, or your full self-hosted address, whichever you open the dashboard on.\n\n## Then run\n\n```sh\nsprid connect posthog --app myapp --key-from-clipboard --project 12345 --host eu\n```\n\nUse your own app slug, project id and host. Add `--workspace <slug>` if needed. Keep the key out of chat.\n\nSprid reads the key from your clipboard, saves it and clears the clipboard, so it never shows on screen or lands in a file. Working with an agent? Tell it the key is copied and it runs this for you. Typing it yourself, copy the key last: paste the command into your terminal first, then copy the key, then press Enter. If the clipboard still holds the command, Sprid refuses it; copy the key and run it again. Without clipboard access (a remote shell), save the key to a file and pass `--key <file>` instead.\n\n## How to check it worked\n\nAsk your agent: “Check that Sprid can read recent PostHog events for this app, and confirm the project is correct.” The check reports recent activity or explains why there is none; a saved connection alone does not prove events arrive.\n\n## Metric definitions\n\nIn **Settings → Apps → your app → PostHog**, set the account-created event and the first UTC date from which tracking is complete.\n\n| Metric | What counts |\n|---|---|\n| Signups | First `sprid_signup` event per PostHog person, across all surfaces |\n| New product users | First product activity, including anonymous people |\n| Active product users | Distinct people with product activity in the period |\n| Web visitors | Distinct pageview visitors on the saved website hostname, minus your product |\n\n**Signups.** Send `sprid_signup` from your server after the account is created, with the account id as `distinct_id`, and identify that id in your clients. Never fire it on login, page load or install. An existing `posthogEvents.signup` mapping also works; `posthogConfig.registration.event` wins over it. With no tracking start date, no matching event history reads as unavailable; with one, it reads as zero signups. A period that starts before the tracking date is counted from that date and says so. Comparisons crossing it, or with no confirmed start, are withheld. Backfill with the original creation timestamps.\n\n**Every signup surface has to send it.** An event your app sends after sign-up misses accounts made on your website, and the other way round: one app's in-app event missed 17% of its signups, which happened on its website. A server event covers every surface at once. Without one, list each surface's account-created event in `registration.events`; the first of any of them per identity counts, so an existing account signing in elsewhere is not a new signup. Never add a sign-in event that existing accounts also send. The marketing review flags a signup-like event on a surface where your registration event never fires.\n\nWhen an app reports signups, they are its user count on Home and in Insights. First product activity counts anonymous devices, so it moves into that card's detail.\n\n### What counts as your product\n\nSet this under **What counts as your product** on the same screen. It decides who is an active person, and it is the single setting most likely to make the people card wrong.\n\n| Your product is | Choose | Also give |\n|---|---|---|\n| An iOS or Android app | **A phone app** | Nothing. This is the default |\n| A web app on its own subdomain | **A website or web app** | The hostnames, such as `app.example.com` |\n| A web app under a path on your marketing domain | **A website or web app** | The paths, such as `/app`, `/dashboard` |\n| A phone app with a web client | **Both** | The web hostnames or paths |\n\n**A web product must say where it lives.** Your marketing pages and your product are the same PostHog events; without a hostname or a path there is nothing to tell them apart, and every visitor would be counted as someone using the product. Sprid refuses that configuration rather than reporting it.\n\nWhatever you name here is **subtracted from your website visitor numbers**, so a page inside the product is never also counted as a visit.\n\n**A phone app** needs `posthog-react-native` on iOS, iPadOS or Android and rejects explicit web surfaces: set `app_surface: 'web'` on Expo web and `'native'` on devices. Web counts need a browser and exclude reported bots and crawler user agents. Every metric excludes events or people with `is_internal`, `is_test` or `sprid_test = true`. What remains is observed identities, not guaranteed humans.\n\n### Keep your own testing out\n\nKeep known staff and test activity separate from customer metrics. Sprid applies\nits built-in exclusions and flags including `is_internal`, `is_test` and\n`sprid_test`; inspect the reading's filter notes to see what was excluded.\n\n- Flag verified staff or test identities on the events and person records that\n your queries use. Include anonymous test sessions when they can be identified\n safely, and apply the same convention to server events.\n- Use custom exclusions only for properties that reliably identify testing in\n this product. A common phone model, country or language also describes customers.\n\nFor example, a dedicated person property can identify verified staff accounts:\n\n```json\n{\"exclude\":[{\"scope\":\"person\",\"property\":\"staff\",\"operator\":\"in\",\"values\":[true]}]}\n```\n\nPossible store-review activity needs corroboration from release timing or known\ntest records. A measurement warning is a reason to investigate, not permission\nto exclude everyone sharing that device name, locale or geography.\n\n### Custom properties\n\nAdvanced rules go under **Custom properties**:\n\n```json\n{\"registration\":{\"identity\":{\"scope\":\"event\",\"property\":\"account_id\"}},\"app\":{\"kind\":\"web\",\"hosts\":[\"app.example.com\"]},\"exclude\":[{\"scope\":\"person\",\"property\":\"staff\",\"operator\":\"in\",\"values\":[true]}]}\n```\n\n- `app.kind` is `native`, `web` or `both`, with `app.hosts` and `app.pathPrefixes` saying where a web product lives. This is what the screen above writes.\n- `app.filters` replaces the whole definition with your own rule; all filters must match. Choosing it shows as **Custom rules** on the screen, and the rule is subtracted from web visitors the same way.\n- `exclude` removes matching traffic from every metric.\n- `registration.filters` narrows the registration event, for example `result = success`.\n- `registration.events` adds account-created events from other surfaces, counted with `registration.event` (see Signups above).\n- `entryUrls` lists the pages a plan, campaign or bio link sends people to, such as a web funnel's first step. The review flags one with almost no pageviews in 14 days, which usually means a link stopped pointing at it.\n- `registration.accounts: false` says the app has no accounts (a local-first app, or one that identifies by device). Registrations then read as not applicable instead of a missing event, and the weekly email stops listing them as unread.\n- `registration.identity` defaults to `person_id`. A configured property must be present and should never change, because it counts accounts.\n- Each rule takes `scope: event|person`, `operator: in|not_in` and string, number or boolean `values`. A missing property fails `in` and passes `not_in`. Property names are literal keys, dots included.\n\nThe same object is `posthogConfig` in REST `PATCH /api/app-profiles/:id`, MCP `upsert_app_profile` and the App Profile JSON used by `sprid init`, where `registration.event` and `registration.since` also live. No credentials or SQL belong in it. After saving, fetch Insights and check its registration notes and totals against your database before trusting a funnel.\n\n### Paywall and purchase events\n\nA paywall event that says only that a paywall appeared cannot tell a price change from a paywall that failed to load: both read as fewer sales. One app's Android paywall had no purchasable product for five months and its revenue view read as zero demand. Send at least these, and map the events under `posthogEvents` as `paywallShown` and `purchase`:\n\n| Event | Properties |\n|---|---|\n| Paywall shown | `price`, `currency`, `product_id`, `offering_id`, `offering_loaded` (true once the store returned a product), `has_free_phase`, `store_country` |\n| Purchase or trial started, after the store confirms it | `price`, `currency`, `product_id`, `has_free_phase` |\n| Purchase failed | `product_id`, the store's error code |\n\nReport a billing error to your error tracker too, never only to the console. The marketing review flags a platform that shows the paywall to many people and never sells, and paywall events that carry none of these properties.\n\n## Countries, sources and entry pages\n\nEach website breakdown is one PostHog property on each `$pageview`. posthog-js fills them all itself, so a site that loads it with default settings needs nothing here. They go missing when something else sends the events, and the breakdown then reads **Unknown**. Insights and `get_insights` (`traffic.gaps`) name which one is missing, why, and the fix.\n\n| Breakdown | Property Sprid reads | posthog-js sets it from |\n|---|---|---|\n| Country, region, city | `$geoip_country_code`, `$geoip_subdivision_1_name`, `$geoip_city_name` | PostHog’s GeoIP lookup of the visitor’s IP |\n| Source | `$referring_domain` | The referrer’s hostname, or `$direct` when there is none, on every page of the visit |\n| Entry and exit page, sessions, bounce, time on site | `$session_id` | One id per visit |\n| Browser, OS, device | `$browser`, `$os`, `$device_type` | The user agent |\n| Pages, hostname | `$pathname`, `$host` | The page address |\n\nWebsite events also need `$lib: \"web\"`; Sprid tells your site apart from your app by it.\n\nThree setups lose the country, and each has its own fix:\n\n- **Cookieless mode** (`cookieless_mode: \"always\"`): PostHog does not geolocate cookieless events, so every country is Unknown. Register the country yourself before the first pageview, from a value your host already has. On Cloudflare that is `request.cf.country`, the `CF-IPCountry` header, or `loc=` in `/cdn-cgi/trace` on your own domain: `posthog.register({ $geoip_country_code: \"SE\" })`. Or turn cookieless mode off if your privacy policy allows it.\n- **Your own relay or server** (a Worker, an API route or a proxy that calls PostHog’s capture endpoint): PostHog sees the server’s IP, or none if you set `$geoip_disable: true`. Set `$geoip_country_code` in the relay. A property called `country` does not count: nothing in PostHog or Sprid reads it.\n- **\"Discard client IP data\"** under Project settings, or the GeoIP transformation switched off under Data pipelines: no event in the project gets a location. Turn it back on, or supply the country as above.\n\nA relay also has to send what posthog-js would have: `$referring_domain` on every pageview (`$direct` when the visit had no referrer, and the landing page’s referrer on every later page of the same visit), and a `$session_id` that stays the same for the whole visit. For example, in a Cloudflare Worker:\n\n```js\nproperties: {\n ...props,\n $lib: \"web\",\n $host: \"example.com\",\n $pathname: path,\n $session_id: visitId,\n $referring_domain: visitReferrer || \"$direct\",\n $geoip_disable: true, // PostHog never sees the IP\n $geoip_country_code: request.cf?.country ?? null,\n}\n```\n\nA visit whose source is `localhost` is someone on a development build clicking through to your live site. Stop capturing on development hosts, or exclude it as below.\n\n## Review suspected automated traffic\n\nRead [Check whether a traffic spike is real](https://sprid.studio/docs/traffic) (`sprid docs traffic`) before saving a rule: a spike or a shared fingerprint alone does not prove bots.\n\nNot every crawler arrives as a spike. One site's September was 84% people on desktop, with no referrer, from one country, most of them for one pageview, with a normal Chrome user agent that no bot filter catches. The marketing review flags a desktop, direct, single-country group above half of website people; confirm it here before excluding anything.\n\nOpen **Web visitors → Review traffic exclusions**, or **Settings → Apps → your app → PostHog → Review traffic**. Pick a UTC date range and a traffic group, preview the effect, then save. Agents use MCP `review_traffic` or `POST /api/app-profiles/:ref/traffic-review?workspaceId=…` with `{\"start\":\"2026-09-18\",\"end\":\"2026-09-19\"}`: end dates exclusive, UTC, at most 31 days. An optional `exclusion` previews a rule against this period and the one before. Save approved rules in `posthogConfig.trafficExclusions` through `upsert_app_profile` or the profile PATCH route, keeping the rest of the configuration. Sprid records who edited it and when.\n\nHow exclusions apply:\n\n- App-specific and date-bounded. Properties inside a rule are AND; enabled rules are OR.\n- Applied to website totals, charts, breakdowns, social attribution and weekly website counts, comparison period included. Remaining visitors are recounted as distinct people, never subtracted.\n- Native app activity and registrations are unchanged, and PostHog’s data is never changed or deleted. **Restore this traffic** disables a rule.\n- Raw connected queries and the marketing-review event inventory stay unfiltered as evidence; Insights shows the corrected figures.\n- Only PostHog is supported today.\n\n## Social traffic and clip comparisons\n\nSave the app’s website URL on its App Profile as well as connecting PostHog. Insights then compares completed-day website sessions with recorded social view gains. Shared bio traffic stays at channel level; only a dedicated tagged link identifies one publish. Older clips with metric activity stay candidates.\n\nCopy the stable bio link and dedicated clip links from Insights. Keep `utm_source`, `utm_medium`, `sprid_account` and, on dedicated links only, `sprid_publish` through any redirects. Never repoint the shared bio link to the newest publish. The optional bio-page HTML export records selections separately and keeps a direct app link; host it on the saved hostname with your existing PostHog setup.\n\nTo capture store-link clicks and website outcomes, add after your PostHog initialization:\n\n```html\n<script src=\"https://sprid.studio/sprid-attribution.js\" defer></script>\n```\n\nIt uses your PostHog client and consent state and sends nothing to Sprid. Call `window.spridAttribution?.track('signup')` or `.track('activation')` only after that action succeeds. `posthogEvents.signup` and `posthogEvents.activation` mappings also count when the event shares the arriving website session. It does not track a native app or join visits to purchases, and a store click is not an install. Missing outcomes stay unmeasured. Redirect-only flows need a beacon before navigating; redirect events are counted apart from sessions. `get_insights` and `sprid insights` return the same evidence as the dashboard.\n\n## If it fails\n\n- **Access denied:** check the key is active, grants your project and has both Read permissions. Reconnect with a corrected key file.\n- **Project not found:** check project number and host together. A US project needs the US host.\n- **Connected but no events:** check the dates and project, then ask your agent to check your app’s tracking is sending events.\n\n## Investigate with this connection\n\nReads `query` (HogQL), `events` and `properties` for the pinned project. Check instrumentation before interpreting events. Event-definition discovery also needs `event_definition:read`, property-definition discovery `property_definition:read`, both restricted to the same project.\n\n```sh\nsprid marketing-review capabilities --app <slug> --source posthog --json\n```\n\nSee [connected queries](https://sprid.studio/docs/queries).\n\n## Sources and verification\n\nSetup and live reads checked 2026-09-09.\n\n- [PostHog personal API keys](https://posthog.com/docs/api/personal-api-keys)\n- [Project identity endpoint](https://posthog.com/docs/api/projects)\n- [HogQL query endpoint](https://posthog.com/docs/api/query)\n- [SDK and framework guides](https://posthog.com/docs/libraries)\n- [React Native screen tracking](https://posthog.com/docs/libraries/react-native)\n- [Identifying users](https://posthog.com/docs/product-analytics/identify)\n- [Funnels](https://posthog.com/docs/product-analytics/funnels)\n",
403
+ "revision": "8ae7cbb023b9c925"
404
404
  },
405
405
  {
406
406
  "id": "google-analytics",
@@ -620,7 +620,7 @@ export const GUIDES = [
620
620
  "id": "see-who-buys",
621
621
  "title": "See who buys",
622
622
  "kind": "detail",
623
- "markdown": "This key tells Sprid how much you sold, split by country, store, product and, when the app sends attribution to RevenueCat, by campaign. It cannot tell which visitor or user became a buyer. More permissions on this key would not change that: RevenueCat’s API reads customers one at a time.\n\nTo see who buys, send RevenueCat’s purchase events to the tool that already knows your users, under the same user id:\n\n1. In the app, give RevenueCat the id your analytics tool uses. Either log the user in with it (`Purchases.logIn`), or set the attribute the integration’s own RevenueCat page names.\n2. In RevenueCat, open **Integrations** and turn on the one you use: Amplitude, Mixpanel, PostHog, Segment, mParticle, Firebase (which reaches Google Analytics), or an attribution provider such as AppsFlyer or Adjust. For anything else, use a webhook or a scheduled export to your warehouse.\n3. Ask the question in that tool: which sources, pages or onboarding steps come before a purchase.\n\n**The PostHog integration moves every payer to the US.** RevenueCat sends its events from its own servers, so PostHog's GeoIP gives each `rc_` event the server's location (Ashburn, Virginia on every event we measured) and sets it on the person. After the first purchase, person-level country reads US for everyone who paid. Read buyer country from RevenueCat's country breakdown, which is the store country, or from a purchase event your app sends itself; never from person properties. To stop the overwrite, exclude `rc_` events from GeoIP enrichment in PostHog's data pipeline settings, if your plan allows filtering it. Sprid's own country breakdowns already come from RevenueCat, and its review flags this pattern when it finds it.\n\nCookieless analytics such as Plausible count visits without knowing who made them, so there is no user to match. Their UTM sources still line up with RevenueCat’s campaign breakdown as totals."
623
+ "markdown": "This key tells Sprid how much you sold, split by country, store, product and, when the app sends attribution to RevenueCat, by campaign. It cannot tell which visitor or user became a buyer. More permissions on this key would not change that: RevenueCat’s API reads customers one at a time.\n\nTo see who buys, send RevenueCat’s purchase events to the tool that already knows your users, under the same user id:\n\n1. In the app, give RevenueCat the id your analytics tool uses. Either log the user in with it (`Purchases.logIn`), or set the attribute the integration’s own RevenueCat page names.\n2. In RevenueCat, open **Integrations** and turn on the one you use: Amplitude, Mixpanel, PostHog, Segment, mParticle, Firebase (which reaches Google Analytics), or an attribution provider such as AppsFlyer or Adjust. For anything else, use a webhook or a scheduled export to your warehouse.\n3. Ask the question in that tool: which sources, pages or onboarding steps come before a purchase.\n\n**Verify geography on server-sent purchase events.** GeoIP enrichment can use\nthe integration server's location rather than the buyer's. Check event and person\nproperties before reporting buyer country. Use a verified store-country breakdown\nor an app-supplied purchase property where available. If server events overwrite\nperson geography, inspect the analytics pipeline's enrichment settings before\nchanging them. Sprid's review flags suspected cases for investigation.\n\nCookieless analytics such as Plausible count visits without knowing who made them, so there is no user to match. Their UTM sources still line up with RevenueCat’s campaign breakdown as totals."
624
624
  },
625
625
  {
626
626
  "id": "if-it-fails",
@@ -641,8 +641,8 @@ export const GUIDES = [
641
641
  "markdown": "Setup and live reads checked 2026-09-09.\n\n- [RevenueCat API keys](https://www.revenuecat.com/docs/projects/authentication)\n- [API V2 reference](https://www.revenuecat.com/docs/api-v2)"
642
642
  }
643
643
  ],
644
- "markdown": "# RevenueCat\n\n**What Sprid does with this:** See subscription revenue and how it changes over time.\n\n## You need\n\n**Admin** access to the RevenueCat project, to create a dedicated **V2 secret API key**. The app’s public key and V1 keys do not work.\n\n## Click path (app.revenuecat.com)\n\n1. Select your project → **API keys → Secret API keys → New secret API key**.\n2. Name it `Sprid analytics` and select **V2**.\n3. **Charts metrics permissions**: set **Overview Configuration Access Level** and **Charts Configuration Access Level** to **Read only**.\n4. **Project configuration permissions**: set **Apps Configuration Access Level** to **Read only**. Leave everything else at **No access**.\n5. Click **Generate**. Use a separate key per app.\n6. Copy the key and leave it on your clipboard.\n\n## Project id\n\n**Project settings → General → Project ID**, such as `proj1ab2c3d4`. Not the shorter id in the dashboard address.\n\n## Then run\n\n```sh\nsprid connect revenuecat --app myapp --key-from-clipboard --project proj1ab2c3d4\n```\n\nUse your own app slug and project id. Add `--workspace <slug>` if needed. Keep the key out of chat.\n\nSprid reads the key from your clipboard, saves it and clears the clipboard, so it never shows on screen or lands in a file. Working with an agent? Tell it the key is copied and it runs this for you. Typing it yourself, copy the key last: paste the command into your terminal first, then copy the key, then press Enter. If the clipboard still holds the command, Sprid refuses it; copy the key and run it again. Without clipboard access (a remote shell), save the key to a file and pass `--key <file>` instead.\n\n## How to check it worked\n\nAsk your agent: “Check that Sprid can read this app’s RevenueCat overview and daily revenue history. Report any missing dates.” A saved key alone does not confirm access. The overview can work while history does not; missing dates are not zero sales.\n\n## If you also sell on the web\n\nConnect the services that record the other sales. Sprid withholds a combined total when RevenueCat may already include the same Stripe or Paddle sales, or when currencies or definitions differ. RevenueCat Web Billing and your own Stripe are separate merchant accounts, so they never overlap.\n\n## See who buys\n\nThis key tells Sprid how much you sold, split by country, store, product and, when the app sends attribution to RevenueCat, by campaign. It cannot tell which visitor or user became a buyer. More permissions on this key would not change that: RevenueCat’s API reads customers one at a time.\n\nTo see who buys, send RevenueCat’s purchase events to the tool that already knows your users, under the same user id:\n\n1. In the app, give RevenueCat the id your analytics tool uses. Either log the user in with it (`Purchases.logIn`), or set the attribute the integration’s own RevenueCat page names.\n2. In RevenueCat, open **Integrations** and turn on the one you use: Amplitude, Mixpanel, PostHog, Segment, mParticle, Firebase (which reaches Google Analytics), or an attribution provider such as AppsFlyer or Adjust. For anything else, use a webhook or a scheduled export to your warehouse.\n3. Ask the question in that tool: which sources, pages or onboarding steps come before a purchase.\n\n**The PostHog integration moves every payer to the US.** RevenueCat sends its events from its own servers, so PostHog's GeoIP gives each `rc_` event the server's location (Ashburn, Virginia on every event we measured) and sets it on the person. After the first purchase, person-level country reads US for everyone who paid. Read buyer country from RevenueCat's country breakdown, which is the store country, or from a purchase event your app sends itself; never from person properties. To stop the overwrite, exclude `rc_` events from GeoIP enrichment in PostHog's data pipeline settings, if your plan allows filtering it. Sprid's own country breakdowns already come from RevenueCat, and its review flags this pattern when it finds it.\n\nCookieless analytics such as Plausible count visits without knowing who made them, so there is no user to match. Their UTM sources still line up with RevenueCat’s campaign breakdown as totals.\n\n## If it fails\n\n- **Key rejected:** check it is an active V2 secret key for this project.\n- **Access denied or history missing:** open the key’s **More → Edit**, check all three Read only settings, then **Submit**. No new key needed.\n- **Project not found:** copy the full Project ID from **Project settings → General**. Key and id must belong to the same project.\n- **Too many requests:** retry later. Another key will not help.\n\n## Investigate with this connection\n\nReads `chart_options`, `chart` and `subscriptions` for the pinned project. Read `chart_options` before picking dimensions, filters or resolution, and keep the returned units. `subscriptions` also needs `customer_information:subscriptions:read` and defaults to production; ask for sandbox to see test purchases.\n\n```sh\nsprid marketing-review capabilities --app <slug> --source revenuecat --json\n```\n\nSee [connected queries](https://sprid.studio/docs/queries).\n\n## Sources and verification\n\nSetup and live reads checked 2026-09-09.\n\n- [RevenueCat API keys](https://www.revenuecat.com/docs/projects/authentication)\n- [API V2 reference](https://www.revenuecat.com/docs/api-v2)\n",
645
- "revision": "3fae2d573ba6fa46"
644
+ "markdown": "# RevenueCat\n\n**What Sprid does with this:** See subscription revenue and how it changes over time.\n\n## You need\n\n**Admin** access to the RevenueCat project, to create a dedicated **V2 secret API key**. The app’s public key and V1 keys do not work.\n\n## Click path (app.revenuecat.com)\n\n1. Select your project → **API keys → Secret API keys → New secret API key**.\n2. Name it `Sprid analytics` and select **V2**.\n3. **Charts metrics permissions**: set **Overview Configuration Access Level** and **Charts Configuration Access Level** to **Read only**.\n4. **Project configuration permissions**: set **Apps Configuration Access Level** to **Read only**. Leave everything else at **No access**.\n5. Click **Generate**. Use a separate key per app.\n6. Copy the key and leave it on your clipboard.\n\n## Project id\n\n**Project settings → General → Project ID**, such as `proj1ab2c3d4`. Not the shorter id in the dashboard address.\n\n## Then run\n\n```sh\nsprid connect revenuecat --app myapp --key-from-clipboard --project proj1ab2c3d4\n```\n\nUse your own app slug and project id. Add `--workspace <slug>` if needed. Keep the key out of chat.\n\nSprid reads the key from your clipboard, saves it and clears the clipboard, so it never shows on screen or lands in a file. Working with an agent? Tell it the key is copied and it runs this for you. Typing it yourself, copy the key last: paste the command into your terminal first, then copy the key, then press Enter. If the clipboard still holds the command, Sprid refuses it; copy the key and run it again. Without clipboard access (a remote shell), save the key to a file and pass `--key <file>` instead.\n\n## How to check it worked\n\nAsk your agent: “Check that Sprid can read this app’s RevenueCat overview and daily revenue history. Report any missing dates.” A saved key alone does not confirm access. The overview can work while history does not; missing dates are not zero sales.\n\n## If you also sell on the web\n\nConnect the services that record the other sales. Sprid withholds a combined total when RevenueCat may already include the same Stripe or Paddle sales, or when currencies or definitions differ. RevenueCat Web Billing and your own Stripe are separate merchant accounts, so they never overlap.\n\n## See who buys\n\nThis key tells Sprid how much you sold, split by country, store, product and, when the app sends attribution to RevenueCat, by campaign. It cannot tell which visitor or user became a buyer. More permissions on this key would not change that: RevenueCat’s API reads customers one at a time.\n\nTo see who buys, send RevenueCat’s purchase events to the tool that already knows your users, under the same user id:\n\n1. In the app, give RevenueCat the id your analytics tool uses. Either log the user in with it (`Purchases.logIn`), or set the attribute the integration’s own RevenueCat page names.\n2. In RevenueCat, open **Integrations** and turn on the one you use: Amplitude, Mixpanel, PostHog, Segment, mParticle, Firebase (which reaches Google Analytics), or an attribution provider such as AppsFlyer or Adjust. For anything else, use a webhook or a scheduled export to your warehouse.\n3. Ask the question in that tool: which sources, pages or onboarding steps come before a purchase.\n\n**Verify geography on server-sent purchase events.** GeoIP enrichment can use\nthe integration server's location rather than the buyer's. Check event and person\nproperties before reporting buyer country. Use a verified store-country breakdown\nor an app-supplied purchase property where available. If server events overwrite\nperson geography, inspect the analytics pipeline's enrichment settings before\nchanging them. Sprid's review flags suspected cases for investigation.\n\nCookieless analytics such as Plausible count visits without knowing who made them, so there is no user to match. Their UTM sources still line up with RevenueCat’s campaign breakdown as totals.\n\n## If it fails\n\n- **Key rejected:** check it is an active V2 secret key for this project.\n- **Access denied or history missing:** open the key’s **More → Edit**, check all three Read only settings, then **Submit**. No new key needed.\n- **Project not found:** copy the full Project ID from **Project settings → General**. Key and id must belong to the same project.\n- **Too many requests:** retry later. Another key will not help.\n\n## Investigate with this connection\n\nReads `chart_options`, `chart` and `subscriptions` for the pinned project. Read `chart_options` before picking dimensions, filters or resolution, and keep the returned units. `subscriptions` also needs `customer_information:subscriptions:read` and defaults to production; ask for sandbox to see test purchases.\n\n```sh\nsprid marketing-review capabilities --app <slug> --source revenuecat --json\n```\n\nSee [connected queries](https://sprid.studio/docs/queries).\n\n## Sources and verification\n\nSetup and live reads checked 2026-09-09.\n\n- [RevenueCat API keys](https://www.revenuecat.com/docs/projects/authentication)\n- [API V2 reference](https://www.revenuecat.com/docs/api-v2)\n",
645
+ "revision": "6357f75a855e8df3"
646
646
  },
647
647
  {
648
648
  "id": "stripe",
@@ -1665,8 +1665,8 @@ export const CONTENT_GUIDES = [
1665
1665
  "title": "Use Sprid in chat",
1666
1666
  "summary": "Take an app from setup to a reviewed post and confirmed delivery using only your chat app and a browser. No CLI or installed skills needed.",
1667
1667
  "url": "https://sprid.studio/docs/chat",
1668
- "markdown": "# Use Sprid in chat\n\n**What this guide does:** Take an app from setup to a reviewed post and confirmed delivery using only your chat app and a browser. No CLI or installed skills needed.\n\nYou tell the agent what you want; it handles the tool calls below. The tool names are here so the agent knows the order. Setup should only ask for the connection the current task needs.\n\n## Connect Sprid\n\nChoose your chat at `https://sprid.studio/start#chat` and follow its steps. Your chat may call this an app, connector or integration. The address is `https://api.sprid.studio/api/mcp?surface=core`. Sign in to Sprid and authorize the right workspace.\n\nAgent: once the app exists, start with `get_marketing_plan` and reuse its prepared work and next step rather than starting over. Discovery can start from a website or store URL, with no social account.\n\n## Pick the app and account\n\nAgent: call `get_capabilities`, then `list_apps` and `list_accounts`, and reuse what exists. If the app is missing, get its description or website/store URL, confirm it and create it with `upsert_app_profile`. A content account owns voice and media; a channel is one exact social destination. Never pick a handle just because it is first in a list. If the app has no account, ask what to call it. Use `create_content_account` when your connector exposes it. If that tool is unavailable, open `https://app.sprid.studio/settings/accounts/new?workspaceId=<workspace-id>&app=<app-id>` with the selected app’s workspace and app IDs filled in. Ask the person to confirm that app and create the account, then read `list_accounts` again.\n\nThen ask whether the app has users yet. If it does, offer the analytics and revenue connections `next_actions` lists first: each link opens Sprid’s secure form, and the first recommendation then reads real numbers. If it has none yet, go on to the first post; connecting stays optional.\n\n## Connect a destination or data source\n\n- **Social channel:** `get_documentation` with the platform name, then `connect_channel`. The person opens the returned link and approves in the browser; `channel_status` confirms the saved result.\n- **Store or analytics:** the person enters identifiers and the key in the secure form at `https://app.sprid.studio/settings/app-profiles`. Credentials never go in chat, tool arguments or reports. A saved-key indicator only proves configuration; `get_marketing_review` does a live read.\n\n## Bring images or a finished video\n\nDraft copy in the chat, in the account’s voice. No extra model key needed.\n\nIf the chat exposes the attached files, pass them to `import_assets`: each file has `download_url` and `file_id`, optionally `mime_type` and `file_name`. Public HTTPS URLs and existing `{kind, id}` library references also work. Limits: PNG, JPEG, WebP or finished MP4, up to 32 MiB each. Each position reports on its own; retry the missing ones before building the post. Files are copied to the account and deduplicated by bytes; temporary download URLs are not kept.\n\nA `sandbox:` URL or local path can’t be fetched. For an image the chat generated, use the existing file if the host exposes it (not verified for every host); otherwise download it and use the browser upload. Never ask the agent to recreate an image or transcribe binary data.\n\n**Fallback: browser upload.** Call `create_upload_session` with the account and a new UUID `requestId`. The person opens the link and picks the files. Then read `get_studio_job` with the returned ID; its result keys are zero-based positions. The same session and asset IDs work from any other chat. No image-generation charge.\n\n## Create and review the post\n\nCall `create_post` with a new UUID `requestId`, the account, ordered `{kind, id}` assets, captions and aspect ratio. Reuse the same ID and inputs if a response is lost.\n\n- `presentation: \"finished\"` keeps artwork as-is: no text overlay, no added music. Images fit inside the canvas, which can add margins.\n- `presentation: \"editable\"` allows `text` and `subtitle` on image slides.\n- One finished video becomes one reel. Never mix a video with image slides.\n\nEdit with `update_post` (captions) and `update_slides` (slides). Call `preview_post` and check every slide or the video: captions, order, crop, legibility. It returns a small image of each slide for the agent to look at and an embedded preview for the person; a chat without embedded apps still shows the images. A preview is not approval to publish.\n\nAgent: show the person the preview, and link every post you mention with the `links` the tools return, as a Markdown link on its ref or title: `links.review` when the ask is to approve or schedule, `links.editor` when it is to change something. Never draw the slides in your own widget or artifact: those sandboxes block Sprid's media host and the images show broken.\n\n## Make a reel from photos or text\n\nCheck `get_capabilities` for hosted creation first. `create_reel` takes ordered scenes (`imageId`, `text`, `durationMs`), up to 120 seconds, and makes a silent MP4. It costs 3 Sprid credits per started render minute, or uses the plan allowance: check `spend_status`, tell the person, and get their go-ahead before sending `confirmed: true`. Save the job ID and read `get_studio_job`. Retrying with the same ID never starts a second render; a stopped job reports `needs_attention` instead of charging again.\n\nTo turn an existing post's slides into a video instead, use its own video: `get_video_capabilities`, then `get_post_video` and `edit_post_video` for transitions and motion, and `update_post {outputs}` for which destinations get it. Rendering it is metered the same way: `quote_post_video_render`, the person's go-ahead, then `render_post_video` with `confirmed: true`. Schedule a video destination with the render's `videoRenderId`.\n\nApp captures, simulator recordings and your own build scripts run outside remote MCP. Upload their finished files.\n\n## Approve and confirm delivery\n\nPublishing and scheduling require the person's approval of the preview and intended destinations. The browser review link remains available for picking channels, time and platform options.\n\nTikTok requires an explicit `postMode`; nothing defaults to a direct post or a draft:\n\n- `MEDIA_UPLOAD` sends the media to the creator's TikTok inbox. They finish the post in TikTok; report it as a draft.\n- `DIRECT_POST` requires `privacyLevel`, `disableComment`, `disableDuet`, `disableStitch`, `yourBrandToggle`, `brandedContentToggle` and `confirmed: true` in `options.tiktok`. Fetch fresh choices with `list_connections {includeTikTokCreatorInfo: true}` and show the creator identity, media and caption. The person chooses every setting for this post. Photo posts use `disableDuet: true` and `disableStitch: true`; comments respect the creator's restriction. Branded content cannot use `SELF_ONLY`.\n\nBefore sending `confirmed: true`, get the person's approval of this exact post and their agreement to TikTok's [Music Usage Confirmation](https://www.tiktok.com/legal/page/global/music-usage-confirmation/en), plus the [Branded Content Policy](https://www.tiktok.com/legal/page/global/bc-policy/en) for branded content. That flag is a caller's attestation, not proof of a click in TikTok's prescribed UI. Never invent choices or carry usual preferences into a new post. If choices are missing, open the returned `approvalUrl` for the person to answer in Sprid.\n\nAn approval covers the version the person saw. If you edit a scheduled direct post afterwards, the edit's result carries `tiktokApproval`: the scheduled post will not publish as it is. Show the person the change and the settings in its `rebook` call, ask whether to post this version, and only after they agree call `rebook` with `options.tiktok.confirmed: true` added. `post_queue` returns the same call while the approval is outstanding.\n\nTikTok's published guidelines specify a privacy dropdown and posting controls. Acceptance of this chat-based confirmation flow has not been verified with TikTok. Keep that distinction explicit in review submissions.\n\nAfter approval, read `post_queue`. Keep draft, scheduled, failed and delivered apart, and report delivery only with the platform receipt. Read a failure before retrying. Never repeat a publish call just because a response was lost.\n\nFor results, read `get_documentation` for `marketing-review`. Keep the same app, account and post IDs across clients. `next_actions` gives the next step; an empty `verb` means follow its browser link.\n\n## Limits and troubleshooting\n\n- **Attachment can’t be read:** use the browser upload, not the CLI.\n- **Missing account or service:** set it up in the dashboard. Never paste credentials into chat.\n- **Hosted creation:** check `get_capabilities` before promising it. Store screenshots and store releases are local unless it reports otherwise.\n- **File transfer:** whether a host exposes uploaded or generated files has to be checked per host. A supported schema doesn’t prove it.\n",
1669
- "revision": "65ac7d19472b2e66"
1668
+ "markdown": "# Use Sprid in chat\n\n**What this guide does:** Take an app from setup to a reviewed post and confirmed delivery using only your chat app and a browser. No CLI or installed skills needed.\n\nYou tell the agent what you want; it handles the tool calls below. The tool names are here so the agent knows the order. Setup should only ask for the connection the current task needs.\n\n## Connect Sprid\n\nChoose your chat at `https://sprid.studio/start#chat` and follow its steps. Your chat may call this an app, connector or integration. The address is `https://api.sprid.studio/api/mcp?surface=core`. Sign in to Sprid and authorize the right workspace.\n\nAgent: once the app exists, start with `get_marketing_plan` and reuse its prepared work and next step rather than starting over. Discovery can start from a website or store URL, with no social account.\n\n## Pick the app and account\n\nAgent: call `get_capabilities`, then `list_apps` and `list_accounts`, and reuse what exists. If the app is missing, get its description or website/store URL, confirm it and create it with `upsert_app_profile`. A content account owns voice and media; a channel is one exact social destination. Never pick a handle just because it is first in a list. If the app has no account, ask what to call it. Use `create_content_account` when your connector exposes it. If that tool is unavailable, open `https://app.sprid.studio/settings/accounts/new?workspaceId=<workspace-id>&app=<app-id>` with the selected app’s workspace and app IDs filled in. Ask the person to confirm that app and create the account, then read `list_accounts` again.\n\nThen ask whether the app has users yet. If it does, offer the analytics and revenue connections `next_actions` lists first: each link opens Sprid’s secure form, and the first recommendation then reads real numbers. If it has none yet, go on to the first post; connecting stays optional.\n\n## Connect a destination or data source\n\n- **Social channel:** `get_documentation` with the platform name, then `connect_channel`. The person opens the returned link and approves in the browser; `channel_status` confirms the saved result.\n- **Store or analytics:** the person enters identifiers and the key in the secure form at `https://app.sprid.studio/settings/app-profiles`. Credentials never go in chat, tool arguments or reports. A saved-key indicator only proves configuration; `get_marketing_review` reports source status and freshness; inspect those before treating data as current.\n\n## Bring images or a finished video\n\nDraft copy in the chat, in the account’s voice. No extra model key needed.\n\nIf the chat exposes the attached files, pass them to `import_assets`: each file has `download_url` and `file_id`, optionally `mime_type` and `file_name`. Public HTTPS URLs and existing `{kind, id}` library references also work. Limits: PNG, JPEG, WebP or finished MP4, up to 32 MiB each. Each position reports on its own; retry the missing ones before building the post. Files are copied to the account and deduplicated by bytes; temporary download URLs are not kept.\n\nA `sandbox:` URL or local path can’t be fetched. For an image the chat generated, use the existing file if the host exposes it (not verified for every host); otherwise download it and use the browser upload. Never ask the agent to recreate an image or transcribe binary data.\n\n**Fallback: browser upload.** Call `create_upload_session` with the account and a new UUID `requestId`. The person opens the link and picks the files. Then read `get_studio_job` with the returned ID; its result keys are zero-based positions. The same session and asset IDs work from any other chat. No image-generation charge.\n\n## Create and review the post\n\nCall `create_post` with a new UUID `requestId`, the account, ordered `{kind, id}` assets, captions and aspect ratio. Reuse the same ID and inputs if a response is lost.\n\n- `presentation: \"finished\"` keeps artwork as-is: no text overlay, no added music. Images fit inside the canvas, which can add margins.\n- `presentation: \"editable\"` allows `text` and `subtitle` on image slides.\n- One finished video becomes one reel. Never mix a video with image slides.\n\nEdit with `update_post` (captions) and `update_slides` (slides). Call `preview_post` and check every slide or the video: captions, order, crop, legibility. It returns a small image of each slide for the agent to look at and an embedded preview for the person; a chat without embedded apps still shows the images. A preview is not approval to publish.\n\nAgent: show the person the preview, and link every post you mention with the `links` the tools return, as a Markdown link on its ref or title: `links.review` when the ask is to approve or schedule, `links.editor` when it is to change something. Never draw the slides in your own widget or artifact: those sandboxes block Sprid's media host and the images show broken.\n\n## Make a reel from photos or text\n\nCheck `get_capabilities` for hosted creation first. `create_reel` takes ordered scenes (`imageId`, `text`, `durationMs`), up to 120 seconds, and makes a silent MP4. It costs 3 Sprid credits per started render minute, or uses the plan allowance: check `spend_status`, tell the person, and get their go-ahead before sending `confirmed: true`. Save the job ID and read `get_studio_job`. Retrying with the same ID never starts a second render; a stopped job reports `needs_attention` instead of charging again.\n\nTo turn an existing post's slides into a video instead, use its own video: `get_video_capabilities`, then `get_post_video` and `edit_post_video` for transitions and motion, and `update_post {outputs}` for which destinations get it. Rendering it is metered the same way: `quote_post_video_render`, the person's go-ahead, then `render_post_video` with `confirmed: true`. Schedule a video destination with the render's `videoRenderId`.\n\nApp captures, simulator recordings and your own build scripts run outside remote MCP. Upload their finished files.\n\n## Approve and confirm delivery\n\nPublishing and scheduling require the person's approval of the preview and intended destinations. The browser review link remains available for picking channels, time and platform options.\n\nTikTok requires an explicit `postMode`; nothing defaults to a direct post or a draft:\n\n- `MEDIA_UPLOAD` sends the media to the creator's TikTok inbox. They finish the post in TikTok; report it as a draft.\n- `DIRECT_POST` requires `privacyLevel`, `disableComment`, `disableDuet`, `disableStitch`, `yourBrandToggle`, `brandedContentToggle` and `confirmed: true` in `options.tiktok`. Fetch fresh choices with `list_connections {includeTikTokCreatorInfo: true}` and show the creator identity, media and caption. The person chooses every setting for this post. Photo posts use `disableDuet: true` and `disableStitch: true`; comments respect the creator's restriction. Branded content cannot use `SELF_ONLY`.\n\nBefore sending `confirmed: true`, get the person's approval of this exact post and their agreement to TikTok's [Music Usage Confirmation](https://www.tiktok.com/legal/page/global/music-usage-confirmation/en), plus the [Branded Content Policy](https://www.tiktok.com/legal/page/global/bc-policy/en) for branded content. That flag is a caller's attestation, not proof of a click in TikTok's prescribed UI. Never invent choices or carry usual preferences into a new post. If choices are missing, open the returned `approvalUrl` for the person to answer in Sprid.\n\nAn approval covers the version the person saw. If you edit a scheduled direct post afterwards, the edit's result carries `tiktokApproval`: the scheduled post will not publish as it is. Show the person the change and the settings in its `rebook` call, ask whether to post this version, and only after they agree call `rebook` with `options.tiktok.confirmed: true` added. `post_queue` returns the same call while the approval is outstanding.\n\nChat approval does not establish that TikTok's required posting controls have been completed. Use the Sprid review link for those controls and consent before confirming delivery; do not describe chat-only approval as platform-verified.\n\nAfter approval, read `post_queue`. Keep draft, scheduled, failed and delivered apart, and report delivery only with the platform receipt. Read a failure before retrying. Never repeat a publish call just because a response was lost.\n\nFor results, read `get_documentation` for `marketing-review`. Keep the same app, account and post IDs across clients. `next_actions` gives the next step; an empty `verb` means follow its browser link.\n\n## Limits and troubleshooting\n\n- **Attachment can’t be read:** use the browser upload, not the CLI.\n- **Missing account or service:** set it up in the dashboard. Never paste credentials into chat.\n- **Hosted creation:** check `get_capabilities` before promising it. Store screenshots and store releases are local unless it reports otherwise.\n- **File transfer:** whether a host exposes uploaded or generated files has to be checked per host. A supported schema doesn’t prove it.\n",
1669
+ "revision": "d37b92a0d99f8a24"
1670
1670
  },
1671
1671
  {
1672
1672
  "id": "local",
@@ -1679,18 +1679,18 @@ export const CONTENT_GUIDES = [
1679
1679
  {
1680
1680
  "id": "marketing-review",
1681
1681
  "title": "Review marketing from connected evidence",
1682
- "summary": "Investigate acquisition, activation, retention and revenue through the services connected to Sprid, and say plainly what evidence is missing.",
1682
+ "summary": "Review marketing and business outcomes for an app, shop or service through connected evidence, using its actual customer path and stating what remains unknown.",
1683
1683
  "url": "https://sprid.studio/docs/marketing-review",
1684
- "markdown": "# Review marketing from connected evidence\n\n**What this guide does:** Investigate acquisition, activation, retention and revenue through the services connected to Sprid, and say plainly what evidence is missing.\n\n## Start with the question\n\nName the app, the date range and the decision the review should inform.\n\n1. `list_apps` and `list_app_profiles` to find the app.\n2. `get_marketing_review_context` for shared definitions, required investigations, earlier decisions and corrections.\n3. `get_marketing_review` for the app over two comparable periods. The summary links to full evidence per source (`view: \"full\", sources: [<source>]`); omit `sources` to include social history, store reviews and milestones.\n4. Re-read an exact saved packet with `get_marketing_review_evidence` and its `evidence.id` (CLI: `sprid marketing-review evidence --id <id> --app <slug>`). It still works after a connection changes.\n\n`refresh: true` asks for live reads; `cachedOnly: true` reads only stored evidence. Keep returned errors, coverage, population definitions and sample counts in the report.\n\nIn a browser chat there is no repository. For a question about a shipped change, ask for a release summary or public changelog and label it as supplied. Never claim to have read a repository or private database. Local users can add repo diffs and database reads through their own tools.\n\n## Follow the evidence\n\n`list_marketing_queries` lists supported reads and their schemas; `query_marketing_source` runs them with Sprid’s saved credentials. Follow pagination and keep truncation visible. `get_documentation` with `metrics` covers definitions and refused calculations; with `queries`, source-specific reads.\n\n- Read the PostHog source's measurement `checks` before any rate. Each one (a platform whose paywall never sells, payers all placed in the US by the payment integration, a one-country desktop crawler, Apple's review devices, a signup surface the registration event misses, a dead funnel entry) says a number cannot be read at face value. A platform at zero revenue is not zero demand until its paywall is known to sell.\n- Store impressions by source are listing exposure; downloads by source are acquisition. Do not quote one as the other, and treat every store referrer as a last-click floor.\n- Many email opens with no clicks at all means click tracking is broken, not that nobody clicked.\n- Keep acquisition apart from activation, and people apart from events. A post impression is not an install; a download is not an activated user.\n- Pinterest Pins resurface for months: compare them at equal ages (30, 60 and 90 days). Saves are interest on Pinterest and outbound clicks are people leaving it; neither is a site session or an install. The `pinterest` source has `posts` only, with no comments. See [Pinterest content and publishing](pinterest.md).\n- Compare equal windows and comparable populations.\n- No onboarding events means recommending instrumentation, not guessing a funnel.\n- Keep revenue sources separate until the overlap check says they can be added.\n\nFor each finding, test another explanation: tracking changes, traffic exclusions, attribution window, seasonality, a different audience mix. A before/after comparison doesn’t prove cause. If a difference is within normal variation or the sample is small, say so and don’t rank it.\n\n## Report and save\n\nLead with the decision, then the evidence, the limits and what would change the answer. Keep failed reads apart from reads that returned nothing. `next_actions` gives the follow-up. Check [remaining setup](../../../references/setup-continuation.md#keep-setup-current), including the saved app icon, voice and relevant connections. Recommend the next useful integration step without repeating completed or explicitly declined setup. A missing credential goes to the secure App Profile form or a CLI key-file command; no provider MCP is needed for a supported query.\n\n- **Shared context:** when authorized, save non-secret definitions, investigations, decisions, corrections and release references with `save_marketing_review_context`, sending `baseRevision` from the latest read. A conflict returns both versions; reconcile before retrying. Saved context is a claim with references, not verification. CLI: `sprid marketing-review context --app <slug>` reads, `--file <context.json>` imports `{baseRevision,context}`. Private notes stay local.\n- **Changes:** log confirmed product or marketing changes with `add_event`, naming the areas they affect and why.\n- **The review itself:** end every review with `save_marketing_review {profile, verdict, agenda: [{title, why}], period: {start, end}, surface: \"chat\"}`: the commercial verdict in a sentence or two and the ranked agenda, most consequential first. It appears on the app’s Reviews page in Sprid and is where the next review starts; `list_marketing_reviews` reads past ones. Send the verdict and agenda only, never the full report or raw customer rows. A second save the same day replaces that day’s review.\n\nNever store secrets or signed attachment URLs in any of these.\n\n## Collection and coverage\n\n- Sprid refreshes two rolling 30-day windows daily for configured services on active plans, and re-reads late provider exports. No model calls, publishing or paid social reads. Other windows are read on demand.\n- Pause with the App Profile’s `metricsCollectionEnabled: false` or `sprid marketing-review collection --enabled false`. Stored evidence stays readable.\n- Snapshots are private to the app, tied to its configuration and kept 90 days. Save local evidence exports for a lasting archive.\n- Missing days stay unknown. Incomplete store or Search Console windows get no percentage change. Never sum daily unique people into a monthly count.\n- `configuration_only` means identifiers and a key are saved, not that access works. Errors return a safe code, the HTTP status when known, whether to retry and the next step. Retry transient errors before recommending a reconnect. Unsupported metrics and provider privacy thresholds are stated as limits.\n",
1685
- "revision": "0c0be0cb7bca82f3"
1684
+ "markdown": "# Review marketing from connected evidence\n\n**What this guide does:** Review marketing and business outcomes for an app, shop or service through connected evidence, using its actual customer path and stating what remains unknown.\n\n## Start with the question\n\nName the app, the date range and the decision the review should inform.\n\nThe App Profile selects the business; it does not require a mobile app. Reuse the saved goal, audience and customer path; investigate only missing or changed parts. For an app, use its meaningful-use and purchase definitions; for a shop, the product-page → checkout → paid-order path and relevant fulfillment/returns; for a service, qualified enquiry → paid delivery. These are examples, not compulsory funnels. Mark irrelevant metrics not applicable and relevant missing evidence unknown. Keep orders, items and people separate. Full method: [business context and outcomes](../../../references/review-to-action.md#1-follow-the-money-then-split-volume-from-rate).\n\n1. `list_apps` and `list_app_profiles` to find the app.\n2. Reuse complete context already returned by `get_marketing_plan`, `get_marketing_review_session` or a full review packet for this app. Otherwise call `get_marketing_review_context` for definitions, investigations, decisions and corrections. A compact summary is not complete context; keep the loaded revision until a reported change or conflict.\n3. `get_marketing_review` for the app over two comparable periods. The summary links to full evidence per source (`view: \"full\", sources: [<source>]`); omit `sources` to include social history, store reviews and milestones.\n4. Re-read an exact saved packet with `get_marketing_review_evidence` and its `evidence.id` (CLI: `sprid marketing-review evidence --id <id> --app <slug>`). It still works after a connection changes.\n\n`refresh: true` queues background collection; inspect progress and freshness before treating the result as updated; `cachedOnly: true` reads only stored evidence. Keep returned errors, coverage, population definitions and sample counts in the report.\n\nIn a browser chat there is no repository. Use available public pages and campaign/CMS history; ask for a change summary only when it would affect the decision and label it as supplied. Never claim to have read a repository or private database. Local users can add repo diffs and database reads through their own tools.\n\n## Follow the evidence\n\n`list_marketing_queries` lists supported reads and their schemas; `query_marketing_source` runs them with Sprid’s saved credentials. Follow pagination and keep truncation visible. `get_documentation` with `metrics` covers definitions and refused calculations; with `queries`, source-specific reads.\n\n- Read the PostHog source's measurement `checks` before any rate. Each one (a platform whose paywall never sells, purchase events with suspect geography, concentrated traffic that needs investigation, possible store-review activity, a signup surface the registration event misses, a dead funnel entry) says a number cannot be read at face value. A platform at zero revenue is not zero demand until its paywall is known to sell.\n- Store impressions by source are listing exposure; downloads by source are acquisition. Do not quote one as the other, and treat every store referrer as a last-click floor.\n- Many email opens with no recorded clicks warrants checking both tracking and actual engagement; it does not establish either explanation.\n- Keep acquisition apart from activation, and people apart from events. A post impression is not an install; a download is not an activated user.\n- Pinterest Pins resurface for months: compare them at equal ages (30, 60 and 90 days). Saves are interest on Pinterest and outbound clicks are people leaving it; neither is a site session or an install. The `pinterest` source has `posts` only, with no comments. See [Pinterest content and publishing](pinterest.md).\n- Compare equal windows and comparable populations.\n- Missing events on the relevant path require a concrete measurement check or proposal; they do not supply an inferred funnel. App onboarding is relevant only when it exists.\n- Keep revenue sources separate until the overlap check says they can be added.\n- Payment totals do not supply a shop's order states or contribution costs, or a service's qualified leads. Use supported records or scoped exports where available and label unsupported connections. Do not invent profile fields or assume a commerce integration.\n\nFor each finding, test another explanation: tracking changes, traffic exclusions, attribution window, seasonality, a different audience mix. A before/after comparison doesn’t prove cause. If a difference is within normal variation or the sample is small, say so and don’t rank it.\n\n## Report and save\n\nChoose a concrete activity from the business evidence. Prepare further material only within the user's authorization. Match the deliverable to actual access: ready-to-review content, a supported CMS draft, a tested repo patch or a change brief with exact destination, proposed copy/behavior and verification. A needed checkout or product repair can outrank more content. Creating an external issue is a handoff; verify the actual delivery before following its effect.\n\nLead with the decision, then the evidence, the limits and what would change the answer. Keep failed reads apart from reads that returned nothing. Read `next_actions` once in scope, reusing a current result. Check [remaining setup](../../../references/setup-continuation.md#keep-setup-current) for gaps that affect the review or requested unfinished setup; do not restart completed identity/icon/voice work or repeat declined connections. A missing credential goes to the secure App Profile form or a CLI key-file command; no provider MCP is needed for a supported query.\n\n- **Shared context:** when authorized, save selected non-secret goals, definitions, investigations, decisions, corrections and release references with `save_marketing_review_context`, merging with the context at `baseRevision`. If context is empty, derive the proposed summary from existing material and verify the saved result. Saving a review verdict does not save these fields. Accepted goals need no independent research; factual claims and definitions need rechecking when relevant changes, contradictory evidence or a recorded revisit condition call for it. Keep required investigations and due audits. A conflict returns both versions; reconcile before retrying. CLI: `sprid marketing-review context --app <slug>` reads, `--file <context.json>` imports `{baseRevision,context}`. Private notes stay local.\n- **Changes:** log confirmed product or marketing changes with `add_event`, naming the areas they affect and why.\n- **The review itself:** end every review with `save_marketing_review {profile, verdict, agenda: [{title, why}], period: {start, end}, surface: \"chat\"}`: the commercial verdict in a sentence or two and the ranked agenda, most consequential first. It appears on the app’s Reviews page in Sprid and is where the next review starts; `list_marketing_reviews` reads past ones. Send the verdict and agenda only, never the full report or raw customer rows. A second save the same day replaces that day’s review.\n\nNever store secrets or signed attachment URLs in any of these.\n\n## Collection and coverage\n\n- Sprid refreshes two rolling 30-day windows daily for configured services on active plans, and re-reads late provider exports. No model calls, publishing or paid social reads. Other windows are read on demand.\n- Pause with the App Profile’s `metricsCollectionEnabled: false` or `sprid marketing-review collection --enabled false`. Stored evidence stays readable.\n- Snapshots are private to the app, tied to its configuration and kept 90 days. Save local evidence exports for a lasting archive.\n- Missing days stay unknown. Incomplete store or Search Console windows get no percentage change. Never sum daily unique people into a monthly count.\n- `configuration_only` means identifiers and a key are saved, not that access works. Errors return a safe code, the HTTP status when known, whether to retry and the next step. Retry transient errors before recommending a reconnect. Unsupported metrics and provider privacy thresholds are stated as limits.\n",
1685
+ "revision": "aedb94bf4d89b143"
1686
1686
  },
1687
1687
  {
1688
1688
  "id": "traffic",
1689
1689
  "title": "Check whether a traffic spike is real",
1690
- "summary": "Tells you how to judge a sudden jump in website visitors, and how to remove a scraper from your numbers without removing customers. Read it before you act on a spike or explain one.",
1690
+ "summary": "Helps investigate a jump in website visitors and prepare a narrow, reviewable exclusion when evidence supports it.",
1691
1691
  "url": "https://sprid.studio/docs/traffic",
1692
- "markdown": "# Check whether a traffic spike is real\n\n**What this guide does:** Tells you how to judge a sudden jump in website\nvisitors, and how to remove a scraper from your numbers without removing\ncustomers. Read it before you act on a spike or explain one.\n\nA spike is not proof of a bot, and a quiet baseline is not proof of people.\nGet it wrong one way and you chase a channel that never existed; the other way\nand you drop real readers from your own numbers. Use the four reads below, in\norder, and keep any rule as narrow as your evidence.\n\nSprid flags candidates. When one day carries at least 35% of a 30-day period\nand runs at ten times the median day, `next` shows **Review that day** with the\ndate, the multiple and that day’s pageviews per visitor against the rest of the\nmonth. Treat it as a reason to look closer: a daily series can’t tell a crawl from a\ngood day.\n\n## Four reads, in order\n\n**1. Find the hours, not the day.** Split the suspect day by hour. Marketing\narrives across a day and decays over several. A scrape is a block: a flat rate\nfor a few hours, then nothing. If the day’s total sits in four consecutive\nhours with ordinary hours either side, it is one client, not an audience.\n\n**2. Divide pageviews by visitors, but don’t trust it alone.** A browser with no\ncookie is a new visitor on every request, so a crawl that reads each page once\nshows a huge visitor count at 1.0 pageviews each. A crawler that hits each URL\nseveral times doesn’t: the September 2026 scrape ran at **1.60 pageviews a\nvisitor against a baseline of 1.44**. A flat 1.0 is suspicious; a healthy ratio\nproves nothing.\n\n**3. Ask for whole groups, not separate dimensions.** `review_traffic` returns\ncompound groups (browser, version, OS, screen, timezone, referrer, country\ntogether), because adding up separate dimension counts double-counts everyone.\nOne group carrying most of a day is the finding. Twelve groups sharing it evenly\nis not, unless they agree on something (below).\n\n**4. Look at which pages were hit.** A crawl walks your sitemap: every locale\nand item, a few hits each. Real discovery is lopsided: a few pages that rank or\nwere linked take most of the traffic.\n\n## What the fingerprint looks like\n\nRead the fields that are hardest to fake:\n\n- **Timezone against geography** is the strongest tell. A browser on\n `Asia/Shanghai` while its IPs sit in the US, Singapore and Germany is one\n operator on rented addresses. The addresses rotate; the machine’s clock\n doesn’t.\n- **An odd viewport** (`800x600`, `1512x982`, `1280x720`) beats the user agent,\n which is the field they bother to spoof. Expect a plausible current Chrome on\n macOS with a screen no customer has.\n- **A blend is a signature too.** One wave rotated Chrome 118-120, Edge 119-120\n and Firefox 120-121 so no browser stood out, all on one viewport, timezone and\n referrer. What they varied is noise; what they forgot to vary is the rule.\n- **Referrer `$direct`** on everything, with no search or social.\n- **Country is not a tell.** The same wave comes from a dozen countries. A\n country rule removes customers and keeps the scraper.\n\n## Check the comparison period too\n\nA percentage change has two periods, and nobody looks at the older one. On one\napp in September 2026 the same thirty days read **+252.5%** before review,\n**-62.6%** with only the obvious spike excluded, and **-17.6%** once two earlier\nwaves in the comparison period were excluded too. The middle answer was wrong\nand the most convincing, because it already looked corrected.\n\nSo when you find one wave, look for others before saving anything. Query the\nwhole history for its marker (the timezone, the viewport) by day. Waves have\nedges: 5 to 30 visitors a day in the background and several hundred on wave\ndays tells you where the rule starts and that the rest is ordinary.\n\n## Two more traps\n\n**Raw provider data is not what Sprid counted.** Web visitors already exclude\nevents with no browser, crawler and headless user agents, reported bots, and\nanything marked internal or test. A wave with a null browser never reached your\ntotal, so a raw query including it won’t match the card. Compare like with like\nor you exclude the same traffic twice.\n\n**Bound a rule by how ordinary its conditions are.** `1920x1080` is a real\nscreen many customers use, so a rule on it gets exactly the wave’s dates. A\nviewport nobody has can carry a longer window. The date range is the safety\nmargin for a condition that isn’t distinctive on its own.\n\n## Save the exclusion\n\nPreview, then save. `review_traffic` shows visitor counts before and after for\nthe period **and the one before it**, which is the number you want to see move.\n\n```json\n{\n \"id\": \"scrape-2026-09-18\",\n \"reason\": \"Full-site crawl 04:00-08:00 UTC: Asia/Shanghai timezone, 800x600 viewport, direct referrer, IPs across US/SG/DE. 39,958 of the day's 40,476 visitors, every locale and item page hit at most 5 times with no cookie reuse.\",\n \"start\": \"2026-09-18\",\n \"end\": \"2026-09-19\",\n \"enabled\": true,\n \"conditions\": [\n { \"field\": \"timezone\", \"value\": \"Asia/Shanghai\" },\n { \"field\": \"screenWidth\", \"value\": \"800\" },\n { \"field\": \"screenHeight\", \"value\": \"600\" }\n ]\n}\n```\n\n- End dates are exclusive and UTC.\n- Conditions in a rule are AND; separate rules are OR.\n- Fields: `browser`, `browserVersion`, `os`, `osVersion`, `screenWidth`,\n `screenHeight`, `timezone`, `referrer`, `country`. Values are exact strings;\n a missing property never matches.\n- Write `reason` for someone reading it in a year: the window, the evidence and\n how much it removes.\n\nSave through `upsert_app_profile` or `PATCH /api/app-profiles/:id` under\n`posthogConfig.trafficExclusions`, keeping the rest of the configuration. Then\n**log the change as an event** with the areas it affects and why, or next\nmonth’s drop in your graph becomes a new mystery.\n\n## What an exclusion doesn’t touch\n\nYour provider’s data is never changed or deleted; Sprid filters when it reads.\nApp activity, registrations and revenue are unaffected, since a scraper doesn’t\nsign up. Raw connected queries and the marketing-review event inventory stay\nunfiltered on purpose, as the evidence to check the rule against. **Restore this\ntraffic** disables a rule and the original numbers come straight back.\n\n## When not to exclude\n\nA spike with a real referrer and lopsided pages is a link that worked: find it\nbefore you filter it. A shared office machine, an uptime probe and a preview\nrenderer all repeat a fingerprint, and none is worth a rule. A group you can’t\nexplain stays in: an unexplained visitor counted is a smaller error than a\ncustomer removed.\n",
1693
- "revision": "865977c916e965ab"
1692
+ "markdown": "# Check whether a traffic spike is real\n\n**What this guide does:** Helps investigate a jump in website visitors and prepare\na narrow, reviewable exclusion when evidence supports it.\n\nA spike can be a successful referral, automated traffic or a mixture. Sprid's\n**Review that day** action is a prompt to investigate, not a bot verdict. Keep raw\ncounts beside any filtered comparison and explain what remains uncertain.\n\n## Inspect the traffic\n\n1. **Locate the change.** Split the period by day and hour. Compare the spike with\n campaigns, referrals and ordinary traffic. A concentrated burst alone does not\n establish that the visitors are automated.\n2. **Read behavior.** Compare pages per visitor, navigation and meaningful events.\n A crawler can send several pageviews; a real reader may need only one page.\n Neither ratio proves who visited.\n3. **Inspect combined groups.** `review_traffic` groups browser, screen, timezone,\n referrer and other dimensions together. Counts from separate dimensions can\n describe the same visitors and must not be added.\n4. **Read the pages and sources.** Broad repeated requests across a catalogue may\n suggest automation. Check whether a real campaign, shared device or monitoring\n service explains the same pattern before excluding it.\n\n## Treat fingerprints as clues\n\nCompare combinations of browser properties, request timing, destinations and\nreferrers. A shared viewport, missing referrer or timezone mismatch is insufficient\non its own: ordinary people can share those properties or use a VPN.\n\nDo not infer a bot from country, language or a device model. Seek independent\nsupport such as verified crawler identification or matching server-log behavior\nwhen those sources are available. Preserve unresolved traffic as uncertain.\n\n## Check both comparison periods\n\nApply the same investigation to the baseline. Removing a spike only from the\ncurrent period can distort the reported change when similar traffic exists in\nthe earlier one. Query suspected patterns across both windows before comparing.\n\nUse matching metric definitions. Sprid's website counts already apply their\nconfigured exclusions; a raw provider query can include traffic that never\nentered the displayed total. Read the returned coverage and filter notes before\ntrying to reconcile or exclude it again.\n\n## Prepare a narrow exclusion\n\nChoose conditions supported by the customer's evidence and limit the dates to\nthe verified interval. Preview the affected visitors and the remaining traffic\nin both periods. Check that known customer activity is not caught by the rule.\n\nThe following is a fictional schema example, not a suggested device blacklist.\nReplace its dates, conditions and reason with verified evidence before saving:\n\n```json\n{\n \"id\": \"verified-crawl-example\",\n \"reason\": \"Example only: record the corroborating evidence, date range and affected counts here.\",\n \"start\": \"2026-01-10\",\n \"end\": \"2026-01-11\",\n \"enabled\": false,\n \"conditions\": [\n { \"field\": \"browser\", \"value\": \"Example crawler\" },\n { \"field\": \"screenWidth\", \"value\": \"800\" }\n ]\n}\n```\n\n- End dates are exclusive and UTC.\n- Conditions in a rule are AND; separate rules are OR.\n- Fields: `browser`, `browserVersion`, `os`, `osVersion`, `screenWidth`,\n `screenHeight`, `timezone`, `referrer`, `country`. Values are exact strings;\n a missing property never matches.\n- Write the evidence and affected counts in `reason`. Keep unrelated exclusions\n and profile settings intact.\n\nWhen authorized, save through `upsert_app_profile` or `PATCH /api/app-profiles/:id`\nunder `posthogConfig.trafficExclusions`. Read back the configuration and affected\nmetrics, then log the measurement change with its areas and reason so a later\nreview can explain the difference.\n\n## What changes\n\nAn exclusion filters Sprid's website readings; it does not delete provider data.\nApp activity, registrations and revenue use their own definitions. Raw connected\nqueries and the marketing-review event inventory remain available as evidence.\nDisabling the rule restores that traffic to the filtered readings.\n\nKeep an unexplained group visible. If the available evidence cannot separate\ncustomers from automation, report that limitation instead of saving an exclusion.\n",
1693
+ "revision": "e29d458977ec10d2"
1694
1694
  },
1695
1695
  {
1696
1696
  "id": "pinterest-content",
@@ -1703,34 +1703,34 @@ export const CONTENT_GUIDES = [
1703
1703
  {
1704
1704
  "id": "distribution",
1705
1705
  "title": "Why a post travels",
1706
- "summary": "Explains what Instagram and TikTok actually reward, and turns each mechanic into a rule you can build a post against. Read it before deciding a format, a slide count or a caption length, and when a post that looked good got no reach. Pinterest works on search and has its own guide, Pinterest content and publishing.",
1706
+ "summary": "Helps choose where and how to publish, then evaluate whether distribution serves the customer's business goal.",
1707
1707
  "url": "https://sprid.studio/docs/distribution",
1708
- "markdown": "# Why a post travels\n\n**What this guide does:** Explains what Instagram and TikTok actually reward,\nand turns each mechanic into a rule you can build a post against. Read it before\ndeciding a format, a slide count or a caption length, and when a post that\nlooked good got no reach. Pinterest works on search and has its own guide,\n[Pinterest content and publishing](pinterest.md).\n\nWhether a post is *good* and whether it *travels* are two different questions.\nThis one is about the second. Every rule here exists because of a specific\nmechanic, and if the mechanic changes the rule goes with it, so each one carries\nits reason.\n\n**Verified against published sources 2026-08-04.** Platform mechanics decay.\nTreat anything here as a claim about a moving system, re-check it quarterly, and\nprefer your own numbers the moment you have them.\n\n## Instagram\n\n**The three named ranking signals are watch time, sends per reach, and likes per\nreach.** Sends are the heaviest, reported at roughly three to five times the\nweight of a like. Sends per reach is specifically the signal that reaches people\nwho do not follow you, which is the only reach a new account can grow on.\n\n**Feed, Reels, Stories and Explore rank separately** and weight those signals\ndifferently. Feed leans on how close you already are to the viewer; Explore on\nengagement velocity and interest match. A post that does well with your existing\nfollowers is not automatically a post that travels.\n\n**Why carousels, specifically:**\n\n- Every swipe is engagement, and dwell time accumulates across the slides.\n Carousels are reported at two to three times the reach of a single image for\n the same content.\n- **The platform re-serves a carousel to people who did not swipe, starting from\n the second slide.** This is the most actionable mechanic available: slide two\n gets an independent second chance to be someone's first impression.\n- Carousels out-save single images by a wide margin, and saves compound, because\n saved posts get resurfaced.\n- Completion - how many people reach the last slide - is what pushes a post out\n of your followers and into Explore.\n\n**Hashtags do not drive distribution.** The platform's own position is that they\ncategorise rather than distribute. Discovery comes from the words in your\ncaption: captions, alt text, bios and on-screen text are indexed and served into\nin-app search. Keyword-rich captions have been measured at around 30% more reach\nthan hashtag-heavy ones. Keep three to five hashtags as labels and spend the\neffort on the prose.\n\n## TikTok, photo mode\n\n- Ranking is swipe-through rate, dwell time and reverse swipes, with completion\n rate as the primary signal.\n- Distribution starts with a **small test batch of roughly 200-500 viewers**,\n mostly followers and people who engage with adjacent content. What that batch\n does decides everything afterwards. This is why the first hour matters, and\n why a weak second slide can end the post’s reach.\n- Saves are weighted and photo posts save well. A carousel with fewer views but\n a high save-and-comment share is doing better than a higher-view post that\n bounces on slide one.\n- Interest-based distribution means an outlier is possible from your first post.\n TikTok is spikier; Instagram grinds.\n\n## Pinterest\n\nThe rules below are for Instagram and TikTok and do not apply to a Pin.\nPinterest is visual search: a Pin is found through search, home feeds and\nrelated Pins for months, mostly regardless of followers, so there is no\nfirst-hour test batch and no second slide. Relevance comes from the image, the\ntitle and description, the board and the page the link opens. Judge Pins at\nequal ages (30, 60 and 90 days) by outbound clicks and what they lead to on\nyour site.\n\n## What follows for how you build a post\n\n| Rule | Because |\n|---|---|\n| **Slide 2 is a second hook.** It delivers on slide 1 *and* opens a new thread, and it has to work cold | the platform re-serves from slide 2; TikTok's test batch dies there |\n| **Seven slides for a narrative format** (hook, five body, closer) | seven to ten is the reported dwell-time sweet spot; under five reads as a short post, over ten causes mid-carousel fatigue |\n| **No slide may be skippable.** If a body slide can be removed without breaking the post, you wrote a list, and people can stop anywhere in a list | completion is the ranking signal, and a list lets people stop anywhere |\n| **The peak lands in the last third** | a middle peak makes the tail a letdown, and the tail is where completion is won |\n| **Design for sends.** At least one slide should make someone think of a specific person | sends per reach is the non-follower signal, worth several likes |\n| **The send prompt lives in the caption**, naming one kind of person, never \"share if you relate\" | it belongs where sends are earned, and it keeps the last slide screenshottable |\n| **At least two slides hand over something usable** | recognition earns likes; recognition plus something doable earns saves, and saves compound |\n| **Captions carry the audience's own search phrases, in prose** | captions are indexed; hashtags are not distribution |\n| **Caption length 400-600 characters on Instagram, 150-300 on TikTok**, first line an independent hook under 125 characters | that is where the \"more\" cut falls; past roughly 300 characters TikTok needs a tap and reach drops |\n| **Three to five hashtags, never the generic feed tag** | labels, not reach |\n| **One canvas at 4:5, content clear of the top and bottom edges** | survives the 3:4 grid crop, letterboxes acceptably on TikTok |\n\n## Cadence, and the first hour\n\n**Three to five posts a week, sustained.** Three a week for twelve weeks beats\nseven a week for three. The failure mode is not low quality, it is stopping - and\nthe documented version of it is 34 drafts and 3 published posts.\n\n**The first hour is the test batch.** Being there to answer early comments is the\ncheapest intervention available on either platform; a comment answered in the\nfirst hour is worth more than the same reply a day later.\n\n**Post at a fixed time** your audience is awake for, set once on the account's\nposting schedule rather than decided per post.\n\n**Expect silence for two months.** A new account with no face takes three to six\nmonths to reach a thousand followers on Instagram. Distribution is a power law:\na few posts carry most of the reach. Judging before 90 days is judging noise.\n\n## When something works, fan it out\n\nWhen a post breaks out, the work starts: more like it, and the best one promoted.\n\n1. Make five to ten variants of the winner with the structure fixed and **exactly\n one variable changed** - the hook phrasing, the register, the opening image.\n2. One variable per variant, or the result teaches nothing.\n3. Log which variant won *and* which source line its hook came from. That tells\n you which well to keep digging.\n4. A losing variant tells you something. Stop it and write down what.\n\n## What is worth not believing\n\nEverything above is published guidance and platform statements, not our\nmeasurements. Before you treat any of it as settled for **your** audience, these\nare the clean one-variable tests: caption length judged on saves and sends per\nreach; a quiet text card against a photo behind text; a native 9:16 crop against\na letterboxed 4:5; whether a screenshot of your app costs reach or buys installs.\n\nLog the real numbers per post at seven days - views, completion, saves, sends,\ncomments, profile taps. The moment you have your own numbers they outrank every\nsource below.\n\n## Sources\n\n- [Instagram algorithm ranking signals (Buffer)](https://buffer.com/resources/instagram-algorithms/) - watch time, sends per reach, likes per reach, per-surface ranking\n- [The ranking signals that matter (Clixie)](https://www.clixie.ai/blog/instagram-algorithm) - sends weighted three to five times a like\n- [How carousels beat Reels for engagement (Storrito)](https://storrito.com/resources/how-instagram-carousels-beat-reels-for-engagement-in-2026-and-when-to-use-each/) - re-serving from slide 2, reach multiple\n- [Carousel best practices (Adpicto)](https://www.adpicto.com/en/blog/instagram-carousel-best-practices-2026) - slide count, dwell time, saves\n- [Carousel algorithm (TryMyPost)](https://www.trymypost.com/blog/instagram-carousel-algorithm-strategy-2026) - dwell time and completion\n- [Do hashtags still work (Kontentino)](https://www.kontentino.com/q-and-a/instagram-hashtags-reach/) - hashtags do not drive reach\n- [Keywords versus hashtags (Dive Media)](https://www.divemedia.com.au/marketing-tips-and-insights/social-seo-keywords-vs-hashtags) - caption indexing and the reach lift\n- [TikTok photo mode algorithm (ReelBase)](https://reelbase.io/blog/tiktok-photo-mode-algorithm-explained) - photo mode reach against video\n- [TikTok carousel algorithm (PostWaffle)](https://www.postwaffle.com/blog/tiktok-carousel-algorithm) - swipe-through, reverse swipes, test batch size, saves\n",
1709
- "revision": "1fdc948323095b06"
1708
+ "markdown": "# Why a post travels\n\n**What this guide does:** Helps choose where and how to publish, then evaluate\nwhether distribution serves the customer's business goal.\n\nUse the account's audience, available formats and measured results. Treat claims\nabout algorithm weights, ideal slide counts or guaranteed reach as unverified\nunless a current source supports them. A platform average is context for a test;\nit does not establish what will work for this account.\n\n## Choose the destination\n\nMatch the content to a real audience and a useful next step. An explainer might\nanswer a search question, demonstrate a product or help someone recognize a\nproblem. Choose its channel from evidence about that audience and the customer's\nexisting distribution, rather than making every business start with social posts.\n\nFor a connected channel, inspect publishing status and account restrictions\nbefore preparing a schedule. A failed publish is different from a published post\nwith low reach. Respect the destination's current format requirements and the\ncustomer's approved publishing scope.\n\n## Build for the actual format\n\nFor an Instagram or TikTok carousel, make the opening understandable and let\neach following slide advance the idea. Preview the full deck at its intended\nsize. Use enough slides to deliver the promise; a fixed slide count is not a\nquality check or a guarantee of distribution.\n\nFor video, inspect the opening, pacing and delivered result using\n[first seconds](../../reels/guides/first-seconds.md). A question, demonstration or\nstory can each work when it matches the purpose and the customer's voice.\n\nFor Pinterest, use the [Pinterest guide](pinterest.md). Prepare a standalone Pin\nwith a relevant destination, board and search wording. Evaluate it with its own\nmetrics and observation window rather than importing a reel's retention target.\n\n## Decide what to measure\n\nRecord the intended outcome before publishing. Depending on the task, it can be\nqualified visits, an order, an account signup or a useful audience response.\nState how the content connects to that outcome and where attribution stops.\n\nRead only metrics the connected tools actually provide. Keep views, reach,\ncomments, saves and outbound clicks distinct. Platform engagement does not by\nitself establish visits or sales. A missing metric is unknown, not zero.\n\nCompare content at similar ages and separate paid from organic distribution.\nInclude the sample size and spread, since one unusual post can dominate an average.\nAvoid comparing different metric definitions as though they measured the same thing.\n\n## Choose a workable cadence\n\nUse the customer's objectives, production process and measured delivery to set\na schedule. The saved posting schedule is a preference to follow, not evidence of\nan algorithmic advantage. Test timing when there is enough comparable activity\nto learn from it.\n\nPrepare a repeatable content process and keep the queue visible. Requested volume\nand quality both matter; do not reduce the user's ambition to a fixed weekly\nquota. Reply to relevant comments within the authorization the user has given.\nA research or publishing request does not authorize unrelated likes or outreach.\n\n## Learn from a result\n\nWhen a post meets its intended outcome, identify what could explain it and test\nthat explanation with another example. Reuse a format when the evidence supports\nit, while writing for the new topic and the account's voice.\n\nKeep each experiment's hypothesis, publication references and results with the\ncustomer's plan. If several variables change, attribute the outcome to the whole\nvariant until further evidence separates them. Preserve unsuccessful examples\nas well as successful ones so later reviews can compare them.\n\n## Recheck external claims when they matter\n\nFor a platform requirement or a claimed ranking mechanism, consult current\nfirst-party documentation and retain the source and date. Separate a platform's\npublished explanation from third-party speculation and from this customer's\nmeasured results. If the distinction could change the recommendation and the\nclaim cannot be verified, leave it out of the decision.\n",
1709
+ "revision": "c29132f09797a901"
1710
1710
  },
1711
1711
  {
1712
1712
  "id": "meta-conversions",
1713
1713
  "title": "Run your first Meta conversion campaign.",
1714
1714
  "summary": "Takes you from Meta account setup to a conversion campaign prepared in Sprid, with a real event checked before you approve spending.",
1715
1715
  "url": "https://sprid.studio/docs/meta-conversions",
1716
- "markdown": "# Run your first Meta conversion campaign.\n\n**What this guide does:** Takes you from Meta account setup to a conversion campaign prepared in Sprid, with a real event checked before you approve spending.\n\nA conversion campaign asks Meta to find people likely to complete an action in your app. Choose the action you want, prove that Meta receives it, then build the campaign. Your agent prepares the ads. You review the campaign and press Launch in Sprid.\n\n## You need.\n\n- Admin access to the Meta business portfolio and ad account that will pay for this app’s ads.\n- A Facebook Page for the app, linked to its professional Instagram account if you want Instagram delivery.\n- Access to your app’s server and domain DNS. A developer needs to send your conversion events to Meta.\n- A Sprid account connected to Meta Ads, and a person signed in to Sprid to approve the reviewed budget.\n\n## Set up Meta once.\n\n1. **Create or choose the business portfolio.** Keep the app’s Page, ad account and dataset under the business that owns them. Your personal Facebook login administers those assets.\n2. **Create the Page and link Instagram.** The Page supplies the identity for the ad, including ads that run only on Instagram. Check the linked handle before connecting Sprid.\n3. **Choose or create the ad account and add billing.** Choose its currency and time zone carefully. Changing them creates a new ad account and closes the old one, stopping its ads. Sprid’s budget amounts use the connected ad account’s currency.\n4. **Create the dataset in Events Manager.** This is where Meta receives conversion events. Keep its ID. A server-only Conversions API integration can use the dataset without installing browser pixel code.\n5. **Verify the app’s domain.** In Meta’s business settings, follow domain verification and add the supplied DNS TXT record with your DNS provider. Verify the domain where your conversions happen.\n6. **Create the Conversions API token.** Use the dataset’s direct integration setup in Events Manager. Save the token in your server’s secret settings. This token sends your app’s events; Sprid’s Meta Ads connection manages campaigns separately.\n7. **Review account-level advertising settings.** Check any optional AI tests that change the destination or its presentation. Your agent’s creative settings cannot prove what an account-level test will do. If you use Meta’s audience-segment reports, define the engaged audience and existing customers there before interpreting those reports.\n8. **Connect Meta Ads to Sprid.** Follow [Connect Meta Ads](https://sprid.studio/docs/connect/meta-ads), selecting this app’s Page, ad account and dataset. Set the daily, campaign and monthly spending caps in Sprid’s account settings. If several apps share the ad account, a workspace owner or admin also sets **Shared Meta limits** there. Each app keeps its own Page and dataset; the shared limits cover campaigns managed through Sprid.\n\n## Prove the conversion signal.\n\n1. In Events Manager, select the dataset and open **Test events**. Add its test code to your server’s Conversions API request as `test_event_code`.\n2. Complete the chosen action through the app. Check that Events Manager receives the exact event name and reports a server connection. A successful HTTP request alone does not prove the expected event arrived.\n3. Inspect the event details. Website events need `event_source_url` and the visitor’s `client_user_agent`. Send a stable `external_id` for matching. When a real Meta click supplies `fbclid`, preserve it through redirects and send the correctly formatted `fbc` value. Keep the click ID’s case unchanged.\n4. Check `Purchase` with the actual paid amount and currency if the app sells something. Store the visitor’s user agent and click ID when they arrive, so a later payment webhook can use the original values.\n5. Remove `test_event_code` for production. Complete another real action and check its arrival, then ask your agent to run `dataset_health` for the event you will optimize on.\n\nEmail and phone identifiers need Meta’s normalization and hashing. Hashing `external_id` is recommended; keep its representation consistent across integrations. `fbc` and `client_user_agent` must remain unhashed. See [customer information parameters](https://developers.facebook.com/documentation/ads-commerce/conversions-api/parameters/customer-information-parameters) when implementing the server call.\n\nTest codes identify test payloads. Meta says those events still flow into Events Manager and can be used for targeting and measurement. Test only with actions you actually perform, then remove the code.\n\n`dataset_health` checks recent event counts and, when Meta provides it, event match quality. Its count check covers seven days and Meta’s top 100 events per hour. An event absent from that response is unobserved. A match-quality score does not prove that a recent conversion happened.\n\n## Choose an event that predicts payment.\n\nUse `Purchase` when purchases happen often enough to give the campaign a useful signal. If purchases are sparse, choose a completed action in the same visit that customers take before paying. Check the connection to payment in your own analytics.\n\nMeta says ad sets usually leave learning after about 50 results in the week following the last significant edit. This is guidance about stable delivery. Results below that volume can still tell you about a broken checkout or whether anyone paid.\n\nStandard events use their exact names, such as `Purchase`, `Lead` or `CompleteRegistration`. For your own event, such as `RecipePlanCompleted`, ask your agent to:\n\n1. Run `dataset_health` without a conversion selection and find the exact name in `datasetHealth.events`. Check that the returned `datasetId` is the connected dataset you intend to use. The readiness result may still ask you to select a conversion.\n2. Run `list_custom_conversions` to check for an existing matching rule.\n3. If needed, call `create_custom_conversion` with a name and the exact `eventName`. Keep the returned ID.\n4. Complete the action again after creating the rule. The custom conversion itself must have a recent fire before launch; an earlier underlying event cannot prove a new rule worked.\n\nA custom conversion is a named rule on events in your dataset. Sprid creates a case-sensitive event-name rule. The ad set selects that rule by `customConversionId`.\n\n## Build the campaign through Sprid.\n\nGive your agent the intended countries, total budget, run length, destination and hypothesis. State the regulated category before the campaign is created. Housing, employment, financial products and services have targeting restrictions; declare `NONE` only when no category applies. [Meta’s category guide](https://developers.facebook.com/docs/marketing-api/audiences/special-ad-category/) explains the scope.\n\nAsk the agent to follow this order:\n\n1. **Check readiness:** `ads_overview`, then `dataset_health` with the chosen `conversionEvent`. Fix missing connections or an unobserved event before creating ads.\n2. **Create the paused campaign:** `create_ad_campaign` with an objective such as `OUTCOME_SALES` and explicit `specialAdCategories`. Political campaigns also need explicit `countries`. For housing, credit and employment, Sprid derives the campaign countries from the ad sets.\n3. **Create the paused ad set:** `create_ad_set` with `OFFSITE_CONVERSIONS`, the chosen event, countries and reviewed budget. Use explicit Facebook and Instagram Feed, Stories and Reels placements for paired creatives. Set `targetCostPerEventCents` as the cost you will judge results against. This is a reporting target, not a bid. Use `startAt` if you want a later start.\n4. **Prepare the creative:** `render_ad_creative` builds from a named template, or `create_ad_creative` accepts your own material. Ask for feed and vertical assets. An external pair must identify whether its assets are images or videos. Keep `destinationUrl` free of hand-written `utm_*` parameters; Sprid adds tracking.\n5. **Attach and review:** use `create_ads` to attach the creatives, review each one with `review_ad_copy`, then run `ads_preflight`. Check previews for Feed, Stories and Reels. A passing comparison confirms the stored fields checked against Sprid’s intent; it does not guarantee Meta will approve delivery.\n6. **Approve in Sprid:** `launch_ads` returns the reviewed commitment and an `approvalUrl`. Open that link, check the amount and end date, then press Launch. The agent cannot start spending or raise the budget.\n\nThe following is a fictional ad-set input for Pantry Note on a USD ad account. The IDs are examples. Choose your own budget and target cost before preparing a campaign.\n\n```json\n{\n \"account\": \"pantrynote\",\n \"campaignId\": \"00000000-0000-4000-8000-000000000001\",\n \"name\": \"Completed recipe plan\",\n \"optimizationGoal\": \"OFFSITE_CONVERSIONS\",\n \"conversionEvent\": { \"customConversionId\": \"123456789\" },\n \"dailyBudgetCents\": 1000,\n \"durationDays\": 7,\n \"countries\": [\"US\"],\n \"targeting\": {\n \"publisher_platforms\": [\"facebook\", \"instagram\"],\n \"facebook_positions\": [\"feed\", \"story\", \"facebook_reels\"],\n \"instagram_positions\": [\"stream\", \"story\", \"reels\"]\n },\n \"targetCostPerEventCents\": 500,\n \"hypothesis\": \"Showing the completed meal plan brings customers who purchase.\"\n}\n```\n\nFor a standard event, use `\"conversionEvent\": {\"standard\": \"Purchase\"}`.\n\nSprid requests opt-outs for the documented creative enhancements it supports and checks Meta’s stored settings before launch. It supplies feed and vertical assets when paired material is available. Conversion ad sets store explicit attribution settings: seven-day click, one-day view and one-day engaged video view. A mismatch found by preflight blocks launch.\n\n## Read where people stop.\n\nUse `ad_status` to check delivery and rejection messages. Ask for `ad_council` to compare cost per optimized event with your target. Then compare Meta’s clicks with your app’s actual outcomes.\n\nThe stage-by-stage diagnosis below is a manual check using Meta reports and your own analytics. Sprid’s automatic combined funnel diagnosis is planned.\n\n| Stage | Compare | Investigate when that stage loses people |\n|---|---|---|\n| Ad | Link clicks per impression | The opening, image or promise |\n| Page loads | Landing-page views per link click | Load errors, slow pages or redirects |\n| App starts | Started actions per landing-page view | Message match and the first action |\n| App completes | Completed actions per started action | Product failures or confusing steps |\n| Customer pays | Paid customers per completed action | The offer, price presentation or checkout |\n\nCompare a consistent cohort and period. Repeat actions by one person are different from independent potential buyers. Keep campaign totals separate from their ad-set totals, since they overlap.\n\nWith zero purchases, the table gives approximate one-sided 95% upper bounds on the purchase rate. It assumes independent comparable people, complete measurement and enough follow-up for them to purchase. Use it to decide what to investigate; a sample alone cannot prove an offer will fail.\n\n| Completed actions from different people, with zero purchases | Approximate upper bound |\n|---|---|\n| 30 | 10% |\n| 60 | 5% |\n| 100 | 3% |\n\nCompare that bound with the purchase rate your margins require. Confirm checkout and purchase tracking work before blaming the offer. [The statistical rule](https://jhanley.biostat.mcgill.ca/c607/ch08/zero_numerator.pdf) explains the approximation.\n\nChanges to targeting, creative or the optimization event can restart learning. Budget changes depend on their size. Record each campaign change and its reason. Fix product and checkout bugs when you find them; changing page content alone is different from editing the ad’s destination or creative.\n\n## If it fails.\n\n- **No recent event observed:** check the dataset ID and server response, perform the action, then recheck Events Manager. Remove the test code before the production check.\n- **Custom conversion has no recent fire:** confirm its dataset and exact event-name rule. Perform the action after the rule exists. Do not substitute an unrelated standard event to pass the check.\n- **Match quality unavailable:** Meta may need dataset permission or an opt-in. Read the score in Events Manager while you resolve access. Missing quality does not turn an observed event into zero.\n- **Preflight names a mismatch:** correct the named setting, or recreate the paused ad set or creative through Sprid when it cannot be edited. Run preflight again. Avoid changing tracked fields independently in Ads Manager.\n- **Budget exceeds a cap:** review the daily and total commitment in Sprid’s account settings. A person must approve the spending limit or budget change in the app.\n- **No purchases yet:** verify measurement, then read the completed-action count and follow-up period. A small cohort is inconclusive.\n\n## Sources.\n\nMeta references checked 14 October 2026. Account-specific acceptance still needs a paused create and readback on your connected account.\n\n- [Meta Ads connection](https://sprid.studio/docs/connect/meta-ads) and [ad-account currency changes](https://www.facebook.com/business/help/291404291014138).\n- [Conversions API: sending and testing events](https://developers.facebook.com/documentation/ads-commerce/conversions-api/using-the-api), [server event parameters](https://developers.facebook.com/documentation/ads-commerce/conversions-api/parameters/server-event), and [click ID handling](https://developers.facebook.com/documentation/ads-commerce/conversions-api/parameters/fbp-and-fbc).\n- [Custom conversions](https://developers.facebook.com/docs/marketing-api/reference/custom-conversion/) and [dataset quality](https://developers.facebook.com/documentation/ads-commerce/conversions-api/dataset-quality-api).\n- [Learning phase](https://www.facebook.com/business/help/112167992830700) and [significant edits](https://www.facebook.com/business/help/316478108955072).\n",
1717
- "revision": "6b190d8339bfa320"
1716
+ "markdown": "# Run your first Meta conversion campaign.\n\n**What this guide does:** Takes you from Meta account setup to a conversion campaign prepared in Sprid, with a real event checked before you approve spending.\n\nA conversion campaign asks Meta to find people likely to complete an action in your app. Choose the action you want, prove that Meta receives it, then build the campaign. Your agent prepares the ads. You review the campaign and press Launch in Sprid.\n\n## You need.\n\n- Admin access to the Meta business portfolio and ad account that will pay for this app’s ads.\n- A Facebook Page for the app, linked to its professional Instagram account if you want Instagram delivery.\n- Access to your app’s server and domain DNS. A developer needs to send your conversion events to Meta.\n- A Sprid account connected to Meta Ads, and a person signed in to Sprid to approve the reviewed budget.\n\n## Set up Meta once.\n\n1. **Create or choose the business portfolio.** Keep the app’s Page, ad account and dataset under the business that owns them. Your personal Facebook login administers those assets.\n2. **Create the Page and link Instagram.** The Page supplies the identity for the ad, including ads that run only on Instagram. Check the linked handle before connecting Sprid.\n3. **Choose or create the ad account and add billing.** Choose its currency and time zone carefully. Changing them creates a new ad account and closes the old one, stopping its ads. Sprid’s budget amounts use the connected ad account’s currency.\n4. **Create the dataset in Events Manager.** This is where Meta receives conversion events. Keep its ID. A server-only Conversions API integration can use the dataset without installing browser pixel code.\n5. **Verify the app’s domain.** In Meta’s business settings, follow domain verification and add the supplied DNS TXT record with your DNS provider. Verify the domain where your conversions happen.\n6. **Create the Conversions API token.** Use the dataset’s direct integration setup in Events Manager. Save the token in your server’s secret settings. This token sends your app’s events; Sprid’s Meta Ads connection manages campaigns separately.\n7. **Review account-level advertising settings.** Check any optional AI tests that change the destination or its presentation. Your agent’s creative settings cannot prove what an account-level test will do. If you use Meta’s audience-segment reports, define the engaged audience and existing customers there before interpreting those reports.\n8. **Connect Meta Ads to Sprid.** Follow [Connect Meta Ads](https://sprid.studio/docs/connect/meta-ads), selecting this app’s Page, ad account and dataset. Set the daily, campaign and monthly spending caps in Sprid’s account settings. If several apps share the ad account, a workspace owner or admin also sets **Shared Meta limits** there. Each app keeps its own Page and dataset; the shared limits cover campaigns managed through Sprid.\n\n## Prove the conversion signal.\n\n1. In Events Manager, select the dataset and open **Test events**. Add its test code to your server’s Conversions API request as `test_event_code`.\n2. Complete the chosen action through the app. Check that Events Manager receives the exact event name and reports a server connection. A successful HTTP request alone does not prove the expected event arrived.\n3. Inspect the event details. Website events need `event_source_url` and the visitor’s `client_user_agent`. Send a stable `external_id` for matching. When a real Meta click supplies `fbclid`, preserve it through redirects and send the correctly formatted `fbc` value. Keep the click ID’s case unchanged.\n4. Check `Purchase` with the actual paid amount and currency if the app sells something. Store the visitor’s user agent and click ID when they arrive, so a later payment webhook can use the original values.\n5. Remove `test_event_code` for production. Complete another real action and check its arrival, then ask your agent to run `dataset_health` for the event you will optimize on.\n\nEmail and phone identifiers need Meta’s normalization and hashing. Hashing `external_id` is recommended; keep its representation consistent across integrations. `fbc` and `client_user_agent` must remain unhashed. See [customer information parameters](https://developers.facebook.com/documentation/ads-commerce/conversions-api/parameters/customer-information-parameters) when implementing the server call.\n\nTest codes identify test payloads. Meta says those events still flow into Events Manager and can be used for targeting and measurement. Test only with actions you actually perform, then remove the code.\n\n`dataset_health` checks recent event counts and, when Meta provides it, event match quality. Its count check covers seven days and Meta’s top 100 events per hour. An event absent from that response is unobserved. A match-quality score does not prove that a recent conversion happened.\n\n## Choose an event that predicts payment.\n\nUse `Purchase` when purchases happen often enough to give the campaign a useful signal. If purchases are sparse, choose a completed action in the same visit that customers take before paying. Check the connection to payment in your own analytics.\n\nMeta says ad sets usually leave learning after about 50 results in the week following the last significant edit. This is guidance about stable delivery. Results below that volume can still tell you about a broken checkout or whether anyone paid.\n\nStandard events use their exact names, such as `Purchase`, `Lead` or `CompleteRegistration`. For your own event, such as `RecipePlanCompleted`, ask your agent to:\n\n1. Run `dataset_health` without a conversion selection and find the exact name in `datasetHealth.events`. Check that the returned `datasetId` is the connected dataset you intend to use. The readiness result may still ask you to select a conversion.\n2. Run `list_custom_conversions` to check for an existing matching rule.\n3. If needed, call `create_custom_conversion` with a name and the exact `eventName`. Keep the returned ID.\n4. Complete the action again after creating the rule. The custom conversion itself must have a recent fire before launch; an earlier underlying event cannot prove a new rule worked.\n\nA custom conversion is a named rule on events in your dataset. Sprid creates a case-sensitive event-name rule. The ad set selects that rule by `customConversionId`.\n\n## Build the campaign through Sprid.\n\nGive your agent the intended countries, total budget, run length, destination and hypothesis. State the regulated category before the campaign is created. Housing, employment, financial products and services have targeting restrictions; declare `NONE` only when no category applies. [Meta’s category guide](https://developers.facebook.com/docs/marketing-api/audiences/special-ad-category/) explains the scope.\n\nAsk the agent to follow this order:\n\n1. **Check readiness:** `ads_overview`, then `dataset_health` with the chosen `conversionEvent`. Fix missing connections or an unobserved event before creating ads.\n2. **Create the paused campaign:** `create_ad_campaign` with an objective such as `OUTCOME_SALES` and explicit `specialAdCategories`. Political campaigns also need explicit `countries`. For housing, credit and employment, Sprid derives the campaign countries from the ad sets.\n3. **Create the paused ad set:** `create_ad_set` with `OFFSITE_CONVERSIONS`, the chosen event, countries and reviewed budget. Use explicit Facebook and Instagram Feed, Stories and Reels placements for paired creatives. Set `targetCostPerEventCents` as the cost you will judge results against. This is a reporting target, not a bid. Use `startAt` if you want a later start.\n4. **Prepare the creative:** `render_ad_creative` builds from a named template, or `create_ad_creative` accepts your own material. Ask for feed and vertical assets. An external pair must identify whether its assets are images or videos. Keep `destinationUrl` free of hand-written `utm_*` parameters; Sprid adds tracking.\n5. **Attach and review:** use `create_ads` to attach the creatives, review each one with `review_ad_copy`, then run `ads_preflight`. Check previews for Feed, Stories and Reels. A passing comparison confirms the stored fields checked against Sprid’s intent; it does not guarantee Meta will approve delivery.\n6. **Approve in Sprid:** `launch_ads` returns the reviewed commitment and an `approvalUrl`. Open that link, check the amount and end date, then press Launch. The agent cannot start spending or raise the budget.\n\nThe following is a fictional ad-set input for Pantry Note on a USD ad account. The IDs are examples. Choose your own budget and target cost before preparing a campaign.\n\n```json\n{\n \"account\": \"pantrynote\",\n \"campaignId\": \"00000000-0000-4000-8000-000000000001\",\n \"name\": \"Completed recipe plan\",\n \"optimizationGoal\": \"OFFSITE_CONVERSIONS\",\n \"conversionEvent\": { \"customConversionId\": \"123456789\" },\n \"dailyBudgetCents\": 1000,\n \"durationDays\": 7,\n \"countries\": [\"US\"],\n \"targeting\": {\n \"publisher_platforms\": [\"facebook\", \"instagram\"],\n \"facebook_positions\": [\"feed\", \"story\", \"facebook_reels\"],\n \"instagram_positions\": [\"stream\", \"story\", \"reels\"]\n },\n \"targetCostPerEventCents\": 500,\n \"hypothesis\": \"Showing the completed meal plan brings customers who purchase.\"\n}\n```\n\nFor a standard event, use `\"conversionEvent\": {\"standard\": \"Purchase\"}`.\n\nSprid requests opt-outs for the documented creative enhancements it supports and checks Meta’s stored settings before launch. It supplies feed and vertical assets when paired material is available. Conversion ad sets store explicit attribution settings: seven-day click, one-day view and one-day engaged video view. A mismatch found by preflight blocks launch.\n\n## Read where people stop.\n\nUse `ad_status` to check delivery and rejection messages. Ask for `ad_council` to compare cost per optimized event with your target. Then compare Meta’s clicks with your app’s actual outcomes.\n\nThe stage-by-stage diagnosis below uses Meta reports and your own analytics. Verify each stage against its source and keep attribution gaps explicit.\n\n| Stage | Compare | Investigate when that stage loses people |\n|---|---|---|\n| Ad | Link clicks per impression | The opening, image or promise |\n| Page loads | Landing-page views per link click | Load errors, slow pages or redirects |\n| App starts | Started actions per landing-page view | Message match and the first action |\n| App completes | Completed actions per started action | Product failures or confusing steps |\n| Customer pays | Paid customers per completed action | The offer, price presentation or checkout |\n\nCompare a consistent cohort and period. Repeat actions by one person are different from independent potential buyers. Keep campaign totals separate from their ad-set totals, since they overlap.\n\nWith zero purchases, the table gives approximate one-sided 95% upper bounds on the purchase rate. It assumes independent comparable people, complete measurement and enough follow-up for them to purchase. Use it to decide what to investigate; a sample alone cannot prove an offer will fail.\n\n| Completed actions from different people, with zero purchases | Approximate upper bound |\n|---|---|\n| 30 | 10% |\n| 60 | 5% |\n| 100 | 3% |\n\nCompare that bound with the purchase rate your margins require. Confirm checkout and purchase tracking work before blaming the offer. [The statistical rule](https://jhanley.biostat.mcgill.ca/c607/ch08/zero_numerator.pdf) explains the approximation.\n\nChanges to targeting, creative or the optimization event can restart learning. Budget changes depend on their size. Record each campaign change and its reason. Fix product and checkout bugs when you find them; changing page content alone is different from editing the ad’s destination or creative.\n\n## If it fails.\n\n- **No recent event observed:** check the dataset ID and server response, perform the action, then recheck Events Manager. Remove the test code before the production check.\n- **Custom conversion has no recent fire:** confirm its dataset and exact event-name rule. Perform the action after the rule exists. Do not substitute an unrelated standard event to pass the check.\n- **Match quality unavailable:** Meta may need dataset permission or an opt-in. Read the score in Events Manager while you resolve access. Missing quality does not turn an observed event into zero.\n- **Preflight names a mismatch:** correct the named setting, or recreate the paused ad set or creative through Sprid when it cannot be edited. Run preflight again. Avoid changing tracked fields independently in Ads Manager.\n- **Budget exceeds a cap:** review the daily and total commitment in Sprid’s account settings. A person must approve the spending limit or budget change in the app.\n- **No purchases yet:** verify measurement, then read the completed-action count and follow-up period. A small cohort is inconclusive.\n\n## Sources.\n\nMeta references checked 14 October 2026. Account-specific acceptance still needs a paused create and readback on your connected account.\n\n- [Meta Ads connection](https://sprid.studio/docs/connect/meta-ads) and [ad-account currency changes](https://www.facebook.com/business/help/291404291014138).\n- [Conversions API: sending and testing events](https://developers.facebook.com/documentation/ads-commerce/conversions-api/using-the-api), [server event parameters](https://developers.facebook.com/documentation/ads-commerce/conversions-api/parameters/server-event), and [click ID handling](https://developers.facebook.com/documentation/ads-commerce/conversions-api/parameters/fbp-and-fbc).\n- [Custom conversions](https://developers.facebook.com/docs/marketing-api/reference/custom-conversion/) and [dataset quality](https://developers.facebook.com/documentation/ads-commerce/conversions-api/dataset-quality-api).\n- [Learning phase](https://www.facebook.com/business/help/112167992830700) and [significant edits](https://www.facebook.com/business/help/316478108955072).\n",
1717
+ "revision": "5b9c3c6ecc3864d7"
1718
1718
  },
1719
1719
  {
1720
1720
  "id": "ads",
1721
1721
  "title": "Ads: pay to show more people what already worked",
1722
1722
  "summary": "Explains how Sprid thinks about paid ads on Instagram, Facebook, YouTube and TikTok: when to spend, how a promotion runs from draft to result, and which numbers to believe along the way.",
1723
1723
  "url": "https://sprid.studio/docs/ads",
1724
- "markdown": "# Ads: pay to show more people what already worked\n\n**What this guide does:** Explains how Sprid thinks about paid ads on\nInstagram, Facebook, YouTube and TikTok: when to spend, how a promotion runs\nfrom draft to result, and which numbers to believe along the way.\n\nAds don't rescue a post nobody wanted. They amplify, and they amplify\nwhatever you give them, including a weak idea. So the order Sprid follows is\norganic first: publish, see which posts people actually watched and saved, and\nput money behind the one that already beat your normal. That post has passed\nthe hardest test for free.\n\n## What we believe\n\n**Promote proof, not hope.** A post that did twice your median has evidence\nbehind it. A brand-new creative has only an opinion. Start with the post, and\nmake new creatives once you know what the winning one had.\n\n**Nothing spends without you.** Everything Sprid or your agent prepares is\npaused. Spending starts only when you press Launch or Confirm in the Sprid app,\non a screen that shows what will run, what it costs a day and for how long. An\nagent can prepare a campaign and send you that link. It can't start one or\nraise a budget itself.\n\n**Keep the words, vary the picture.** We read 3,549 live ads across five\nmarkets. Among advertisers running eight or more, the median was 2.9 ads per\nline of copy, and the largest wellness advertiser ran 109 ads on essentially one\nline. The ad library counts placements separately, so the exact ratio overstates,\nbut the shape holds: the people who keep paying hold the copy and change the\ncreative. A new version changes one thing, or the result teaches nothing.\n\n**Check how much evidence you have.** Meta says ad sets usually leave learning\nafter about 50 results in the week following the last significant edit. That\nis delivery guidance. A smaller sample can still expose a broken checkout.\nSprid’s three-day early-observation flag describes its own review policy;\nit does not report Meta’s learning status. Read the sample and follow-up period\nbefore deciding whether an offer worked.\n\n**Judge by what happens in your product.** Cost per install or subscriber comes\nfirst and impressions come last, because the order a report puts numbers in is\nthe order people believe them in. When your app reports who became a paying\ncustomer, that outranks anything the platform can see.\n\n## Two ways to start\n\n**Promote a post.** Pick a post already live, set a daily budget and an end\ndate, and confirm. The ad runs only where that post lives, through the network\nbehind it:\n\n| Post live on | Runs as | Paid by |\n|---|---|---|\n| Instagram | a Meta ad on Instagram only | your Meta ad account |\n| Facebook | a Meta ad on Facebook only | your Meta ad account |\n| YouTube | a Google Ads video campaign (YouTube, Shorts, Discover, Gmail) | your Google Ads account |\n| TikTok | a Spark Ad on TikTok | your TikTok ad account |\n\nInstagram needs one more thing: the Instagram account has to be linked to the\nFacebook Page your Meta ad account runs as, because that is how Meta runs an\nInstagram ad. Until it is, the Ads page shows Instagram as **Not linked**, and\ntapping it says what to do.\n\nSprid checks the ad account can pay before you choose a budget, the network\nreviews the ad, and you get an email when it goes live, is rejected (with the\nnetwork's reason) or ends (with what the money bought). The budget and end date\ncan change while it runs, from Sprid. A TikTok post also needs the creator's\npermission for that one post: a code the TikTok app makes under the post's Ad\nsettings, pasted into Sprid.\n\n**Run a test.** For finding out what to say, not just saying it louder. Tests\nrun on Meta. Every\ncreative is built from a named shape, such as the screen that shows the app's\nverdict about the reader, two columns where the reader does the arithmetic, or\none sourced number. The shape is what makes a result transferable: \"showing the\nverdict beat listing features\" is something you can build again, \"ad 7 beat\nad 3\" is not.\n\nFor a campaign that optimizes for a signup or purchase, follow\n[Run your first Meta conversion campaign](https://sprid.studio/docs/meta-conversions).\nIt covers server events, custom conversions and the fresh checks before launch.\n\n## The checks before money moves\n\n- **The copy check.** Banned words, your own never-say list, invented urgency,\n and openings that name an identity (\"for anxious people\") instead of a moment\n (\"you reread the message four times\"). Warnings pass, errors block the launch.\n- **Regulated categories.** Housing, credit, employment and political ads must\n be declared. An undeclared one isn't quietly rejected, it's pulled with the\n ad account attached. Anything about where people live, what they can borrow or\n who gets hired counts, even when the product doesn't look like it.\n- **The payment method**, before a budget is chosen rather than after Meta has\n accepted the campaign and refused the ad.\n\n## Reading the result\n\nAsk for an ad set's verdict once a week and it answers three questions: did it\nwork, why, and what next.\nWhen a test ends, it writes down what the hypothesis was, what happened and\nwhich shape carried it, so the next test starts from that instead of from zero.\nA losing set is data: close it and say what it ruled out.\n\nTwo rules for the numbers. A campaign's totals and its ad sets' totals are\nseparate readings of the same money, never added together. And a period that\nends today and one that ended last week are two different readings, so every\nnumber says which window it covers.\n\n## What competitors can and can't tell you\n\nMeta's Ad Library shows every active ad: the creative, the copy and the date it\nstarted. It shows no spend, no clicks and no results. Days live means somebody\nkeeps paying, not that it works, so a competitor's ad is \"longest-running\",\nnever \"best-performing\". Sprid's scout research reads the library for your own\nmarket, which is where the shapes above came from.\n\n## You need\n\n- An ad account with a card on it, connected to Sprid, for the network behind\n the post: [Meta Ads](https://sprid.studio/docs/connect/meta-ads) for\n Instagram and Facebook, [Google Ads](https://sprid.studio/docs/connect/google-ads)\n for YouTube, [TikTok Ads](https://sprid.studio/docs/connect/tiktok-ads) for\n TikTok.\n- A published post to promote, or, on Meta, an image or video to build a\n creative from.\n- You, signed in to the Sprid app, to press Launch. No agent or token can.\n",
1725
- "revision": "39522800c08ab728"
1724
+ "markdown": "# Ads: pay to show more people what already worked\n\n**What this guide does:** Explains how Sprid thinks about paid ads on\nInstagram, Facebook, YouTube and TikTok: when to spend, how a promotion runs\nfrom draft to result, and which numbers to believe along the way.\n\nChoose a paid campaign from the customer's audience, objective and acquisition\neconomics. Organic results can help select a post to promote, but a paid creative\ncan also be tested directly with an approved budget and a measurable outcome.\nNeither social engagement nor a competitor's activity proves profitable demand.\n\n## Preparing a campaign\n\n**Nothing spends without you.** Everything Sprid or your agent prepares is\npaused. Spending starts only when you press Launch or Confirm in the Sprid app,\non a screen that shows what will run, what it costs a day and for how long. An\nagent can prepare a campaign and send you that link. It can't start one or\nraise a budget itself.\n\n**Give each test a question.** A visual test can hold the copy constant; a\nmessage test can hold the format constant. Record what changes and what result\nwould support the hypothesis. When several elements change together, treat the\nresult as evidence about the complete variant.\n\n**Check how much evidence you have.** Read delivery, conversions and the\nfollow-up period before judging a campaign. A platform's learning status and\nSprid's review guidance answer different questions. A small sample may reveal a\nbroken destination without establishing which creative performs better.\n\n**Judge by what happens in your product.** Use the outcome the campaign is meant to produce, such as a qualified lead,\npaid account or completed order. Check attribution and reconcile platform\nconversions with the business records before estimating acquisition value.\n\n## Two ways to start\n\n**Promote a post.** Pick a post already live, set a daily budget and an end\ndate, and confirm. The ad runs only where that post lives, through the network\nbehind it:\n\n| Post live on | Runs as | Paid by |\n|---|---|---|\n| Instagram | a Meta ad on Instagram only | your Meta ad account |\n| Facebook | a Meta ad on Facebook only | your Meta ad account |\n| YouTube | a Google Ads video campaign (YouTube, Shorts, Discover, Gmail) | your Google Ads account |\n| TikTok | a Spark Ad on TikTok | your TikTok ad account |\n\nInstagram needs one more thing: the Instagram account has to be linked to the\nFacebook Page your Meta ad account runs as, because that is how Meta runs an\nInstagram ad. Until it is, the Ads page shows Instagram as **Not linked**, and\ntapping it says what to do.\n\nSprid checks the ad account can pay before you choose a budget, the network\nreviews the ad, and you get an email when it goes live, is rejected (with the\nnetwork's reason) or ends (with what the money bought). The budget and end date\ncan change while it runs, from Sprid. A TikTok post also needs the creator's\npermission for that one post: a code the TikTok app makes under the post's Ad\nsettings, pasted into Sprid.\n\n**Run a test.** For finding out what to say, not just saying it louder. Tests\nrun on Meta. Every\ncreative is built from a named shape, such as the screen that shows the app's\nverdict about the reader, two columns where the reader does the arithmetic, or\none sourced number. The shape is what makes a result transferable: \"showing the\nverdict beat listing features\" is something you can build again, \"ad 7 beat\nad 3\" is not.\n\nFor a campaign that optimizes for a signup or purchase, follow\n[Run your first Meta conversion campaign](https://sprid.studio/docs/meta-conversions).\nIt covers server events, custom conversions and the fresh checks before launch.\n\n## The checks before money moves\n\n- **The copy check.** Banned words, your own never-say list, invented urgency,\n and openings that name an identity (\"for anxious people\") instead of a moment\n (\"you reread the message four times\"). Warnings pass, errors block the launch.\n- **Regulated categories.** Housing, credit, employment and political ads must\n be declared. An undeclared one isn't quietly rejected, it's pulled with the\n ad account attached. Anything about where people live, what they can borrow or\n who gets hired counts, even when the product doesn't look like it.\n- **The payment method**, before a budget is chosen rather than after Meta has\n accepted the campaign and refused the ad.\n\n## Reading the result\n\nAsk for an ad set's verdict once a week and it answers three questions: did it\nwork, why, and what next.\nWhen a test ends, it writes down what the hypothesis was, what happened and\nwhich shape carried it, so the next test starts from that instead of from zero.\nA losing set is data: close it and say what it ruled out.\n\nTwo rules for the numbers. A campaign's totals and its ad sets' totals are\nseparate readings of the same money, never added together. And a period that\nends today and one that ended last week are two different readings, so every\nnumber says which window it covers.\n\n## What competitors can and can't tell you\n\nMeta's Ad Library shows every active ad: the creative, the copy and the date it\nstarted. It shows no spend, no clicks and no results. Days live means somebody\nkeeps paying, not that it works, so a competitor's ad is \"longest-running\",\nnever \"best-performing\". Sprid's scout research reads the library for your own\nmarket. Use those observations to propose tests, with their sources and limits.\n\n## You need\n\n- An ad account with a card on it, connected to Sprid, for the network behind\n the post: [Meta Ads](https://sprid.studio/docs/connect/meta-ads) for\n Instagram and Facebook, [Google Ads](https://sprid.studio/docs/connect/google-ads)\n for YouTube, [TikTok Ads](https://sprid.studio/docs/connect/tiktok-ads) for\n TikTok.\n- A published post to promote, or, on Meta, an image or video to build a\n creative from.\n- You, signed in to the Sprid app, to press Launch. No agent or token can.\n",
1725
+ "revision": "788bf5758834e4b6"
1726
1726
  },
1727
1727
  {
1728
1728
  "id": "reel-first-seconds",
1729
1729
  "title": "The first seconds of a reel",
1730
- "summary": "Explains why a vertical video is watched or skipped in its opening seconds, and gives the timing arithmetic to build one that gets past it. Read it before writing a hook, choosing a length, or diagnosing a reel that got no views.",
1730
+ "summary": "Helps write and inspect the opening of a vertical video, then test changes against the customer's own results.",
1731
1731
  "url": "https://sprid.studio/docs/reel-first-seconds",
1732
- "markdown": "# The first seconds of a reel\n\n**What this guide does:** Explains why a vertical video is watched or skipped in\nits opening seconds, and gives the timing arithmetic to build one that gets past\nit. Read it before writing a hook, choosing a length, or diagnosing a reel that\ngot no views.\n\nBoth platforms decide a video's reach from a small first batch of viewers and\nfrom what each of them does in the first seconds. Nothing after second three\nmatters to someone who left at second two. Everything below follows from that.\n\nEach claim carries how we know it: **measured** (we ran it and wrote the number\ndown), **reproduced** (we made the mistake and watched it happen), **published**\n(the platform or a cited source says so, we have not tested it).\n\n## Frame 0 is the cover, so it must already be readable\n\n**Reproduced, twice, on two different accounts.** The feed shows the video's\nfirst frame as its poster image. A hook that fades in from black has a black\ncover. A hook that animates up from 0.42 opacity reads as washed-out grey for\nthe first half second - which is inside the window that decides whether anyone\nstays.\n\nPlace the first card. Do not animate it in. Light it at 0.7 opacity or more, and\nmake it true at t=0.\n\n## The opener has to end before second three\n\n**Reproduced.** A hook card held for 3.3 seconds puts the first cut at 3.1\nseconds, so the entire skip window contains one motionless card. Nothing has\nhappened yet when the viewer decides.\n\nSize the opener to its own reading load with a floor of 1.6 seconds, and check\nthat the first cut lands under three. A cut inside the window is the cheapest\nsignal you have that this video is going somewhere.\n\n## Length: one measured result, and its confound\n\n**Measured, on one of our own accounts, 2026-08-29.** A batch cut to 15.5\nseconds drew 113-162 views on an account with a single follower. The 29-32\nsecond cuts that followed on the same account drew 6-22, and one sat at 6 views\nafter 29 hours - a distribution collapse rather than a slow start.\n\nState the confound honestly: three things changed in that batch at once\n(background, word density, length). Length is the reversible one, not the proven\none. That account now runs a 10-13 second test. **Neither band is a benchmark**,\nand anyone quoting 15.5 seconds as an optimum - including us - is over-reading a\nsingle comparison.\n\n## The timing arithmetic\n\nReading load, not taste. These are the holds that stop a card from reading faster\nthan it is shown.\n\n| | hold | why |\n|---|---|---|\n| any word | 300 ms | silent reading speed on a phone |\n| any character | 52 ms | a compound noun is one word and twenty letters, so take `max(words, chars)` |\n| opener | 1.6-2.8 s | reading load + 0.5 s, and the first cut must land under 3 s |\n| body or turn | 2.4-4.2 s | reading load + 0.7 s |\n| a big number | 2.6-3.8 s | the number reads at a glance, its label does not |\n| closer | 3.6-5.0 s | reading load + 1.3 s; this is the card people screenshot |\n| overlap | 200 ms | each card starts before the previous one leaves |\n\nWord budgets per card, never totalled: hook 3-11, body 3-14, closer 4-16. A\n3.5-second card holds roughly 10 words or 55 characters at a comfortable size.\nPast that the card reads faster than it holds, and the viewer waits - which is\nthe same experience as being bored.\n\n## What a hook is, and is not\n\n- A sentence your buyer has actually thought, or the sharpest fact stated flat.\n A scene or a number. It opens a loop the closer shuts.\n- **Not a \"how to\".** That creates an expectation of information, and information\n can be postponed. Not a question. Not \"did you know\".\n- **Recognition stops a scroll; tempo does not.** A harvested ten-word hook beat\n an invented four-word one on our own account (**reproduced**). Hooks come from\n what people actually said - forum threads, reviews, support mail - not from the\n bank of phrases that sound like hooks.\n- **The second card is a second hook.** It has to work cold, with the first card\n gone. On carousels the platform literally re-serves from the second slide; on\n video, the first cut is the same second chance.\n- The closer is flat and screenshottable, and it lets the product do the work\n rather than issuing an install instruction.\n\n## The kill rule\n\n**Published**, from platform averages rather than our own numbers: under 25% of\nviewers still watching past three seconds, keep the body and replace the hook.\nAround 30% is healthy and 40% is exceptional on TikTok.\n\nTwo guards on that rule. Never judge a format before ten posts, and never judge\na new account before 90 days - distribution is a power law, and a handful of\nposts carry most of the reach. Reading the first three posts of a new account\nis reading noise.\n\n## Where that number actually lives\n\nThis matters more than it sounds, because the metric the kill rule needs is the\none hardest to get:\n\n- Views, likes, comments, shares, saves and reach come back through the\n platforms' public APIs, and Sprid stores them per post.\n- **Three-second hook rate and the retention curve exist only inside the native\n Instagram and TikTok insights screens.** There is no API field for them as an\n organic number. If you need them, you open the app.\n- Average watch time is available through both business APIs, which makes\n `average watch ÷ duration` the honest proxy you can automate.\n- Taps through to your site or store are the number that pays. The rest are\n directional.\n\n## A reel as a Pinterest video Pin\n\nThe same file can go out as a video Pin, with its own board, destination link,\ntitle and description. The timing arithmetic still holds for whoever presses\nplay. The kill rule does not carry over: a Pin is found through search and\nrelated Pins for months, with no first test batch, so compare video Pins at\nequal ages (30, 60 and 90 days). Pinterest reports daily impressions, saves, Pin\nclicks, outbound clicks and video views (2 seconds with half the video in view),\nwith no hook rate, retention curve, likes, comments or shares. The number that\npays is outbound clicks followed through to visits and activation.\n\n## One picture, many languages\n\nThe reason a demo video is usually made once is that the work is a person\nholding a phone. Split it instead: the wordless part (the footage, the gesture\nscript, the photographs) and the worded part (the captions and cards, bound to\nmarks in the footage rather than to timestamps).\n\nThe rule that makes it hold: **no word is ever inside a picture.** A wordless\ncutaway serves every language you have a string table for, so a seventh language\ncosts its translation and one render rather than a second shoot.\n",
1733
- "revision": "b773194337f31090"
1732
+ "markdown": "# The first seconds of a reel\n\n**What this guide does:** Helps write and inspect the opening of a vertical video,\nthen test changes against the customer's own results.\n\nStart from the audience, the purpose of the post and the account's accepted voice.\nA product demonstration, an explainer and a personal story can need different\nopenings. No fixed duration or hook formula is a performance guarantee.\n\n## Make the opening understandable\n\nInspect the rendered first frame and the opening at normal playback speed on a\nphone-sized preview. Check that the subject and any essential text are visible,\nincluding when playback starts without sound. An empty fade or an unreadable\ncaption can delay the point of the video.\n\nChoose the cover separately where the destination supports it. Do not assume\nthat every platform or placement uses the same frame as its cover.\n\n## Match timing to the content\n\nRead the opening aloud and watch it without pausing. Keep enough time to read\nand understand it, then move when the next useful piece of information arrives.\nShorten excess wording before speeding up the cards.\n\nFor text-led videos, inspect each card's reading load, contrast and placement.\nFor demonstrations, allow the viewer to see the action and its result. Captions\nmust follow speech without covering the thing being explained. Validate each\nlanguage separately because translated text and narration have different lengths.\n\nTreat renderer timing defaults as starting points. Keep deliberate pauses when\nthey help comprehension; neither a cut before a fixed second nor a shorter total\nruntime is evidence that the video will perform better.\n\n## Choose an opening worth testing\n\nUse customer language, a recognizable situation, a useful question, a product\naction or a sourced fact. Deliver what the opening promises. A how-to or a question\nis appropriate when it fits the viewer's intent and the content answers it.\n\nWrite alternatives for the same idea before choosing. Keep the account's voice,\naccepted examples and previous corrections with the brief. Do not invent a\nquotation or present a drafted hook as something a customer said.\n\nChoose the next beat so the explanation progresses. End with the useful result\nand a call to action when it serves the purpose of the post.\n\n## Diagnose with available evidence\n\nRead the connected platform's returned metrics and coverage before selecting a\ncomparison. Use retention or opening-view metrics when actually available through\nthat connection or the user's native analytics. Do not promise an API metric that\nthe current tools do not expose.\n\nAverage watch time divided by duration describes average watch proportion. It\ndoes not establish how many viewers stayed through the opening. Views, likes and\nsaves also cannot reconstruct that curve. State a missing metric as a gap.\n\nCompare posts of similar age, audience, placement and distribution. Show the\nsample size and spread. A small or unevenly distributed sample may support a\nnew experiment without establishing a winning format.\n\n## Test one explanation at a time\n\nRecord the hypothesis, what changes and what outcome would support it. For an\nopening test, keep the body comparable. If length, visuals and wording all change,\nreport the result as a comparison of complete variants rather than attributing\nit to one change.\n\nChoose a review point from the customer's posting cadence and observed delivery.\nDo not impose a universal retention threshold, post count or account age before\na decision. A visible defect can be corrected before a performance test finishes.\nConnect creative results to qualified visits, purchases or the declared business\noutcome where measurement supports it.\n\n## A reel as a Pinterest video Pin\n\nPrepare its board, destination, title and description using the\n[Pinterest guide](../../post/guides/pinterest.md). Compare Pins at matching ages\nand keep platform metrics separate from site visits and product outcomes.\nDo not import an Instagram or TikTok retention benchmark into a Pin review.\n\n## Reuse footage across languages\n\nKeep captions and other editable text separate from footage where practical.\nBind overlays to the relevant actions so translated narration can be retimed.\nLocalize product screens when their visible text matters to the explanation.\nPreview each export; a reusable composition still needs language-specific review.\n",
1733
+ "revision": "0d7c80fbc629126f"
1734
1734
  },
1735
1735
  {
1736
1736
  "id": "store-listing",
package/src/post/cli.mjs CHANGED
@@ -2030,10 +2030,8 @@ async function cmdSync(cfg, argv) {
2030
2030
  for (const m of all) {
2031
2031
  const cp = captionPath(cfg, m.slug);
2032
2032
  if (!existsSync(cp)) continue;
2033
- // **A read that failed is not a post that agrees with us.** The first cut
2034
- // swallowed the error and left the slug out of the map, which reads as "no
2035
- // title drift" - the same unverified-negative that cost the whole day. One
2036
- // retry for a restarting dev server, then it counts as drift.
2033
+ // Retry once, then keep an unreadable remote title in the drift report.
2034
+ // A failed read cannot establish that local and remote titles agree.
2037
2035
  let post = await api(cfg, "GET", `/posts/${m.sprid.postId}`).catch(() => null);
2038
2036
  if (!post) post = await api(cfg, "GET", `/posts/${m.sprid.postId}`).catch(() => null);
2039
2037
  remoteTitle.set(m.slug, post ? (post.title ?? "") : UNREADABLE);
@@ -1,28 +1,7 @@
1
- // The finger.
2
- //
3
- // **Two backends, and the good one does not touch the machine.**
4
- //
5
- // `baguette` (Homebrew, Apple Silicon, Xcode 26) injects touches through
6
- // SimulatorKit's private symbols with the iOS-26 calling conventions. Nothing
7
- // is focused, the pointer never moves, and the Simulator does not come to the
8
- // front - so a run costs the machine nothing and you can keep working through
9
- // it. `baguette input` reads newline-delimited JSON gestures from stdin, which
10
- // is the part that matters: **we keep our own velocity curve.** A canned
11
- // `swipe --duration` would be a linear interpolation, and a linear
12
- // interpolation is the single thing that makes a scripted scroll read as
13
- // scripted.
14
- //
15
- // The CGEvent backend below is the fallback, and it is a real fallback rather
16
- // than a preference: posting to the global HID tap is the ONLY way to reach the
17
- // Simulator without baguette, and the global tap owns the pointer for the
18
- // length of the run. Measured 2026-09-06: `CGEventPostToPid`, plain and with
19
- // fields 91/92 set to the Simulator's window number, delivers without moving
20
- // the cursor and is ignored. Do not spend another hour on it.
21
- //
22
- // Coordinates are normalised 0..1 in both backends. baguette scales x/y by the
23
- // width/height you pass, so the device's pixel size goes straight in and there
24
- // is no window geometry to calibrate at all - which also means resizing the
25
- // Simulator window cannot break a run.
1
+ // Simulator input backends. baguette injects into the selected simulator without
2
+ // taking the desktop pointer. The CGEvent fallback uses the global pointer and
3
+ // requires Accessibility permission. Both take normalized coordinates (0..1).
4
+ // Stream swipe samples at the chosen timing; use dedicated commands for taps.
26
5
 
27
6
  import { spawn, spawnSync } from "node:child_process";
28
7
 
@@ -84,7 +63,7 @@ function baguetteInput(udid, px) {
84
63
 
85
64
  // **Paced by the clock, not by the acknowledgements.** Awaiting each ack
86
65
  // would make the sample rate a function of pipe latency, and the whole point
87
- // of streaming rather than calling `baguette swipe` is that WE own the
66
+ // of streaming rather than calling `baguette swipe` is to preserve the
88
67
  // timing. Errors are read off stdout in the background and raised at the end
89
68
  // of the gesture instead.
90
69
  const send = (type, nx, ny) => {
@@ -100,8 +79,8 @@ function baguetteInput(udid, px) {
100
79
  * **A tap goes through baguette's one-shot path, not the stream.**
101
80
  *
102
81
  * The streaming `touch1-*` path is `IOHIDDigitizerDispatch`, built for
103
- * continuous gestures - swipes, edge pulls, app-switcher drags - and it is
104
- * the reason we can keep our own velocity curve. It is not equally good at
82
+ * continuous gestures - swipes, edge pulls, app-switcher drags - and
83
+ * preserves the gesture velocity curve. It is not equally good at
105
84
  * a discrete tap: UIKit tab bars can ignore streamed taps that React Native
106
85
  * views accept. Use the dedicated tap command for discrete input.
107
86
  *