@sprid/cli 0.1.3 → 0.1.5
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 +28 -0
- package/README-post.md +0 -1
- package/package.json +1 -1
- package/src/cli.mjs +2 -2
- package/src/commands/auth.mjs +7 -1
- package/src/commands/connect.mjs +52 -1
- package/src/commands/family.mjs +1 -41
- package/src/docs/commands.mjs +3 -10
- package/src/docs/guides.generated.mjs +207 -6
- package/src/docs/queries.mjs +1 -1
- package/src/post/cli.mjs +3 -8
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,34 @@
|
|
|
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
|
+
## 0.1.5 - 2026-09-24
|
|
7
|
+
|
|
8
|
+
- `sprid connect ga4|plausible|umami` saves a website-analytics key beside the
|
|
9
|
+
store and revenue ones. The four providers, PostHog included, count the same
|
|
10
|
+
visits, so Sprid reads exactly one of them and never adds them together;
|
|
11
|
+
`--use` names which one answers, and the others stay connected and idle.
|
|
12
|
+
`--property` on Google Analytics is the numeric property id, and a
|
|
13
|
+
measurement id (`G-…`) is refused by name rather than failing on the first
|
|
14
|
+
read. A self-hosted Umami gets `/api` appended to `--host` when it is
|
|
15
|
+
missing, which is the commonest setup mistake.
|
|
16
|
+
- `sprid docs google-analytics|plausible|umami` are the three new connect
|
|
17
|
+
guides. `sprid connect` names all four providers as one choice, not four
|
|
18
|
+
separate gaps.
|
|
19
|
+
|
|
20
|
+
## 0.1.4 - 2026-09-23
|
|
21
|
+
|
|
22
|
+
- `sprid whoami` warns when the credentials file is readable by other users
|
|
23
|
+
on the machine, with the `chmod 600` that fixes it, and reports
|
|
24
|
+
`credentialsFileSecure` in `--json`.
|
|
25
|
+
- `sprid docs` carries the tightened connect guides and the new distribution,
|
|
26
|
+
first-seconds and store-listing guides.
|
|
27
|
+
- `sprid post create --file` saves an idea when the file says `"status":
|
|
28
|
+
"idea"`: notes, a `sourceUrl` and any gathered assets, with no assets
|
|
29
|
+
required. The same `requestId` makes a retry safe, so a repo's hook bank
|
|
30
|
+
can be pushed in and pushed again.
|
|
31
|
+
- `sprid family` is removed with market editions, which the server no longer
|
|
32
|
+
has. `sprid apps`, `sprid insights` and `sprid account` are unchanged.
|
|
33
|
+
|
|
6
34
|
## 0.1.3 - 2026-09-23
|
|
7
35
|
|
|
8
36
|
- Posts and reviews have refs: `BND-78` is post 78 of the account keyed BND,
|
package/README-post.md
CHANGED
|
@@ -269,7 +269,6 @@ the *copy* here anyway:
|
|
|
269
269
|
carousels: {
|
|
270
270
|
kind: "composed",
|
|
271
271
|
spec: "social/specs/<slug>.json",
|
|
272
|
-
// format: <format id>, // choose from this account’s formats
|
|
273
272
|
// template: <template id>, // choose from this account’s templates
|
|
274
273
|
expect: { slides: [5, 9], words: [0, 30] },
|
|
275
274
|
neverSay: ["the phrase this account decided it does not say"],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sprid/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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",
|
package/src/cli.mjs
CHANGED
|
@@ -29,7 +29,7 @@ import * as mcp from "./commands/mcp.mjs";
|
|
|
29
29
|
import * as reviews from "./commands/reviews.mjs";
|
|
30
30
|
import { marketingReview } from "./commands/marketing-review.mjs";
|
|
31
31
|
import { plan, research } from './commands/plan.mjs';
|
|
32
|
-
import { apps,
|
|
32
|
+
import { apps, insights, account } from "./commands/family.mjs";
|
|
33
33
|
import * as post from "./commands/post.mjs";
|
|
34
34
|
import { docs } from "./commands/docs.mjs";
|
|
35
35
|
import { screenshots, release } from './commands/local-tools.mjs';
|
|
@@ -55,7 +55,7 @@ export const COMMANDS = {
|
|
|
55
55
|
plan, research,
|
|
56
56
|
media, capabilities, completion,
|
|
57
57
|
screenshots, release, doctor, update,
|
|
58
|
-
apps,
|
|
58
|
+
apps, insights, account,
|
|
59
59
|
docs,
|
|
60
60
|
login: auth.login,
|
|
61
61
|
logout: auth.logout,
|
package/src/commands/auth.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// a long secret, the person types a short code into a signed-in browser, and
|
|
3
3
|
// the server hands over a personal access token exactly once.
|
|
4
4
|
|
|
5
|
-
import { DEFAULT_API_URL, deleteCredentials, credentialsPath, readCredentials, stripSlash, writeCredentials } from "../creds.mjs";
|
|
5
|
+
import { DEFAULT_API_URL, deleteCredentials, credentialsModeOk, credentialsPath, readCredentials, stripSlash, writeCredentials } from "../creds.mjs";
|
|
6
6
|
import { ApiError } from "../http.mjs";
|
|
7
7
|
import { C, OK } from "../format.mjs";
|
|
8
8
|
import { pickWorkspace } from "../cli.mjs";
|
|
@@ -124,6 +124,8 @@ export async function whoami(ctx) {
|
|
|
124
124
|
// The current one, if it can be known without asking; never exit 2 from whoami.
|
|
125
125
|
const current = await ctx.workspace().catch(() => null);
|
|
126
126
|
const source = a.source === "env" ? "SPRID_PAT" : credentialsPath(ctx.env);
|
|
127
|
+
// Written 0600, but a later copy, restore or chmod can loosen it silently.
|
|
128
|
+
const fileSecure = a.source === "env" ? null : credentialsModeOk(ctx.env);
|
|
127
129
|
if (ctx.json) {
|
|
128
130
|
ctx.out({
|
|
129
131
|
email: user.email ?? a.email ?? null,
|
|
@@ -133,6 +135,7 @@ export async function whoami(ctx) {
|
|
|
133
135
|
apiUrl: a.apiUrl,
|
|
134
136
|
tokenSource: source,
|
|
135
137
|
tokenId: a.tokenId,
|
|
138
|
+
credentialsFileSecure: fileSecure,
|
|
136
139
|
});
|
|
137
140
|
return 0;
|
|
138
141
|
}
|
|
@@ -143,6 +146,9 @@ export async function whoami(ctx) {
|
|
|
143
146
|
: "no workspace";
|
|
144
147
|
ctx.print(` ${user.email ?? a.email ?? "(unknown)"} · workspace ${wsLine}`);
|
|
145
148
|
ctx.print(C.dim(` ${a.apiUrl} · token from ${source}`));
|
|
149
|
+
if (fileSecure === false) {
|
|
150
|
+
ctx.print(` Warning: ${source} is readable by other users on this machine. Fix: chmod 600 "${source}"`);
|
|
151
|
+
}
|
|
146
152
|
if (workspaces.length > 1) {
|
|
147
153
|
ctx.print(workspaceList(workspaces, current));
|
|
148
154
|
if (!current) ctx.print(C.dim(" Pick one: sprid use <slug>"));
|
package/src/commands/connect.mjs
CHANGED
|
@@ -26,6 +26,12 @@ export const SENSOR_SERVICES = [
|
|
|
26
26
|
"play",
|
|
27
27
|
"gsc",
|
|
28
28
|
"posthog",
|
|
29
|
+
// Website analytics. These three are ALTERNATIVES - they count the same
|
|
30
|
+
// visits - so connecting a second one does not add visitors, it offers a
|
|
31
|
+
// different source. `--use` picks which one answers.
|
|
32
|
+
"ga4",
|
|
33
|
+
"plausible",
|
|
34
|
+
"umami",
|
|
29
35
|
// The revenue rails. RevenueCat reads store sales; the other four read web
|
|
30
36
|
// ones, and a customer may legitimately have two of them.
|
|
31
37
|
"revenuecat",
|
|
@@ -44,6 +50,9 @@ export const SENSOR_KEYS = {
|
|
|
44
50
|
play: ["playServiceAccountJson"],
|
|
45
51
|
gsc: ["gscServiceAccountJson"],
|
|
46
52
|
posthog: ["posthogApiKey"],
|
|
53
|
+
ga4: ["ga4ServiceAccountJson"],
|
|
54
|
+
plausible: ["plausibleApiKey"],
|
|
55
|
+
umami: ["umamiApiKey"],
|
|
47
56
|
revenuecat: ["revenuecatApiKey"],
|
|
48
57
|
stripe: ["stripeApiKey"],
|
|
49
58
|
polar: ["polarApiKey"],
|
|
@@ -57,6 +66,9 @@ const SENSOR_LABEL = {
|
|
|
57
66
|
play: "Google Play",
|
|
58
67
|
gsc: "Search Console",
|
|
59
68
|
posthog: "PostHog",
|
|
69
|
+
ga4: "Google Analytics",
|
|
70
|
+
plausible: "Plausible",
|
|
71
|
+
umami: "Umami",
|
|
60
72
|
revenuecat: "RevenueCat",
|
|
61
73
|
stripe: "Stripe",
|
|
62
74
|
polar: "Polar",
|
|
@@ -124,7 +136,8 @@ async function wizard(ctx) {
|
|
|
124
136
|
const optional = [];
|
|
125
137
|
for (const p of profiles) {
|
|
126
138
|
const opts = [];
|
|
127
|
-
|
|
139
|
+
// One website-analytics provider is enough; they measure the same visits.
|
|
140
|
+
if (!p.posthogProjectId && !p.ga4PropertyId && !p.plausibleSiteId && !p.umamiWebsiteId) opts.push("PostHog, Google Analytics, Plausible or Umami");
|
|
128
141
|
if (!p.revenuecatProjectId) opts.push("RevenueCat");
|
|
129
142
|
if (!p.gscProperty) opts.push("Search Console");
|
|
130
143
|
if (!p.cloudflareZoneId) opts.push("Cloudflare");
|
|
@@ -301,6 +314,44 @@ export function sensorPatch(service, flags, cwd) {
|
|
|
301
314
|
}
|
|
302
315
|
return body;
|
|
303
316
|
}
|
|
317
|
+
case "ga4": {
|
|
318
|
+
// The Data API takes the numeric property id. A measurement id
|
|
319
|
+
// (G-XXXXXXX) is the tag on the page and is refused, so say that here
|
|
320
|
+
// rather than letting the first read fail an hour later.
|
|
321
|
+
const property = String(need("property")).replace(/^properties\//, "");
|
|
322
|
+
if (!/^\d+$/.test(property)) {
|
|
323
|
+
throw new UsageError(
|
|
324
|
+
`--property must be the numeric GA4 property id, not "${property}". Google Analytics → Admin → Property details → Property ID.`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
const body = { ga4PropertyId: property, secrets: { ga4ServiceAccountJson: readKeyFile(need("key"), cwd) } };
|
|
328
|
+
if (flags.use) body.trafficProvider = "ga4";
|
|
329
|
+
return body;
|
|
330
|
+
}
|
|
331
|
+
case "plausible": {
|
|
332
|
+
const body = {
|
|
333
|
+
plausibleSiteId: String(need("site")).replace(/^https?:\/\//, "").replace(/\/+$/, ""),
|
|
334
|
+
secrets: { plausibleApiKey: fileOrValue(need("key"), cwd) },
|
|
335
|
+
};
|
|
336
|
+
if (flags.host && flags.host !== true) body.plausibleHost = String(flags.host).replace(/\/+$/, "");
|
|
337
|
+
if (flags.use) body.trafficProvider = "plausible";
|
|
338
|
+
return body;
|
|
339
|
+
}
|
|
340
|
+
case "umami": {
|
|
341
|
+
const body = {
|
|
342
|
+
umamiWebsiteId: String(need("site")),
|
|
343
|
+
secrets: { umamiApiKey: fileOrValue(need("key"), cwd) },
|
|
344
|
+
};
|
|
345
|
+
// A self-hosted Umami serves its API under /api. Leaving that off is the
|
|
346
|
+
// commonest setup mistake, and it fails as a 404 on every read, so add
|
|
347
|
+
// it here rather than explaining it in a guide nobody re-reads.
|
|
348
|
+
if (flags.host && flags.host !== true) {
|
|
349
|
+
const host = String(flags.host).replace(/\/+$/, "");
|
|
350
|
+
body.umamiHost = /\/api$/.test(host) ? host : `${host}/api`;
|
|
351
|
+
}
|
|
352
|
+
if (flags.use) body.trafficProvider = "umami";
|
|
353
|
+
return body;
|
|
354
|
+
}
|
|
304
355
|
case "revenuecat":
|
|
305
356
|
return { revenuecatProjectId: need("project"), secrets: { revenuecatApiKey: fileOrValue(need("key"), cwd) } };
|
|
306
357
|
// Stripe and Paddle need no id: the key names the account. Polar takes an
|
package/src/commands/family.mjs
CHANGED
|
@@ -18,53 +18,13 @@ function objectFile(ctx) {
|
|
|
18
18
|
function positiveId(raw) {
|
|
19
19
|
const id = Number(raw);
|
|
20
20
|
if (!Number.isInteger(id) || id <= 0)
|
|
21
|
-
throw new UsageError("Pass a positive
|
|
21
|
+
throw new UsageError("Pass a positive account ID.");
|
|
22
22
|
return id;
|
|
23
23
|
}
|
|
24
24
|
export async function apps(ctx) {
|
|
25
25
|
ctx.out(await ctx.api().get("/api/app-profiles/hierarchy"));
|
|
26
26
|
return 0;
|
|
27
27
|
}
|
|
28
|
-
export async function family(ctx) {
|
|
29
|
-
const [verb = "list", raw] = ctx.positionals;
|
|
30
|
-
let result;
|
|
31
|
-
if (verb === "list" || verb === "compare") {
|
|
32
|
-
const workspaceId = await ctx.workspaceId();
|
|
33
|
-
const app = await resolveProfile(ctx, workspaceId);
|
|
34
|
-
result = await ctx
|
|
35
|
-
.api()
|
|
36
|
-
.get(
|
|
37
|
-
`/api/content-families${verb === "compare" ? "/performance" : ""}?workspaceId=${workspaceId}&app=${app.id}&ageDays=${encodeURIComponent(ctx.flags.days ?? 7)}`,
|
|
38
|
-
);
|
|
39
|
-
} else if (verb === "create")
|
|
40
|
-
result = await ctx.api().post("/api/content-families", objectFile(ctx));
|
|
41
|
-
else {
|
|
42
|
-
const id = positiveId(raw);
|
|
43
|
-
const edition = ["get", "preview", "review", "localize"].includes(verb);
|
|
44
|
-
const path = edition
|
|
45
|
-
? `/api/content-families/editions/${id}`
|
|
46
|
-
: `/api/content-families/${id}`;
|
|
47
|
-
if (verb === "get") result = await ctx.api().get(path);
|
|
48
|
-
else if (verb === "preview")
|
|
49
|
-
result = await ctx.api().post(`${path}/preview`, {});
|
|
50
|
-
else if (
|
|
51
|
-
["edition", "review", "localize", "plan", "schedule"].includes(verb)
|
|
52
|
-
)
|
|
53
|
-
result = await ctx
|
|
54
|
-
.api()
|
|
55
|
-
.post(
|
|
56
|
-
`${path}/${verb === "edition" ? "editions" : verb}`,
|
|
57
|
-
objectFile(ctx),
|
|
58
|
-
);
|
|
59
|
-
else
|
|
60
|
-
throw new UsageError(
|
|
61
|
-
"sprid family list|create|edition|get|localize|preview|review|plan|schedule|compare [id] [--file payload.json]",
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
// Always print exact IDs, preview URLs, blockers and revision fingerprints.
|
|
65
|
-
ctx.out(result);
|
|
66
|
-
return 0;
|
|
67
|
-
}
|
|
68
28
|
export async function insights(ctx) {
|
|
69
29
|
const workspaceId = await ctx.workspaceId();
|
|
70
30
|
const app = await resolveProfile(ctx, workspaceId);
|
package/src/docs/commands.mjs
CHANGED
|
@@ -28,7 +28,7 @@ export const COMMAND_GROUPS = [
|
|
|
28
28
|
['media', 'sprid media reel --file <recipe.json>', 'Create a metered hosted photos/text reel after explicit user authorization. Returns a durable job ID.'],
|
|
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
|
-
['post', 'sprid post create --file <draft.json>', 'Create a shared draft from ordered asset references and a stable requestId. Never publishes.'],
|
|
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
32
|
['post', 'sprid post get <postId>', 'Read the shared post and its slides. Every <postId> here takes the number or the post\'s ref, such as BND-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.'],
|
|
@@ -45,19 +45,11 @@ export const COMMAND_GROUPS = [
|
|
|
45
45
|
['release', 'sprid release metadata|screenshots|graphics [--config release.config.ts] [--execute]', 'Preview store listing changes locally; --execute sends the reviewed changes. Requires Bun.'],
|
|
46
46
|
['release', 'sprid release ship [--execute] [--ios-only|--android-only]', 'Build and submit your app using its release configuration. Dry run by default; use --help for all options.'],
|
|
47
47
|
] },
|
|
48
|
-
{ title: 'Apps and
|
|
48
|
+
{ title: 'Apps and accounts', entries: [
|
|
49
49
|
['account', 'sprid account create|update [id] --file payload.json', 'Create an app content account or update its identity. Creation requires workspaceId, appProfileId, name and slug; market, locale, persona and timezone are explicit.'],
|
|
50
50
|
['account', 'sprid account avatar <account> [file|url]', 'Set the avatar Sprid’s mails and templates draw. No source uses the app’s icon: App Store artwork, else the website’s manifest or touch icon. A file must be PNG, JPEG or WebP; a 1024 px app icon from the repo is the best source.'],
|
|
51
51
|
['apps', 'sprid apps', 'List apps with their content accounts and workspace IDs.'],
|
|
52
52
|
['insights', 'sprid insights --app <slug> [--account <slug>] [--days N]', 'Read app results once, with social content optionally narrowed to one account.'],
|
|
53
|
-
['family', 'sprid family compare --app <slug> [--days N]', 'Compare themes at matched post age with sample counts and channel baselines.'],
|
|
54
|
-
['family', 'sprid family list --app <slug>', 'List content families, themes and market editions.'],
|
|
55
|
-
['family', 'sprid family create --file payload.json', 'Create a family: sourcePostId, title, theme and sourceReference.'],
|
|
56
|
-
['family', 'sprid family edition <familyId> --file payload.json', 'Copy an independent draft: accountId, locale and market. Translate it in the editor or through the API.'],
|
|
57
|
-
['family', 'sprid family localize <postId> --file payload.json', 'Save translated captions and slide text or reel beats atomically, clearing old approval.'],
|
|
58
|
-
['family', 'sprid family get|preview <postId>', 'Read the edition or render its current content for review.'],
|
|
59
|
-
['family', 'sprid family review <postId> --file payload.json', 'Approve a rendered revision with fingerprint, sourceReference and confirmed: true.'],
|
|
60
|
-
['family', 'sprid family plan|schedule <familyId> --file payload.json', 'Plan exact postId/channelId/scheduledFor destinations. Scheduling also requires the returned planFingerprint.'],
|
|
61
53
|
] },
|
|
62
54
|
{ title: 'Sign in', entries: [
|
|
63
55
|
['setup', 'sprid setup begin --file <artifact> --task publish|review|repo [--source <source>] [--entry website|installed-skill|browser|paid]', 'Save the first useful result locally before signup. Repeating begin preserves the same task and retry key.'],
|
|
@@ -75,6 +67,7 @@ export const COMMAND_GROUPS = [
|
|
|
75
67
|
['connect', 'sprid connect', 'Show missing connections and the commands to add them.'],
|
|
76
68
|
['connect', 'sprid connect <service> --app <slug> --key <file> …', 'Save a store or analytics key. Read sprid docs <service> for the required fields.'],
|
|
77
69
|
['connect', 'sprid connect <platform> --account <slug>', 'Connect a publishing account through the platform’s sign-in page.'],
|
|
70
|
+
['connect', 'sprid connect ga4|plausible|umami --app <slug> --key <file> … [--use]', 'PostHog, Google Analytics, Plausible and Umami all count the same website visits, so Sprid reads one of them and never adds them together. --use makes this one the source the traffic card reports from.'],
|
|
78
71
|
['pinterest', 'sprid pinterest boards --account <slug> [--channel <id>] [--bookmark <token>]', 'List boards and sections for the exact connected Pinterest channel; a returned bookmark reads the next page.'],
|
|
79
72
|
['pinterest', 'sprid pinterest create --account <slug> --name <name> [--description <text>] [--privacy public|secret]', 'Create a board in the exact connected Pinterest account. Older connections must reconnect once to grant board creation permission.'],
|
|
80
73
|
['pinterest', 'sprid pinterest set <postId> --board <id> --link <url> [--section <id>] [--title "…"] [--description "…"] [--alt-text "…"] [--ai-disclosures AI_MODIFIED,SYNTHETIC_PERFORMER]', 'Save the reviewed Pin destination and searchable metadata on a draft.'],
|
|
@@ -13,6 +13,9 @@ export const GUIDE_GROUPS = [
|
|
|
13
13
|
"google-play",
|
|
14
14
|
"search-console",
|
|
15
15
|
"posthog",
|
|
16
|
+
"google-analytics",
|
|
17
|
+
"plausible",
|
|
18
|
+
"umami",
|
|
16
19
|
"revenuecat",
|
|
17
20
|
"stripe",
|
|
18
21
|
"polar",
|
|
@@ -37,7 +40,10 @@ export const GUIDE_GROUPS = [
|
|
|
37
40
|
export const GUIDE_ALIASES = {
|
|
38
41
|
"asc": "app-store-connect",
|
|
39
42
|
"play": "google-play",
|
|
40
|
-
"gsc": "search-console"
|
|
43
|
+
"gsc": "search-console",
|
|
44
|
+
"ga4": "google-analytics",
|
|
45
|
+
"ga": "google-analytics",
|
|
46
|
+
"google-analytics-4": "google-analytics"
|
|
41
47
|
};
|
|
42
48
|
export const METRIC_EVIDENCE = "# Metric evidence\n\nSprid’s review packet includes a versioned `dataset`: observations, checked results and hashes of the source receipts. The CLI saves complete review/query packets under `marketing-reports/evidence/` with immutable content-hash filenames and owner-only file permissions. The plugin runner preserves that dataset alongside its local evidence and git context. An explicit runner `--output` refuses to overwrite an existing file.\n\n## Read a number\n\n- `definition` names the entity, measurement kind, unit, calculation basis and implementation version. An active subscription is not a unique customer; a store download is not an activated person.\n- `window` uses an exclusive end and retains its calendar. `asOf` belongs to a snapshot; `fetchedAt` records collection. Historical revenue and current MRR are separate observations.\n- `quality` separates coverage, pagination, sampling, finality and trust. A successful HTTP request does not prove complete coverage. Unknown, pending, suppressed and unavailable values remain missing.\n- `value.kind: money` stores an exact decimal coefficient and scale with a currency and conversion policy. Do not assume every provider amount is cents. Keep gross, refunded revenue and proceeds separate.\n\n## Respond to a blocked calculation\n\n| Reason | Continue with |\n| --- | --- |\n| `currency_mismatch` | Separate currency rows; convert only with explicit dated FX evidence and a declared policy. |\n| `overlap_unresolved` | Source subtotals and the identity check. Do not add RevenueCat and a rail it already ingests. |\n| `definition_mismatch` | Name the provider definitions separately, including MRR policy and revenue basis. |\n| `window_mismatch` | Request matching periods/calendars. A daily Pacific aggregate cannot be relabeled UTC. |\n| `population_mismatch` | Verify numerator/denominator attribution with an actual identity mapping. All-app revenue cannot stand for website-cohort revenue. |\n| `incomplete_sources`, `incomplete_coverage` | Keep usable readings; complete pagination or narrow the period. Missing dates are not automatically zero. |\n| `unverified_calculation` | Inspect emitting code and the query’s population, ordering and exclusions. Preserve a local attestation for local database work. |\n| `non_additive` | Query period-level distinct people, select the relevant snapshot, or recompute a rate from compatible numerators and denominators. |\n\n## Investigate through Sprid\n\nRun `sprid marketing-review capabilities --app <slug>` to discover operations. The `revenuecat` / `revenue` operation reads an explicit period total, with `revenue_type` set to `revenue`, `revenue_net_of_taxes` or `proceeds`. Its scope is the bound RevenueCat project, which can contain multiple apps. Use `chart_options` before selecting chart dimensions. Cohort-chart period zero may describe customer count, not the month-zero metric.\n\nCustom query results carry `measurement` provenance and retain provider-native data. They remain investigative; the server does not certify arbitrary SQL’s business meaning. The shared calculation library supports sequential/strict ordered funnels and fixed elapsed-time retention from complete event evidence. These helpers are not additional public query operation names. For provider-side investigations, use the discovered custom query operation and preserve the exact SQL.\n\nStripe MRR is a current active/past-due price estimate before discounts. Paddle’s adapter reports gross completed transactions before adjustments. Lemon Squeezy combines orders with renewal/update invoices and excludes duplicate initial invoices; a complete priced schedule is required for MRR. Separate source readings survive when combined totals cannot be justified.\n\nRevenue observations and aggregate Search Console observations are normalized in the review dataset. Other source receipts retain native definitions and coverage; they are not silently converted into a universal business scorecard. App-specific activation, cross-source identity and attribution still require repository evidence. Local fallback results do not automatically inherit the server dataset’s verification.\n\nSave the full packet and exact query beside the report. Hashes identify evidence but cannot recreate it. Link corrected reports to their earlier dataset and retain the earlier packet; provider backfills and refund revisions must remain inspectable. Sprid does not upload repository database rows or provide hosted dataset-history storage through this contract.\n";
|
|
43
49
|
export const GUIDES = [
|
|
@@ -330,7 +336,7 @@ export const GUIDES = [
|
|
|
330
336
|
"id": "metric-definitions",
|
|
331
337
|
"title": "Metric definitions",
|
|
332
338
|
"kind": "detail",
|
|
333
|
-
"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| Registrations | First `sprid_signup` event per PostHog person, across all surfaces |\n| New
|
|
339
|
+
"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| Registrations | 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**Registrations.** 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. No matching event history reads as unavailable; history with no new registrations reads as zero. Periods before the tracking start are unavailable, and comparisons crossing it, or with no confirmed start, are withheld. Backfill with the original creation timestamps.\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### 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.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."
|
|
334
340
|
},
|
|
335
341
|
{
|
|
336
342
|
"id": "review-suspected-automated-traffic",
|
|
@@ -363,8 +369,179 @@ export const GUIDES = [
|
|
|
363
369
|
"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)"
|
|
364
370
|
}
|
|
365
371
|
],
|
|
366
|
-
"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. Copy the key before closing the dialog and save it to a private file such as `~/keys/posthog-myapp.txt`. Use a separate key per app.\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 ~/keys/posthog-myapp.txt --project 12345 --host eu\n```\n\nUse your own app slug, file path, project id and host. Add `--workspace <slug>` if needed. Keep the key out of chat.\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| Registrations | First `sprid_signup` event per PostHog person, across all surfaces |\n| New app users | First native app activity, including anonymous people |\n| Active app users | Distinct people with native activity in the period |\n| Web visitors | Distinct pageview visitors on the saved website hostname |\n\n**Registrations.** 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. No matching event history reads as unavailable; history with no new registrations reads as zero. Periods before the tracking start are unavailable, and comparisons crossing it, or with no confirmed start, are withheld. Backfill with the original creation timestamps.\n\n**App and web.** Native defaults need `posthog-react-native` on iOS, iPadOS or Android and reject 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### Custom properties\n\nAdvanced rules go under **Custom properties**:\n\n```json\n{\"registration\":{\"identity\":{\"scope\":\"event\",\"property\":\"account_id\"}},\"app\":{\"filters\":[{\"scope\":\"event\",\"property\":\"client_type\",\"operator\":\"in\",\"values\":[\"native\"]}]},\"exclude\":[{\"scope\":\"person\",\"property\":\"staff\",\"operator\":\"in\",\"values\":[true]}]}\n```\n\n- `app.filters` replaces SDK/OS matching with your own native rule; all filters must match.\n- `exclude` removes matching traffic from every metric.\n- `registration.filters` narrows the registration event, for example `result = success`.\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## 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\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",
|
|
367
|
-
"revision": "
|
|
372
|
+
"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. Copy the key before closing the dialog and save it to a private file such as `~/keys/posthog-myapp.txt`. Use a separate key per app.\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 ~/keys/posthog-myapp.txt --project 12345 --host eu\n```\n\nUse your own app slug, file path, project id and host. Add `--workspace <slug>` if needed. Keep the key out of chat.\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| Registrations | 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**Registrations.** 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. No matching event history reads as unavailable; history with no new registrations reads as zero. Periods before the tracking start are unavailable, and comparisons crossing it, or with no confirmed start, are withheld. Backfill with the original creation timestamps.\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### 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.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## 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\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",
|
|
373
|
+
"revision": "2b1d02d602ee3177"
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
"id": "google-analytics",
|
|
377
|
+
"title": "Google Analytics 4",
|
|
378
|
+
"summary": "See how many people visit your website, where they came from and which pages they land on. If your app uses Firebase Analytics, this is also how Sprid sees your app’s active and new users.",
|
|
379
|
+
"command": "sprid connect ga4 --key ~/Downloads/ga4-sa.json --property 493820184",
|
|
380
|
+
"url": "https://sprid.studio/docs/connect/google-analytics",
|
|
381
|
+
"sections": [
|
|
382
|
+
{
|
|
383
|
+
"id": "you-need",
|
|
384
|
+
"title": "You need",
|
|
385
|
+
"kind": "requirements",
|
|
386
|
+
"markdown": "**Editor** or **Administrator** on the GA4 property, and a Google service account. You can reuse the one from Sprid’s Google Play or Search Console connection."
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
"id": "click-path",
|
|
390
|
+
"title": "Click path",
|
|
391
|
+
"kind": "steps",
|
|
392
|
+
"markdown": "### A. Google Cloud\n\n1. Open [Google Cloud](https://console.cloud.google.com) and select your service account’s project.\n2. **APIs & Services → Library**: enable **Google Analytics Data API**.\n3. No service account yet? **IAM & Admin → Service Accounts → Create service account**, name it `sprid-analytics`, then **Done**.\n4. Open the account → **Keys → Add key → Create new key → JSON**. Save it as `ga4-sa.json` and copy the account’s email. Reusing an account? Use its existing key file and email.\n\n### B. Google Analytics\n\n5. Open [Google Analytics](https://analytics.google.com) and select the property.\n6. **Admin → Property access management → + → Add users**.\n7. Enter the service account’s email, choose **Viewer**, uncheck the notification email, then **Add**.\n\n### C. The property id\n\n8. **Admin → Property details**. Copy **Property ID**: digits only, such as `493820184`.\n\nA measurement id (`G-XXXXXXX`) is the tag on your page, not the property. The Data API refuses it, so Sprid rejects it when you type it."
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
"id": "then-run",
|
|
396
|
+
"title": "Then run",
|
|
397
|
+
"kind": "command",
|
|
398
|
+
"markdown": "```\nsprid connect ga4 --key ~/Downloads/ga4-sa.json --property 493820184\n```\n\nUse your own file path and property id. Keep the file private.\n\nAdd `--use` to make Google Analytics the source Sprid reports website traffic from. See **Only one provider answers** below."
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
"id": "only-one-provider-answers",
|
|
402
|
+
"title": "Only one provider answers",
|
|
403
|
+
"kind": "detail",
|
|
404
|
+
"markdown": "PostHog, Google Analytics and Plausible all count the same visits to the same site. Sprid reads **one** of them per app and never adds them together, because adding them would overstate your traffic by roughly the overlap, and the overlap is nearly everything.\n\nWith one connected, that one answers. With several, Sprid uses the one you chose with `--use` (PostHog by default, because it also answers the people and registration cards). The others stay connected and idle, and the traffic card says so."
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
"id": "how-to-check-it-worked",
|
|
408
|
+
"title": "How to check it worked",
|
|
409
|
+
"kind": "verify",
|
|
410
|
+
"markdown": "Run `sprid status`, then ask your agent: “Read this website’s visitors for the last 28 days through Sprid and say which provider answered.” A saved key confirms setup; only the live read confirms access."
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
"id": "if-it-fails",
|
|
414
|
+
"title": "If it fails",
|
|
415
|
+
"kind": "troubleshooting",
|
|
416
|
+
"markdown": "- **Permission denied:** access is granted on the property, not on the Google Cloud project. Check the service account’s email is in **Property access management** with at least Viewer.\n- **API not enabled:** enable **Google Analytics Data API** in the project the key came from, then wait a minute.\n- **Property not found:** you probably saved a measurement id or a stream id. Use **Admin → Property details → Property ID**.\n- **Numbers do not match the GA4 dashboard:** Google applies thresholding and its own bot filtering, and reports in the property’s time zone rather than UTC. Small differences on small properties are expected."
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
"id": "what-sprid-can-and-cannot-read-here",
|
|
420
|
+
"title": "What Sprid can and cannot read here",
|
|
421
|
+
"kind": "detail",
|
|
422
|
+
"markdown": "Visitors, pageviews, sessions, engaged sessions and engagement time, plus breakdowns by country, region, city, source, channel group, campaign, term, page, landing page, hostname, browser, operating system and device.\n\nTwo honest gaps. Google reports **no exit page**, so that breakdown is empty rather than estimated. And **channel** is Google’s own default channel group and **referrer** is the session source, neither of which is the raw referring domain another provider would show, so those rows do not compare like for like across providers."
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
"id": "sources",
|
|
426
|
+
"title": "Sources",
|
|
427
|
+
"kind": "sources",
|
|
428
|
+
"markdown": "- [Data API `runReport`](https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/properties/runReport)\n- [Batch limit of five reports](https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/properties/batchRunReports)\n- [Firebase reports into Google Analytics](https://firebase.google.com/docs/analytics)\n- [Linking a Firebase app to a GA4 property](https://support.google.com/analytics/answer/9289234)"
|
|
429
|
+
}
|
|
430
|
+
],
|
|
431
|
+
"markdown": "# Google Analytics 4\n\n**What Sprid does with this:** See how many people visit your website, where they came from and which pages they land on. If your app uses Firebase Analytics, this is also how Sprid sees your app’s active and new users.\n\nFirebase Analytics **is** Google Analytics: a Firebase app reports into a linked GA4 property, and that property is what you connect here.\n\n## You need\n\n**Editor** or **Administrator** on the GA4 property, and a Google service account. You can reuse the one from Sprid’s Google Play or Search Console connection.\n\n## Click path\n\n### A. Google Cloud\n\n1. Open [Google Cloud](https://console.cloud.google.com) and select your service account’s project.\n2. **APIs & Services → Library**: enable **Google Analytics Data API**.\n3. No service account yet? **IAM & Admin → Service Accounts → Create service account**, name it `sprid-analytics`, then **Done**.\n4. Open the account → **Keys → Add key → Create new key → JSON**. Save it as `ga4-sa.json` and copy the account’s email. Reusing an account? Use its existing key file and email.\n\n### B. Google Analytics\n\n5. Open [Google Analytics](https://analytics.google.com) and select the property.\n6. **Admin → Property access management → + → Add users**.\n7. Enter the service account’s email, choose **Viewer**, uncheck the notification email, then **Add**.\n\n### C. The property id\n\n8. **Admin → Property details**. Copy **Property ID**: digits only, such as `493820184`.\n\nA measurement id (`G-XXXXXXX`) is the tag on your page, not the property. The Data API refuses it, so Sprid rejects it when you type it.\n\n## Then run\n\n```\nsprid connect ga4 --key ~/Downloads/ga4-sa.json --property 493820184\n```\n\nUse your own file path and property id. Keep the file private.\n\nAdd `--use` to make Google Analytics the source Sprid reports website traffic from. See **Only one provider answers** below.\n\n## Only one provider answers\n\nPostHog, Google Analytics and Plausible all count the same visits to the same site. Sprid reads **one** of them per app and never adds them together, because adding them would overstate your traffic by roughly the overlap, and the overlap is nearly everything.\n\nWith one connected, that one answers. With several, Sprid uses the one you chose with `--use` (PostHog by default, because it also answers the people and registration cards). The others stay connected and idle, and the traffic card says so.\n\n## How to check it worked\n\nRun `sprid status`, then ask your agent: “Read this website’s visitors for the last 28 days through Sprid and say which provider answered.” A saved key confirms setup; only the live read confirms access.\n\n## If it fails\n\n- **Permission denied:** access is granted on the property, not on the Google Cloud project. Check the service account’s email is in **Property access management** with at least Viewer.\n- **API not enabled:** enable **Google Analytics Data API** in the project the key came from, then wait a minute.\n- **Property not found:** you probably saved a measurement id or a stream id. Use **Admin → Property details → Property ID**.\n- **Numbers do not match the GA4 dashboard:** Google applies thresholding and its own bot filtering, and reports in the property’s time zone rather than UTC. Small differences on small properties are expected.\n\n## What Sprid can and cannot read here\n\nVisitors, pageviews, sessions, engaged sessions and engagement time, plus breakdowns by country, region, city, source, channel group, campaign, term, page, landing page, hostname, browser, operating system and device.\n\nTwo honest gaps. Google reports **no exit page**, so that breakdown is empty rather than estimated. And **channel** is Google’s own default channel group and **referrer** is the session source, neither of which is the raw referring domain another provider would show, so those rows do not compare like for like across providers.\n\n## Sources\n\n- [Data API `runReport`](https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/properties/runReport)\n- [Batch limit of five reports](https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/properties/batchRunReports)\n- [Firebase reports into Google Analytics](https://firebase.google.com/docs/analytics)\n- [Linking a Firebase app to a GA4 property](https://support.google.com/analytics/answer/9289234)\n",
|
|
432
|
+
"revision": "dfd97414267786dc"
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
"id": "plausible",
|
|
436
|
+
"title": "Plausible",
|
|
437
|
+
"summary": "See how many people visit your website, where they came from and which pages they land on.",
|
|
438
|
+
"command": "sprid connect plausible --key ~/Downloads/plausible-key.txt --site example.com",
|
|
439
|
+
"url": "https://sprid.studio/docs/connect/plausible",
|
|
440
|
+
"sections": [
|
|
441
|
+
{
|
|
442
|
+
"id": "you-need",
|
|
443
|
+
"title": "You need",
|
|
444
|
+
"kind": "requirements",
|
|
445
|
+
"markdown": "A **Business** plan or higher. The Stats API is a Business-plan feature, so a valid key on a cheaper plan is refused with a plan error rather than a key error. An API key also belongs to **one team**, so create it in the team that owns the site."
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
"id": "click-path",
|
|
449
|
+
"title": "Click path",
|
|
450
|
+
"kind": "steps",
|
|
451
|
+
"markdown": "1. Open [Plausible](https://plausible.io) and switch to the team that owns the site.\n2. **Settings → API keys → New API key**.\n3. Name it `sprid`, create it, and copy the key. Plausible shows it once.\n4. Save it to a file, such as `~/Downloads/plausible-key.txt`, so it never sits in your shell history.\n5. Your **site id** is the domain exactly as it was added to Plausible: no `https://`, no trailing slash. It is the last part of the site’s URL, `plausible.io/<site id>`."
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
"id": "then-run",
|
|
455
|
+
"title": "Then run",
|
|
456
|
+
"kind": "command",
|
|
457
|
+
"markdown": "```\nsprid connect plausible --key ~/Downloads/plausible-key.txt --site example.com\n```\n\nSelf-hosting? Add `--host https://analytics.example.com`. Add `--use` to make Plausible the source Sprid reports website traffic from."
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
"id": "only-one-provider-answers",
|
|
461
|
+
"title": "Only one provider answers",
|
|
462
|
+
"kind": "detail",
|
|
463
|
+
"markdown": "PostHog, Google Analytics and Plausible all count the same visits to the same site. Sprid reads **one** of them per app and never adds them together, because adding them would overstate your traffic by roughly the overlap, and the overlap is nearly everything.\n\nWith one connected, that one answers. With several, Sprid uses the one you chose with `--use` (PostHog by default, because it also answers the people and registration cards). The others stay connected and idle, and the traffic card says so."
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
"id": "how-to-check-it-worked",
|
|
467
|
+
"title": "How to check it worked",
|
|
468
|
+
"kind": "verify",
|
|
469
|
+
"markdown": "Run `sprid status`, then ask your agent: “Read this website’s visitors for the last 28 days through Sprid and say which provider answered.” A saved key confirms setup; only the live read confirms access."
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
"id": "if-it-fails",
|
|
473
|
+
"title": "If it fails",
|
|
474
|
+
"kind": "troubleshooting",
|
|
475
|
+
"markdown": "- **Business plan required:** the key is fine; the plan does not include the Stats API. Move to Business, or use PostHog or Google Analytics for this app instead. Nothing needs re-entering afterwards.\n- **Unauthorized:** the key belongs to a different team from the site. Create the key inside the team that owns the site.\n- **Site not found:** the site id is the domain as Plausible has it, with no scheme and no trailing slash.\n- **Rate limited:** Plausible allows 600 requests an hour per key. One reading is well inside that, so check whether another tool shares the key."
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
"id": "what-sprid-can-and-cannot-read-here",
|
|
479
|
+
"title": "What Sprid can and cannot read here",
|
|
480
|
+
"kind": "detail",
|
|
481
|
+
"markdown": "Visitors, pageviews, visits, bounce rate and visit duration, plus breakdowns by country, region, city, source, channel, campaign, term, page, entry page, exit page, hostname, browser, operating system and device.\n\nPlausible is **cookieless**: it has no person id, so a visitor is its own daily estimate rather than someone Sprid can follow between days. Window totals therefore come from Plausible directly and are never the daily numbers added up. Bounces and session time arrive as a rate and an average, and Sprid turns them back into counts, so they carry Plausible’s rounding. Days are the site’s own time zone, not UTC."
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
"id": "sources",
|
|
485
|
+
"title": "Sources",
|
|
486
|
+
"kind": "sources",
|
|
487
|
+
"markdown": "- [Stats API v2 `query`, and the Business-plan requirement](https://plausible.io/docs/stats-api)"
|
|
488
|
+
}
|
|
489
|
+
],
|
|
490
|
+
"markdown": "# Plausible\n\n**What Sprid does with this:** See how many people visit your website, where they came from and which pages they land on.\n\n## You need\n\nA **Business** plan or higher. The Stats API is a Business-plan feature, so a valid key on a cheaper plan is refused with a plan error rather than a key error. An API key also belongs to **one team**, so create it in the team that owns the site.\n\n## Click path\n\n1. Open [Plausible](https://plausible.io) and switch to the team that owns the site.\n2. **Settings → API keys → New API key**.\n3. Name it `sprid`, create it, and copy the key. Plausible shows it once.\n4. Save it to a file, such as `~/Downloads/plausible-key.txt`, so it never sits in your shell history.\n5. Your **site id** is the domain exactly as it was added to Plausible: no `https://`, no trailing slash. It is the last part of the site’s URL, `plausible.io/<site id>`.\n\n## Then run\n\n```\nsprid connect plausible --key ~/Downloads/plausible-key.txt --site example.com\n```\n\nSelf-hosting? Add `--host https://analytics.example.com`. Add `--use` to make Plausible the source Sprid reports website traffic from.\n\n## Only one provider answers\n\nPostHog, Google Analytics and Plausible all count the same visits to the same site. Sprid reads **one** of them per app and never adds them together, because adding them would overstate your traffic by roughly the overlap, and the overlap is nearly everything.\n\nWith one connected, that one answers. With several, Sprid uses the one you chose with `--use` (PostHog by default, because it also answers the people and registration cards). The others stay connected and idle, and the traffic card says so.\n\n## How to check it worked\n\nRun `sprid status`, then ask your agent: “Read this website’s visitors for the last 28 days through Sprid and say which provider answered.” A saved key confirms setup; only the live read confirms access.\n\n## If it fails\n\n- **Business plan required:** the key is fine; the plan does not include the Stats API. Move to Business, or use PostHog or Google Analytics for this app instead. Nothing needs re-entering afterwards.\n- **Unauthorized:** the key belongs to a different team from the site. Create the key inside the team that owns the site.\n- **Site not found:** the site id is the domain as Plausible has it, with no scheme and no trailing slash.\n- **Rate limited:** Plausible allows 600 requests an hour per key. One reading is well inside that, so check whether another tool shares the key.\n\n## What Sprid can and cannot read here\n\nVisitors, pageviews, visits, bounce rate and visit duration, plus breakdowns by country, region, city, source, channel, campaign, term, page, entry page, exit page, hostname, browser, operating system and device.\n\nPlausible is **cookieless**: it has no person id, so a visitor is its own daily estimate rather than someone Sprid can follow between days. Window totals therefore come from Plausible directly and are never the daily numbers added up. Bounces and session time arrive as a rate and an average, and Sprid turns them back into counts, so they carry Plausible’s rounding. Days are the site’s own time zone, not UTC.\n\n## Sources\n\n- [Stats API v2 `query`, and the Business-plan requirement](https://plausible.io/docs/stats-api)\n",
|
|
491
|
+
"revision": "732c29e4661f1403"
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
"id": "umami",
|
|
495
|
+
"title": "Umami",
|
|
496
|
+
"summary": "See how many people visit your website, where they came from and which pages they land on.",
|
|
497
|
+
"command": "sprid connect umami --key ~/Downloads/umami-key.txt --site 8f2a1c90-4d1e-4b7a-9f33-2c0b5e7a1d44",
|
|
498
|
+
"url": "https://sprid.studio/docs/connect/umami",
|
|
499
|
+
"sections": [
|
|
500
|
+
{
|
|
501
|
+
"id": "you-need",
|
|
502
|
+
"title": "You need",
|
|
503
|
+
"kind": "requirements",
|
|
504
|
+
"markdown": "An **API key**. On Umami Cloud that is any plan. Self-hosting, you need a version new enough to have **Settings → API keys**; an older instance cannot be connected at all, whatever key you paste."
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
"id": "click-path",
|
|
508
|
+
"title": "Click path",
|
|
509
|
+
"kind": "steps",
|
|
510
|
+
"markdown": "1. Open [Umami](https://cloud.umami.is), or your own instance.\n2. **Settings → API keys → Create API key**. Name it `sprid` and copy the key. Umami shows it once.\n3. Save it to a file, such as `~/Downloads/umami-key.txt`, so it never sits in your shell history.\n4. **Settings → Websites →** the site **→ Details**. Copy the **Website ID**. It is a uuid such as `8f2a1c90-4d1e-4b7a-9f33-2c0b5e7a1d44`, **not** the domain."
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
"id": "then-run",
|
|
514
|
+
"title": "Then run",
|
|
515
|
+
"kind": "command",
|
|
516
|
+
"markdown": "```\nsprid connect umami --key ~/Downloads/umami-key.txt --site 8f2a1c90-4d1e-4b7a-9f33-2c0b5e7a1d44\n```\n\nSelf-hosting? Add `--host https://analytics.example.com`. Sprid appends the `/api` your instance serves under, so either form works. Add `--use` to make Umami the source Sprid reports website traffic from."
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
"id": "only-one-provider-answers",
|
|
520
|
+
"title": "Only one provider answers",
|
|
521
|
+
"kind": "detail",
|
|
522
|
+
"markdown": "PostHog, Google Analytics, Plausible and Umami all count the same visits to the same site. Sprid reads **one** of them per app and never adds them together, because adding them would overstate your traffic by roughly the overlap, and the overlap is nearly everything.\n\nWith one connected, that one answers. With several, Sprid uses the one you chose with `--use` (PostHog by default, because it also answers the people and registration cards). The others stay connected and idle, and the traffic card says so."
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
"id": "how-to-check-it-worked",
|
|
526
|
+
"title": "How to check it worked",
|
|
527
|
+
"kind": "verify",
|
|
528
|
+
"markdown": "Run `sprid status`, then ask your agent: “Read this website’s visitors for the last 28 days through Sprid and say which provider answered.” A saved key confirms setup; only the live read confirms access."
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
"id": "if-it-fails",
|
|
532
|
+
"title": "If it fails",
|
|
533
|
+
"kind": "troubleshooting",
|
|
534
|
+
"markdown": "- **Unauthorized:** the key cannot see this website, or your self-hosted Umami predates API keys. Check for the **Settings → API keys** screen; if there is none, upgrade first.\n- **Not found:** you probably saved the domain instead of the website id, or left `/api` off a self-hosted host. Sprid adds `/api` for you when you pass `--host`.\n- **Rate limited:** Umami Cloud is limiting requests. The next hourly read picks it up."
|
|
535
|
+
},
|
|
536
|
+
{
|
|
537
|
+
"id": "what-sprid-can-and-cannot-read-here",
|
|
538
|
+
"title": "What Sprid can and cannot read here",
|
|
539
|
+
"kind": "detail",
|
|
540
|
+
"markdown": "Visitors, pageviews, visits, bounces and total time, plus **every** breakdown this product draws: country, region, city, referrer, channel, campaign, term, page, entry page, exit page, hostname, browser, operating system and device. Umami is the only alternative provider that reports an exit page.\n\nUmami is **cookieless**, so window totals come from Umami directly and are never the daily numbers added up. The daily line counts **sessions** rather than distinct people, so it will not sum to the visitor total beside it."
|
|
541
|
+
}
|
|
542
|
+
],
|
|
543
|
+
"markdown": "# Umami\n\n**What Sprid does with this:** See how many people visit your website, where they came from and which pages they land on.\n\nUmami is the one to reach for if Plausible's Business plan is more than you want to pay for an API, or if you self-host.\n\n## You need\n\nAn **API key**. On Umami Cloud that is any plan. Self-hosting, you need a version new enough to have **Settings → API keys**; an older instance cannot be connected at all, whatever key you paste.\n\n## Click path\n\n1. Open [Umami](https://cloud.umami.is), or your own instance.\n2. **Settings → API keys → Create API key**. Name it `sprid` and copy the key. Umami shows it once.\n3. Save it to a file, such as `~/Downloads/umami-key.txt`, so it never sits in your shell history.\n4. **Settings → Websites →** the site **→ Details**. Copy the **Website ID**. It is a uuid such as `8f2a1c90-4d1e-4b7a-9f33-2c0b5e7a1d44`, **not** the domain.\n\n## Then run\n\n```\nsprid connect umami --key ~/Downloads/umami-key.txt --site 8f2a1c90-4d1e-4b7a-9f33-2c0b5e7a1d44\n```\n\nSelf-hosting? Add `--host https://analytics.example.com`. Sprid appends the `/api` your instance serves under, so either form works. Add `--use` to make Umami the source Sprid reports website traffic from.\n\n## Only one provider answers\n\nPostHog, Google Analytics, Plausible and Umami all count the same visits to the same site. Sprid reads **one** of them per app and never adds them together, because adding them would overstate your traffic by roughly the overlap, and the overlap is nearly everything.\n\nWith one connected, that one answers. With several, Sprid uses the one you chose with `--use` (PostHog by default, because it also answers the people and registration cards). The others stay connected and idle, and the traffic card says so.\n\n## How to check it worked\n\nRun `sprid status`, then ask your agent: “Read this website’s visitors for the last 28 days through Sprid and say which provider answered.” A saved key confirms setup; only the live read confirms access.\n\n## If it fails\n\n- **Unauthorized:** the key cannot see this website, or your self-hosted Umami predates API keys. Check for the **Settings → API keys** screen; if there is none, upgrade first.\n- **Not found:** you probably saved the domain instead of the website id, or left `/api` off a self-hosted host. Sprid adds `/api` for you when you pass `--host`.\n- **Rate limited:** Umami Cloud is limiting requests. The next hourly read picks it up.\n\n## What Sprid can and cannot read here\n\nVisitors, pageviews, visits, bounces and total time, plus **every** breakdown this product draws: country, region, city, referrer, channel, campaign, term, page, entry page, exit page, hostname, browser, operating system and device. Umami is the only alternative provider that reports an exit page.\n\nUmami is **cookieless**, so window totals come from Umami directly and are never the daily numbers added up. The daily line counts **sessions** rather than distinct people, so it will not sum to the visitor total beside it.\n",
|
|
544
|
+
"revision": "e3527f9e20e023de"
|
|
368
545
|
},
|
|
369
546
|
{
|
|
370
547
|
"id": "revenuecat",
|
|
@@ -1144,8 +1321,8 @@ export const CONTENT_GUIDES = [
|
|
|
1144
1321
|
"title": "Review marketing from connected evidence",
|
|
1145
1322
|
"summary": "Investigate acquisition, activation, retention and revenue through the services connected to Sprid, and say plainly what evidence is missing.",
|
|
1146
1323
|
"url": "https://sprid.studio/docs/marketing-review",
|
|
1147
|
-
"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- 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- 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. 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:** save it as an app-scoped `kind: \"note\"` event with the date, conclusion, evidence references, coverage and next hypothesis in `meta`. Other clients read it with `list_events
|
|
1148
|
-
"revision": "
|
|
1324
|
+
"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- 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- 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:** save it as an app-scoped `kind: \"note\"` event with the date, conclusion, evidence references, coverage and next hypothesis in `meta`. Other clients read it with `list_events`.\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",
|
|
1325
|
+
"revision": "c2e55d715091dd81"
|
|
1149
1326
|
},
|
|
1150
1327
|
{
|
|
1151
1328
|
"id": "traffic",
|
|
@@ -1162,5 +1339,29 @@ export const CONTENT_GUIDES = [
|
|
|
1162
1339
|
"url": "https://sprid.studio/docs/pinterest",
|
|
1163
1340
|
"markdown": "# Pinterest content and publishing\n\n**What this guide does:** Plan searchable Pins, approve a useful batch, let Sprid publish the selected Pins on schedule and judge the result by qualified traffic and activation.\n\nChecked against Pinterest’s guidance on 21 September 2026. Anything labelled a test is a starting hypothesis, not a claim about what will win for your account.\n\n## How Pinterest discovery works\n\nPinterest is visual search and recommendation. A Pin can show up in search, home feeds and related Pins long after it is published, mostly regardless of followers.\n\nRelevance comes from the Pin itself and how people interact with it: clear keywords, original imagery, link quality and board context. Pinterest publishes no weighting. Make the visual, title, description, board and landing page all answer the same “what is this about?”, in the words a person would search, without repeating a keyword unnaturally.\n\nBecause Pins keep getting found, compare them at equal ages and keep older Pins in the analysis.\n\n## Start with the destination\n\nPick the page and the action before making the visual:\n\n1. One useful guide, tool, feature page or store destination.\n2. The concrete question it answers.\n3. One next action that continues the same task, such as opening that feature.\n4. Campaign parameters added without removing existing ones, with a stable Pin or creative ID so visits trace back to the exact Pin.\n\nThe page must deliver the pictured promise immediately and work on a phone. A specific Pin that lands on a generic homepage breaks the promise. A store visit is not an install, and an outbound click is not activation: report each step separately.\n\n## Prepare the website\n\n- **Claim your website** in Pinterest to link the profile to Pins from that domain and get website analytics. A website can be claimed by only one Pinterest account.\n- **Rich Pins:** for articles, products or recipes, add Open Graph or Schema.org metadata. Pinterest syncs it from the page; it is not a Sprid export. Check how it appears before a batch, because editing a Pin by hand can override synced article or recipe details.\n- Make sure Pinterestbot can read the page, links work and it loads fast on mobile.\n\n## Choose topics and boards\n\nBuild the topic list from Pinterest Trends, Pinterest search suggestions, customer language and site queries that already work. Search Console is a useful seed, but Google demand doesn’t prove Pinterest demand.\n\nMix evergreen questions with relevant seasonal moments. Use focused boards whose name and description explain the topic: a Pin about apartment-viewing costs belongs on a board about buying an apartment, not a broad “Ideas” board. Only create a board when the topic can fill it.\n\n## Build the Pin\n\n| | Ratio | Ideal size | Notes |\n|---|---|---|---|\n| Image | 2:3 | 1000 × 1500 px | |\n| Video | 9:16 | 1080 × 1920 px | 4 seconds to 5 minutes, H.264 or H.265 |\n\nSprid publishes one image or one finished video per Pin. It never turns an Instagram carousel into a Pin, so make a dedicated asset and check its crop in the preview.\n\nAt thumbnail size the subject and benefit should still be clear: one focus, strong contrast, short readable overlay copy. Check the real phone preview. The Pin must stand alone; an opener that only makes sense after swiping is incomplete here.\n\nFormats worth testing:\n\n- an editorial cover for a specific guide\n- a reference card with a concrete checklist or answer\n- a real app demo showing one task\n- a coherent visual collection for design, travel or architecture\n- a short captioned video when motion explains the task better\n\nPinterest can label detected or declared AI imagery. Keep provenance, use accurate licensed assets and disclose where required. Never publish an image that misrepresents the linked place, product or result.\n\n## Write the metadata\n\n| Field | Limit | Notes |\n|---|---|---|\n| Title | 100 characters | Put the subject early; feeds may cut it. |\n| Description | 800 characters | Often hidden in feeds, but Pinterest reads it for relevance. |\n| Alt text | 500 characters | Describe what the image shows. Not a keyword field. |\n\nWrite a natural title and description naming the topic, audience or situation and what the destination provides. Choose a relevant board, add a working destination, and set the AI disclosure in Sprid when it applies. No stuffed keyword variants, unrelated trends or promises the page doesn’t keep.\n\nThe visual earns attention, the metadata gives context, the destination completes the task. Keep all three aligned.\n\n## Publish your first Pin\n\nPublishing and scheduling need Sprid’s Pinterest app to have Standard access. With Trial access you can connect, browse boards and prepare drafts, but Sprid refuses to publish or schedule. That access belongs to Sprid’s integration; you don’t apply for a developer app.\n\n1. Connect Pinterest, run `sprid status` and confirm the account.\n2. Create or open a post with exactly one image (ideally 2:3) or one finished video (ideally 9:16).\n3. Open **Publish**, select **Pinterest**, then pick a **Board** and optional section.\n4. Add the title, description, destination, alt text and any AI disclosure.\n5. Check the rendered creative and every public field. Publish now or pick a time.\n6. After delivery, open the Pin signed out or from another account and follow its link.\n\n**CLI:** `sprid pinterest boards --account <slug>` lists board IDs, or says there are none; create one with `sprid pinterest create --account <slug> --name <name> [--privacy public|secret]`. `sprid pinterest set <postId> --board <id> --link <url>` saves the draft (optional metadata flags in `sprid docs cli`); review and schedule it in Sprid. `sprid pinterest metrics <publishId> --account <slug>` reads results.\n\n**MCP:** read `get_documentation` topic `pinterest-content`, pick the exact channel from `list_connections`, then `list_pinterest_boards`. If there are none, say so, and call `create_pinterest_board` only with a name and privacy the user approved. Save `pinterestOptions` with `update_post`, preferring `aspectRatio: \"2:3\"` for a new image Pin. Show the user the rendered Pin or dry-run batch, then call `schedule_post` or commit `schedule_batch` only for the Pins they chose. `get_pinterest_metrics` reads results.\n\n## Choose a cadence to test\n\nStart with **2 original Pins a day** in separate slots. With a broad catalog of useful destinations and genuinely distinct assets, test **5 a day** against that. These are Sprid’s proposed tests, not Pinterest limits or proven optimums. A small destination library needs more useful content, not cosmetic duplicates in the queue.\n\nRotate destinations and topics. Start with two distinct treatments per destination, on different days. No exact spacing or best hour is established.\n\nWhat the evidence says: Pinterest recommends original content at least weekly. [Sarah Hanford](https://www.sarahhanford.com/blog/pinterest-growth-organically-60-days) went from three Pins a day to five alongside keyword research and a new website, so frequency isn’t isolated as the cause. [Tailwind’s benchmark](https://www.tailwindapp.com/pinterest-marketing/research/2025-benchmark-study-part-1) of about 1.2 million organic Pins (2024 data) found results concentrated in a small share of Pins; its English-speaking, observational sample can’t set a universal cadence.\n\n## Review and schedule the batch\n\nFor a two-a-day test, choose **Daily** and two times in the batch scheduler, for example **09:00 and 17:00** (convenient, not researched best times). Check the account’s timezone and the actual dates before confirming; they may differ from your device. Keep a cadence the user already chose unless they ask to change it.\n\nOrder Pins so destinations alternate, and check the calendar preview for busy days, gaps and unplaced Pins. Preparing a large batch at once is fine; publication spreads across the schedule.\n\n**MCP:** `schedule_batch` with `cadence: \"daily\"`, `times: [\"09:00\", \"17:00\"]`, the account/channel and the selected post IDs. Run `dryRun: true` first and check `placements`, `unplaced` and `timezone`. Busy days are skipped by default, so read the returned plan instead of promising an end date. Commit only the reviewed Pins and report what was booked. CLI users read this guide with `sprid docs pinterest-content` and the queue with `sprid queue`; place batches in the scheduling screen or over MCP.\n\nBefore approving, check every Pin’s creative, metadata, destination, board and time, plus factual claims, rights and disclosure. Pinterest’s developer guidelines require the user to choose each Pin that publishes: Sprid shows the concrete Pins and schedule and records the selection. Those Pins then publish on time without asking again.\n\n- Moving only the time keeps the reviewed Pin. Changing content, board or link means reviewing and rescheduling it; changed drafts can be blocked at publish until reviewed.\n- After an unclear publish failure, check Pinterest before retrying; the Pin may already exist.\n- After the first Pin publishes, check it and its link from outside the connected account. A draft, preview or accepted schedule is not delivery.\n\n## Measure what happened\n\n- **Impressions:** times the Pin was on screen.\n- **Saves:** times people saved it to a board.\n- **Pin clicks:** opens of the Pin in close-up.\n- **Outbound clicks:** clicks through to a destination outside Pinterest.\n- **Video views:** at least 2 seconds with at least 50% of the video in view.\n\nFor app growth the path is **impression → outbound click → qualified visit → app action → activation**. Saves are a diagnostic, not the goal: a Pin with many saves and few outbound clicks may be a good reference on Pinterest and bring little acquisition.\n\nCompare Pins of similar age within the same topic, language and market. Judge creative by outbound-click rate and qualified visits, the whole path by activations per Pin and revenue where available. Pinterest’s outbound clicks and your own site sessions are measured differently; show both rather than forcing them to match.\n\nReview cohorts at 30, 60 and 90 days of Pin age. Report counts and coverage, including results with the top Pin removed, so one outlier can’t set the strategy. Keep a higher frequency while extra Pins keep adding qualified visits or activations; a lower click rate alone doesn’t cancel growth in total qualified traffic.\n\n- Visits but no activation: check page speed, message match and the next app action before making more Pins.\n- Low impressions: check topic relevance, board, metadata and visual clarity.\n- Negligible qualified traffic and activation across repeated relevant cohorts: stop scaling Pinterest for that app.\n\n## Improve the next batch\n\nChange one meaningful thing per comparison (visual treatment, question, format or destination angle) and repeat across comparable destinations. Organic distribution isn’t randomized, so call it a directional test, not an A/B test.\n\nMake fresh, useful treatments, not cosmetic duplicates. [Tailwind’s freshness study](https://www.tailwindapp.com/pinterest-marketing/research/2025-benchmark-study-part-three) found new images for an existing URL kept distribution better than reusing the same image and URL (an association, not a guaranteed lift). Pinterest advises against re-uploading the same Pin, and repetitive or irrelevant commercial content can be treated as spam. Space treatments of the same destination apart and keep every Pin on a relevant board.\n\nFor a worked example, [Elaine Timms’s recipe-client case](https://elainetimms.com/evergreen-growth-how-food-creator-pins-for-profit/) combines search-specific overlays, creative variants and email opt-ins. Its growth can’t be credited to cadence alone.\n\n## Sources\n\n- [How Pinterest discovery works](https://create.pinterest.com/blog/how-to-increase-discoverability-seo-pinterest/)\n- [Growing through search, boards and keywords](https://create.pinterest.com/blog/best-ways-to-grow-on-pinterest/)\n- [Pin specifications](https://help.pinterest.com/en/article/review-pin-specs)\n- [Pin performance and distribution](https://help.pinterest.com/en/business/article/pin-performance-and-distribution)\n- [Pinterest Analytics definitions](https://help.pinterest.com/en/business/article/pinterest-analytics)\n- [Pinterest developer guidelines](https://policy.pinterest.com/en/developer-guidelines)\n- [Pinterest API schema](https://github.com/pinterest/api-description/blob/main/v5/openapi.json)\n- [Pinterest community guidelines](https://policy.pinterest.com/en/community-guidelines)\n- [Pinterest labels for AI-generated or modified images](https://help.pinterest.com/en/article/gen-ai-labels)\n- [Claim your website](https://help.pinterest.com/en/business/article/claim-your-website)\n- [Rich Pins](https://help.pinterest.com/en-gb/business/article/rich-pins)\n",
|
|
1164
1341
|
"revision": "563dbb5a9c4b3053"
|
|
1342
|
+
},
|
|
1343
|
+
{
|
|
1344
|
+
"id": "distribution",
|
|
1345
|
+
"title": "Why a post travels",
|
|
1346
|
+
"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.",
|
|
1347
|
+
"url": "https://sprid.studio/docs/distribution",
|
|
1348
|
+
"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.\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 is written\nwith its reason attached rather than as a commandment.\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 is fatal rather than merely costly.\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## 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, not an experience | 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 the send, not the like.** 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\nA post that breaks out is the beginning of the work, not the end.\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 is data. Kill it rather than nursing it.\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",
|
|
1349
|
+
"revision": "6799e74c42287dc5"
|
|
1350
|
+
},
|
|
1351
|
+
{
|
|
1352
|
+
"id": "reel-first-seconds",
|
|
1353
|
+
"title": "The first seconds of a reel",
|
|
1354
|
+
"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.",
|
|
1355
|
+
"url": "https://sprid.studio/docs/reel-first-seconds",
|
|
1356
|
+
"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## 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",
|
|
1357
|
+
"revision": "2997f4c1f66e439a"
|
|
1358
|
+
},
|
|
1359
|
+
{
|
|
1360
|
+
"id": "store-listing",
|
|
1361
|
+
"title": "Write a store listing people can find",
|
|
1362
|
+
"summary": "Explains what each field of an App Store and Google Play listing is actually for, which characters are wasted, and what to change first. Read it before editing a listing, and before accepting any keyword suggestion.",
|
|
1363
|
+
"url": "https://sprid.studio/docs/store-listing",
|
|
1364
|
+
"markdown": "# Write a store listing people can find\n\n**What this guide does:** Explains what each field of an App Store and Google\nPlay listing is actually for, which characters are wasted, and what to change\nfirst. Read it before editing a listing, and before accepting any keyword\nsuggestion.\n\nA listing is two jobs in one form. Some fields decide whether you appear in a\nsearch at all; others decide whether the person who found you taps Get. They\nare not the same words, and writing all of them for one job is the most common\nway a listing quietly costs downloads.\n\n## The fields, and what each one does\n\n**App Store name, 30 characters. Indexed.** Your brand, and - when the evidence\nsupports it - one term describing the category. It is the heaviest indexed\nfield, so a name of only a coined brand word spends the store's strongest signal\non a word nobody searches. Against that: an established name is an asset you\nalready own. Quote the current name and the alternatives side by side and decide\nin the open, rather than letting a keyword tool pick.\n\n**App Store subtitle, 30 characters. Indexed.** The benefit, the use case, or the\none thing that separates you from the app above you in the results. It is read\nby a person mid-scroll and indexed by the store, which is why it is the hardest\n30 characters in the listing.\n\n**App Store keyword field, 100 characters. Indexed, invisible.** Comma-separated,\n**no spaces after the commas** - a space costs a character and buys nothing. The\nrules that reclaim the most room:\n\n- **No word that already appears in your name or subtitle.** Those are indexed\n already, and repeating them is the single biggest waste of the 100.\n- **No plural beside its own singular.** The store handles that.\n- No category names the store already knows you by, and no competitor brands.\n- Single words, not phrases: the store builds combinations across your terms, so\n `budget,tracker,expense` covers more searches than `budget tracker`.\n\n**Google Play has no keyword field, and that changes the work.** Play indexes the\ntitle (30), the short description (80) and the full description (4000). So on\nPlay the searchable words have to live inside sentences a human also reads -\nnaturally, a few times, not stuffed. The same app therefore needs two different\npieces of writing, and a listing that pastes the Apple keyword string into a Play\ndescription reads like spam to both the algorithm and the reader.\n\n**The first line of the description survives the fold** (around 170 characters on\niOS before \"more\"). Almost nobody opens the rest - but the store's reviewers do,\nso every claim in it has to be true.\n\n## What to change first\n\nIn order, because this is the order of what it costs to be wrong:\n\n1. **The subtitle**, if it is a slogan. A slogan is indexed and converts nothing.\n2. **The keyword field**, if it repeats the name, carries plurals or has spaces\n after commas. This is pure reclaimed space and needs no new idea.\n3. **The first two lines of the description**, if they open with the company\n rather than with what the person came for.\n4. **The name**, last and rarely. It is the one change that costs you the\n recognition you have already built, and it resets any word-of-mouth search.\n\n## Locales are separate listings, not translations\n\nChoose the locales your actual markets use, and write each one's keyword field\nagainst that language's searches rather than translating the English terms. The\nuseful words are frequently not the translated ones: people search the phrase\nthey say out loud, and in some languages that is a compound noun with no English\nshape at all. Keep what already works in a locale - an existing keyword that\nearns installs is evidence, and replacing it with a tidier translation is how a\nrewrite loses ground.\n\nDo not assume one English locale covers every English-speaking country. Check\nthe store's current localization guidance before making any claim about which\nlocale is indexed where; it changes, and a confident wrong answer here costs a\nwhole market's discoverability.\n\n## When the store will and will not accept a change\n\nKeywords and the description are attached to a **version** on App Store Connect.\nThey push only when an editable version exists - one in \"Prepare for Submission\" -\nand the name and subtitle additionally need an editable app information record.\nSo a listing change is not a live edit: it is a change that ships with your next\nrelease, and a dry run that validates your text locally can still be refused by\nthe store minutes later for this reason alone.\n\nSprid's listing tooling prints the exact fields and values it would send before\nit sends anything, and pushes only on an explicit execute. Read the diff. A\nlisting is one of the few things in marketing where a bad change is visible to\nevery future visitor and takes a release to undo.\n\n## How to tell whether it worked\n\nImpressions, not installs, are the field that moves when a listing's indexed\nwords get better; the conversion rate is what moves when the subtitle and first\nscreenshot get better. Separating those two is the whole reason to change one at\na time.\n\nGive it a full release cycle plus two weeks before reading the result, and\ncompare against the same weekday span, not against the launch spike. If the app\nalso runs paid installs, exclude that traffic first or you will read the ad\nbudget as a listing improvement.\n",
|
|
1365
|
+
"revision": "404de4aaddcf2979"
|
|
1165
1366
|
}
|
|
1166
1367
|
];
|
package/src/docs/queries.mjs
CHANGED
|
@@ -63,7 +63,7 @@ op('lemonsqueezy', 'subscription-invoices', 'Read initial, renewal and update in
|
|
|
63
63
|
op('paddle', 'transactions', 'Read billed transactions by time, status, customer or subscription.', { ...dates, status: array(choice('Status.', ['draft', 'ready', 'billed', 'paid', 'completed', 'canceled', 'past_due']), 7), customer_id: id('Paddle customer ID.'), subscription_id: id('Paddle subscription ID.'), limit, cursor }, [], { start: '2026-08-01', end: '2026-09-01', status: ['completed'], limit: 100 });
|
|
64
64
|
op('paddle', 'subscriptions', 'Read subscription lifecycles by status or price; optional creation dates filter each returned page locally.', { ...dates, status: array(choice('Status.', ['active', 'canceled', 'past_due', 'paused', 'trialing']), 5), customer_id: id('Paddle customer ID.'), price_id: id('Paddle price ID.'), limit, cursor }, [], { status: ['trialing'], limit: 100 });
|
|
65
65
|
for (const source of Object.keys(QUERY_SOURCES).filter(s => QUERY_SOURCES[s].stored)) {
|
|
66
|
-
op(source, 'posts', 'Filter stored publishing outcomes with the latest metric snapshot before the exclusive end.', { ...dates, status: choice('Publishing status.', ['published', 'failed', 'missed', 'scheduled', 'pending', 'rendering', 'publishing', 'awaiting_runner']),
|
|
66
|
+
op(source, 'posts', 'Filter stored publishing outcomes with the latest metric snapshot before the exclusive end.', { ...dates, status: choice('Publishing status.', ['published', 'failed', 'missed', 'scheduled', 'pending', 'rendering', 'publishing', 'awaiting_runner']), contentType: choice('Post type.', ['carousel', 'reel']), limit, offset: integer('Zero-based offset.', 100000, 0) }, ['start', 'end'], { start: '2026-08-01', end: '2026-09-01', status: 'published', limit: 100 });
|
|
67
67
|
op(source, 'comments', 'Read feedback already collected into Sprid’s Inbox, with optional post and reply filters.', { ...dates, postId: integer('Sprid post ID.', 1000000000), replied: { type: 'boolean', description: 'Filter comments by reply state.' }, limit, offset: integer('Zero-based offset.', 100000, 0) }, ['start', 'end'], { start: '2026-08-01', end: '2026-09-01', replied: false, limit: 100 });
|
|
68
68
|
}
|
|
69
69
|
|
package/src/post/cli.mjs
CHANGED
|
@@ -179,7 +179,7 @@ const LANE_KEYS = new Set([
|
|
|
179
179
|
// opening card is still arriving there says so. See `coverFrame`.
|
|
180
180
|
"coverAtMs",
|
|
181
181
|
// composed lanes: what Sprid is asked to render, and what may not be said
|
|
182
|
-
"
|
|
182
|
+
"template", "contentType", "neverSay", "sourcesRequired", "platforms",
|
|
183
183
|
]);
|
|
184
184
|
|
|
185
185
|
/**
|
|
@@ -515,9 +515,7 @@ export default {
|
|
|
515
515
|
carousels: {
|
|
516
516
|
kind: "composed",
|
|
517
517
|
spec: "social/specs/<slug>.json",
|
|
518
|
-
// Optional: the
|
|
519
|
-
// every card renders through.
|
|
520
|
-
// format: <format id from this account>,
|
|
518
|
+
// Optional: the template every card renders through.
|
|
521
519
|
// template: <template id from this account>,
|
|
522
520
|
expect: { slides: [5, 9], words: [0, 30] },
|
|
523
521
|
hashtags: [3, 6],
|
|
@@ -1462,15 +1460,12 @@ async function cmdDraft(cfg, argv) {
|
|
|
1462
1460
|
// Public titles come from approved copy; identifiers stay in internalName.
|
|
1463
1461
|
title: m.post?.title ?? postTitle(caption, m.slug),
|
|
1464
1462
|
internalName: m.post?.internalName ?? m.slug,
|
|
1465
|
-
// A composed lane may name the Sprid format its posts are made from,
|
|
1466
|
-
// and creating with it is what scaffolds the right slide roles.
|
|
1467
|
-
...(cfg.lanes[m.lane]?.format ? { formatId: cfg.lanes[m.lane].format } : {}),
|
|
1468
1463
|
}));
|
|
1469
1464
|
const carousel = (m.kind ?? "reel") === "carousel";
|
|
1470
1465
|
const composed = isComposed(m);
|
|
1471
1466
|
|
|
1472
1467
|
// **A new post already has slides, and `POST /posts` does not return
|
|
1473
|
-
// them.** It scaffolds one row
|
|
1468
|
+
// them.** It scaffolds one row and
|
|
1474
1469
|
// answers with the post alone, so `post.slides?.[0]` was always undefined
|
|
1475
1470
|
// and every draft this package made carried an EMPTY leading slide with
|
|
1476
1471
|
// the real media behind it - a blank first frame on a reel, a blank first
|