@sprid/cli 0.1.6 → 0.1.8

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.
@@ -29,12 +29,21 @@ export const GUIDE_GROUPS = [
29
29
  "slugs": [
30
30
  "instagram",
31
31
  "tiktok",
32
+ "tiktok-comments",
32
33
  "youtube",
33
34
  "linkedin",
34
35
  "facebook",
35
36
  "x",
36
37
  "pinterest"
37
38
  ]
39
+ },
40
+ {
41
+ "title": "Ads",
42
+ "slugs": [
43
+ "meta-ads",
44
+ "google-ads",
45
+ "tiktok-ads"
46
+ ]
38
47
  }
39
48
  ];
40
49
  export const GUIDE_ALIASES = {
@@ -43,7 +52,13 @@ export const GUIDE_ALIASES = {
43
52
  "gsc": "search-console",
44
53
  "ga4": "google-analytics",
45
54
  "ga": "google-analytics",
46
- "google-analytics-4": "google-analytics"
55
+ "google-analytics-4": "google-analytics",
56
+ "meta_ads": "meta-ads",
57
+ "meta": "meta-ads",
58
+ "google_ads": "google-ads",
59
+ "tiktok_ads": "tiktok-ads",
60
+ "tiktok_accounts": "tiktok-comments",
61
+ "tiktok-accounts": "tiktok-comments"
47
62
  };
48
63
  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";
49
64
  export const GUIDES = [
@@ -1021,6 +1036,59 @@ export const GUIDES = [
1021
1036
  "markdown": "# TikTok\n\n**What Sprid does with this:** Publish approved posts to TikTok, or send drafts to finish in the TikTok app.\n\n## You need\n\nA TikTok account you can sign in to, set to public if you want public posts.\n\n## Then run\n\n```\nsprid connect tiktok --account <slug>\n```\n\nReplace `<slug>` with your Sprid account slug.\n\n## Click path (the connect)\n\n1. Sign in to TikTok in the browser that opens, or scan the QR code with your phone.\n2. Review the requested access and click **Authorize**.\n3. Back in Sprid, check the connected handle.\n\n## How to check it worked\n\nRun `sprid status` and check the handle. To test publishing, post a reviewed post as **Only me** and find it on your profile.\n\n## Before publishing\n\nTikTok requires you to choose these on every post, with nothing pre-filled:\n\n- Audience (privacy) and whether to allow comments. Videos also offer Duet and Stitch where your account allows them.\n- The commercial-content disclosure, if the post promotes your business or another brand. Branded content cannot use **Only me**.\n- The music and branded-content terms shown above the publish button.\n\nIn a batch, your choices apply to every post shown. Rescheduling a booking keeps its original choices.\n\n## If it fails\n\n- **A public post arrives as private:** use **Send as a draft** and finish in TikTok, and tell [Sprid support](mailto:hello@sprid.studio) about the public-posting restriction.\n- **Publishing limit reached:** retry later, or send as a draft.\n- **“Checking your TikTok account…” stays on screen:** select **Retry**, then reconnect if it still fails.\n- **Publish stays disabled:** complete the audience and disclosure choices and resolve any message beside the button.\n\n## Investigate with this connection\n\nOperations `posts` and `comments`, read from Sprid’s stored publishes, metric snapshots and inbox (no live platform read; missing metrics are unmeasured, not zero).\n\n```sh\nsprid marketing-review capabilities --app <slug> --source tiktok --json\n```\n\nSee [connected queries](https://sprid.studio/docs/queries).\n\n## Sources\n\n- [Content Posting API](https://developers.tiktok.com/doc/content-posting-api-get-started)\n- [Direct Post reference](https://developers.tiktok.com/doc/content-posting-api-reference-direct-post)\n- [Query creator info](https://developers.tiktok.com/doc/content-posting-api-reference-query-creator-info)\n- [Content Sharing Guidelines](https://developers.tiktok.com/doc/content-sharing-guidelines)\n- [Login Kit for web](https://developers.tiktok.com/doc/login-kit-web)\n",
1022
1037
  "revision": "710f233b9a644bb3"
1023
1038
  },
1039
+ {
1040
+ "id": "tiktok-comments",
1041
+ "title": "TikTok comments",
1042
+ "summary": "Read the comments on your TikTok posts into the Inbox next to Instagram and YouTube, and send the replies you approve. With TikTok Ads connected too, comments on a post you promote are read from the ad account and marked as an ad; replying to them still needs this connection.",
1043
+ "command": "sprid connect tiktok-comments --account <slug>",
1044
+ "url": "https://sprid.studio/docs/connect/tiktok-comments",
1045
+ "sections": [
1046
+ {
1047
+ "id": "you-need",
1048
+ "title": "You need",
1049
+ "kind": "requirements",
1050
+ "markdown": "- The TikTok account that publishes your posts, and its login. Sign in as that account, not as a TikTok for Business or ad account user.\n- TikTok publishing already connected in Sprid, so Sprid knows which posts are yours. Sprid only reads comments on posts it published.\n\nThis is a separate connection from TikTok publishing and from TikTok Ads. The publishing connection cannot read comments. TikTok only allows comment access through its business tools."
1051
+ },
1052
+ {
1053
+ "id": "then-run",
1054
+ "title": "Then run",
1055
+ "kind": "command",
1056
+ "markdown": "```sh\nsprid connect tiktok-comments --account <slug>\n```\n\nReplace `<slug>` with your Sprid account slug. You can also use **Settings → Accounts → [account] → TikTok comments** in Sprid."
1057
+ },
1058
+ {
1059
+ "id": "click-path-the-connect",
1060
+ "title": "Click path (the connect)",
1061
+ "kind": "steps",
1062
+ "markdown": "1. Sign in to TikTok with the account that publishes the posts.\n2. Review the requested access (your profile and your comments) and click **Authorize**.\n3. Back in Sprid, check the connected handle.\n\nWith several brands, connect each Sprid account separately, signed in as that brand's TikTok account."
1063
+ },
1064
+ {
1065
+ "id": "how-to-check-it-worked",
1066
+ "title": "How to check it worked",
1067
+ "kind": "verify",
1068
+ "markdown": "Run `sprid status` and check that TikTok comments shows the right handle. New comments appear in the Inbox within about an hour. A comment on a promoted post is marked as an ad."
1069
+ },
1070
+ {
1071
+ "id": "what-sprid-does-and-does-not-do",
1072
+ "title": "What Sprid does and does not do",
1073
+ "kind": "detail",
1074
+ "markdown": "- It reads comments hourly for posts from the last 3 days, and every 6 hours for posts from the last 30.\n- A reply goes out only when you send it from the Inbox. Sprid never replies on its own.\n- Sprid never hides or deletes a TikTok comment. Filing a comment as done only changes the Inbox.\n- It sees at most three replies under each comment, the limit TikTok sets."
1075
+ },
1076
+ {
1077
+ "id": "if-it-fails",
1078
+ "title": "If it fails",
1079
+ "kind": "troubleshooting",
1080
+ "markdown": "- **“TikTok comments are not switched on in Sprid yet”:** Sprid has not enabled TikTok's business tools on this server. Contact [Sprid support](mailto:hello@sprid.studio).\n- **The Inbox shows no TikTok comments:** check that the handle under TikTok comments is the account that published the posts. Posts sent to your TikTok drafts, or still in TikTok's review, have no public post yet and are skipped until they do.\n- **“Sprid answers TikTok comments through the account that posted them”:** a comment on a promoted post can only be answered with TikTok comments connected. Connect it, or reply in the TikTok app.\n- **TikTok comments shows “Reconnect”:** TikTok's access lasts about a year and then needs one new sign-in. Run the connect command again."
1081
+ },
1082
+ {
1083
+ "id": "sources",
1084
+ "title": "Sources",
1085
+ "kind": "sources",
1086
+ "markdown": "- [TikTok API for Business: TikTok account authorization](https://business-api.tiktok.com/portal/docs?id=1738083939371009)\n- [TikTok API for Business: get comments](https://business-api.tiktok.com/portal/docs?id=1760232109619202)\n- [TikTok API for Business: reply to a comment](https://business-api.tiktok.com/portal/docs?id=1762228448779266)"
1087
+ }
1088
+ ],
1089
+ "markdown": "# TikTok comments\n\n**What Sprid does with this:** Read the comments on your TikTok posts into the Inbox next to Instagram and YouTube, and send the replies you approve. With TikTok Ads connected too, comments on a post you promote are read from the ad account and marked as an ad; replying to them still needs this connection.\n\n## You need\n\n- The TikTok account that publishes your posts, and its login. Sign in as that account, not as a TikTok for Business or ad account user.\n- TikTok publishing already connected in Sprid, so Sprid knows which posts are yours. Sprid only reads comments on posts it published.\n\nThis is a separate connection from TikTok publishing and from TikTok Ads. The publishing connection cannot read comments. TikTok only allows comment access through its business tools.\n\n## Then run\n\n```sh\nsprid connect tiktok-comments --account <slug>\n```\n\nReplace `<slug>` with your Sprid account slug. You can also use **Settings → Accounts → [account] → TikTok comments** in Sprid.\n\n## Click path (the connect)\n\n1. Sign in to TikTok with the account that publishes the posts.\n2. Review the requested access (your profile and your comments) and click **Authorize**.\n3. Back in Sprid, check the connected handle.\n\nWith several brands, connect each Sprid account separately, signed in as that brand's TikTok account.\n\n## How to check it worked\n\nRun `sprid status` and check that TikTok comments shows the right handle. New comments appear in the Inbox within about an hour. A comment on a promoted post is marked as an ad.\n\n## What Sprid does and does not do\n\n- It reads comments hourly for posts from the last 3 days, and every 6 hours for posts from the last 30.\n- A reply goes out only when you send it from the Inbox. Sprid never replies on its own.\n- Sprid never hides or deletes a TikTok comment. Filing a comment as done only changes the Inbox.\n- It sees at most three replies under each comment, the limit TikTok sets.\n\n## If it fails\n\n- **“TikTok comments are not switched on in Sprid yet”:** Sprid has not enabled TikTok's business tools on this server. Contact [Sprid support](mailto:hello@sprid.studio).\n- **The Inbox shows no TikTok comments:** check that the handle under TikTok comments is the account that published the posts. Posts sent to your TikTok drafts, or still in TikTok's review, have no public post yet and are skipped until they do.\n- **“Sprid answers TikTok comments through the account that posted them”:** a comment on a promoted post can only be answered with TikTok comments connected. Connect it, or reply in the TikTok app.\n- **TikTok comments shows “Reconnect”:** TikTok's access lasts about a year and then needs one new sign-in. Run the connect command again.\n\n## Sources\n\n- [TikTok API for Business: TikTok account authorization](https://business-api.tiktok.com/portal/docs?id=1738083939371009)\n- [TikTok API for Business: get comments](https://business-api.tiktok.com/portal/docs?id=1760232109619202)\n- [TikTok API for Business: reply to a comment](https://business-api.tiktok.com/portal/docs?id=1762228448779266)\n",
1090
+ "revision": "c3e5f9ad711a1fbe"
1091
+ },
1024
1092
  {
1025
1093
  "id": "youtube",
1026
1094
  "title": "YouTube",
@@ -1274,7 +1342,7 @@ export const GUIDES = [
1274
1342
  "id": "trial-and-standard-access",
1275
1343
  "title": "Trial and Standard access",
1276
1344
  "kind": "detail",
1277
- "markdown": "Publishing and scheduling need **Sprid’s** Pinterest app to have Standard access. With Trial access you can connect and browse boards, but Sprid refuses to publish or schedule. This is Sprid’s approval, not something you apply for, and reconnecting does not change it. Keep preparing drafts, but do not treat them as booked."
1345
+ "markdown": "Public Pins need **Sprid’s** Pinterest app to have Standard access. This is Sprid’s approval, not something you apply for. Until then Sprid runs Pinterest in test mode: you can connect, pick or create boards and publish image Pins, but every Pin and board it creates is visible only to you, on your own profile. Video Pins are not available in test mode. When Standard access arrives, reconnect once: a test-mode connection cannot publish public Pins."
1278
1346
  },
1279
1347
  {
1280
1348
  "id": "prepare-the-account",
@@ -1286,7 +1354,7 @@ export const GUIDES = [
1286
1354
  "id": "if-it-fails",
1287
1355
  "title": "If it fails",
1288
1356
  "kind": "troubleshooting",
1289
- "markdown": "- **“Pinterest is unavailable until API credentials and an access tier are configured”:** this Sprid deployment is not ready for Pinterest. Follow Sprid’s status or contact support; you do not supply credentials.\n- **Wrong account connected:** disconnect it in Sprid, sign out of Pinterest in that browser, then reconnect and check the identity before authorizing.\n- **“No Pinterest boards yet”:** create one from the board picker. If Sprid asks for board permission, reconnect once; older connections lack the board write scope.\n- **Existing boards missing:** reconnect once, then contact [Sprid support](mailto:hello@sprid.studio) with the account name and error.\n- **Access denied:** confirm the Pinterest account is active and you finished the consent screen. Retry once, then contact Sprid support.\n- **Publishing or scheduling requires Standard access:** see Trial and Standard access above. Boards stay selectable; only Sprid can change the tier.\n- **Analytics unavailable:** use a business account and check the Pin is public. Unavailable data is unavailable, not zero."
1357
+ "markdown": "- **“Pinterest is unavailable until API credentials and an access tier are configured”:** this Sprid deployment is not ready for Pinterest. Follow Sprid’s status or contact support; you do not supply credentials.\n- **Wrong account connected:** disconnect it in Sprid, sign out of Pinterest in that browser, then reconnect and check the identity before authorizing.\n- **“No Pinterest boards yet”:** create one from the board picker. If Sprid asks for board permission, reconnect once; older connections lack the board write scope.\n- **Existing boards missing:** reconnect once, then contact [Sprid support](mailto:hello@sprid.studio) with the account name and error.\n- **Access denied:** confirm the Pinterest account is active and you finished the consent screen. Retry once, then contact Sprid support.\n- **“Reconnect Pinterest to create test Pins” or “…to publish public Pins”:** the connection was made under the other access tier, and its token only works there. Reconnect once. See Trial and Standard access above.\n- **Boards missing in test mode:** test mode sees only boards created in test mode. Create one from the board picker.\n- **Analytics unavailable:** use a business account and check the Pin is public. Unavailable data is unavailable, not zero."
1290
1358
  },
1291
1359
  {
1292
1360
  "id": "sources",
@@ -1295,8 +1363,179 @@ export const GUIDES = [
1295
1363
  "markdown": "- [Pinterest developer access tiers](https://developer.pinterest.com/docs/key-concepts/access-tiers/)\n- [Pinterest developer guidelines](https://policy.pinterest.com/en/developer-guidelines)\n- [Claim your website](https://help.pinterest.com/en/business/article/claim-your-website)\n- [Pinterest Analytics](https://help.pinterest.com/en/business/article/pinterest-analytics)"
1296
1364
  }
1297
1365
  ],
1298
- "markdown": "# Pinterest\n\n**What Sprid does with this:** Publish the Pins you select to the right boards, keep their destination links intact and read their results when analytics access is available.\n\n## You need\n\n- A Pinterest account. A free business account is recommended, because Pinterest Analytics requires one.\n- Access to the Sprid account that will own this channel.\n- A board topic in mind. Sprid can create the board during publishing.\n\nYou do **not** create a Pinterest developer app, request API access or copy a token. You only authorize your own Pinterest account.\n\n## Then run\n\n```sh\nsprid connect pinterest --account <slug>\n```\n\nReplace `<slug>` with your Sprid account slug, or use **Settings → Accounts → [account] → Pinterest → Continue with Pinterest** in Sprid.\n\n## Click path (the connect)\n\n1. Check the Pinterest identity in the browser that opens. Sign out first if it is the wrong account.\n2. Review the requested access and authorize Sprid.\n3. Back in Sprid, confirm the connected Pinterest name.\n4. Open a post, select **Publish → Pinterest** and pick the destination in the **Board** picker. If none fits, select **Create board**, name it and choose public or secret; Sprid selects it automatically.\n\nEach Sprid account needs its own connect, even when several Pinterest accounts are signed in to the same browser. Check the identity every time.\n\n## Check the connection\n\nRun `sprid status` and confirm Pinterest shows the intended account. Open a draft Pin, select **Publish → Pinterest** and confirm the intended board appears before approving any schedule.\n\nAfter the first Pin publishes, open Pinterest signed out or from another account and check the Pin, board, visual, title and destination. A successful API response does not prove public visibility.\n\n## Trial and Standard access\n\nPublishing and scheduling need **Sprid’s** Pinterest app to have Standard access. With Trial access you can connect and browse boards, but Sprid refuses to publish or schedule. This is Sprid’s approval, not something you apply for, and reconnecting does not change it. Keep preparing drafts, but do not treat them as booked.\n\n## Prepare the account\n\nCreate a few specific boards around topics people search for, such as “Small apartment viewing checklist” rather than “Inspiration”. Board names, descriptions and saved Pins give Pinterest context.\n\nClaim your website in Pinterest where possible: it links Pins from that site to the profile and expands website analytics. A website can be claimed by only one Pinterest account, so decide which market or brand owns it before connecting several.\n\nRead [Pinterest content and publishing](https://sprid.studio/docs/pinterest) before preparing the first batch.\n\n## If it fails\n\n- **“Pinterest is unavailable until API credentials and an access tier are configured”:** this Sprid deployment is not ready for Pinterest. Follow Sprid’s status or contact support; you do not supply credentials.\n- **Wrong account connected:** disconnect it in Sprid, sign out of Pinterest in that browser, then reconnect and check the identity before authorizing.\n- **“No Pinterest boards yet”:** create one from the board picker. If Sprid asks for board permission, reconnect once; older connections lack the board write scope.\n- **Existing boards missing:** reconnect once, then contact [Sprid support](mailto:hello@sprid.studio) with the account name and error.\n- **Access denied:** confirm the Pinterest account is active and you finished the consent screen. Retry once, then contact Sprid support.\n- **Publishing or scheduling requires Standard access:** see Trial and Standard access above. Boards stay selectable; only Sprid can change the tier.\n- **Analytics unavailable:** use a business account and check the Pin is public. Unavailable data is unavailable, not zero.\n\n## Sources\n\n- [Pinterest developer access tiers](https://developer.pinterest.com/docs/key-concepts/access-tiers/)\n- [Pinterest developer guidelines](https://policy.pinterest.com/en/developer-guidelines)\n- [Claim your website](https://help.pinterest.com/en/business/article/claim-your-website)\n- [Pinterest Analytics](https://help.pinterest.com/en/business/article/pinterest-analytics)\n",
1299
- "revision": "a81732fc9409f861"
1366
+ "markdown": "# Pinterest\n\n**What Sprid does with this:** Publish the Pins you select to the right boards, keep their destination links intact and read their results when analytics access is available.\n\n## You need\n\n- A Pinterest account. A free business account is recommended, because Pinterest Analytics requires one.\n- Access to the Sprid account that will own this channel.\n- A board topic in mind. Sprid can create the board during publishing.\n\nYou do **not** create a Pinterest developer app, request API access or copy a token. You only authorize your own Pinterest account.\n\n## Then run\n\n```sh\nsprid connect pinterest --account <slug>\n```\n\nReplace `<slug>` with your Sprid account slug, or use **Settings → Accounts → [account] → Pinterest → Continue with Pinterest** in Sprid.\n\n## Click path (the connect)\n\n1. Check the Pinterest identity in the browser that opens. Sign out first if it is the wrong account.\n2. Review the requested access and authorize Sprid.\n3. Back in Sprid, confirm the connected Pinterest name.\n4. Open a post, select **Publish → Pinterest** and pick the destination in the **Board** picker. If none fits, select **Create board**, name it and choose public or secret; Sprid selects it automatically.\n\nEach Sprid account needs its own connect, even when several Pinterest accounts are signed in to the same browser. Check the identity every time.\n\n## Check the connection\n\nRun `sprid status` and confirm Pinterest shows the intended account. Open a draft Pin, select **Publish → Pinterest** and confirm the intended board appears before approving any schedule.\n\nAfter the first Pin publishes, open Pinterest signed out or from another account and check the Pin, board, visual, title and destination. A successful API response does not prove public visibility.\n\n## Trial and Standard access\n\nPublic Pins need **Sprid’s** Pinterest app to have Standard access. This is Sprid’s approval, not something you apply for. Until then Sprid runs Pinterest in test mode: you can connect, pick or create boards and publish image Pins, but every Pin and board it creates is visible only to you, on your own profile. Video Pins are not available in test mode. When Standard access arrives, reconnect once: a test-mode connection cannot publish public Pins.\n\n## Prepare the account\n\nCreate a few specific boards around topics people search for, such as “Small apartment viewing checklist” rather than “Inspiration”. Board names, descriptions and saved Pins give Pinterest context.\n\nClaim your website in Pinterest where possible: it links Pins from that site to the profile and expands website analytics. A website can be claimed by only one Pinterest account, so decide which market or brand owns it before connecting several.\n\nRead [Pinterest content and publishing](https://sprid.studio/docs/pinterest) before preparing the first batch.\n\n## If it fails\n\n- **“Pinterest is unavailable until API credentials and an access tier are configured”:** this Sprid deployment is not ready for Pinterest. Follow Sprid’s status or contact support; you do not supply credentials.\n- **Wrong account connected:** disconnect it in Sprid, sign out of Pinterest in that browser, then reconnect and check the identity before authorizing.\n- **“No Pinterest boards yet”:** create one from the board picker. If Sprid asks for board permission, reconnect once; older connections lack the board write scope.\n- **Existing boards missing:** reconnect once, then contact [Sprid support](mailto:hello@sprid.studio) with the account name and error.\n- **Access denied:** confirm the Pinterest account is active and you finished the consent screen. Retry once, then contact Sprid support.\n- **“Reconnect Pinterest to create test Pins” or “…to publish public Pins”:** the connection was made under the other access tier, and its token only works there. Reconnect once. See Trial and Standard access above.\n- **Boards missing in test mode:** test mode sees only boards created in test mode. Create one from the board picker.\n- **Analytics unavailable:** use a business account and check the Pin is public. Unavailable data is unavailable, not zero.\n\n## Sources\n\n- [Pinterest developer access tiers](https://developer.pinterest.com/docs/key-concepts/access-tiers/)\n- [Pinterest developer guidelines](https://policy.pinterest.com/en/developer-guidelines)\n- [Claim your website](https://help.pinterest.com/en/business/article/claim-your-website)\n- [Pinterest Analytics](https://help.pinterest.com/en/business/article/pinterest-analytics)\n",
1367
+ "revision": "8bf9325a0d261010"
1368
+ },
1369
+ {
1370
+ "id": "meta-ads",
1371
+ "title": "Meta Ads",
1372
+ "summary": "Prepare campaigns in your own Meta ad account, start them only when you confirm, and report what they spent and returned.",
1373
+ "command": "sprid connect meta-ads --account <slug>",
1374
+ "url": "https://sprid.studio/docs/connect/meta-ads",
1375
+ "sections": [
1376
+ {
1377
+ "id": "you-need",
1378
+ "title": "You need",
1379
+ "kind": "requirements",
1380
+ "markdown": "- A Facebook profile with two-factor authentication turned on. Meta will not let you add ad assets without it.\n- A **business portfolio** at [business.facebook.com](https://business.facebook.com). One per company is enough, even with several apps or brands. See **One portfolio or several** below.\n- A **Facebook Page** for the brand. Ads always run from a Page, including the ones shown on Instagram. To create one, see [Create your social accounts](https://sprid.studio/docs/connect/social-accounts).\n- An **ad account** with a payment method, one per brand.\n- Optional: a **dataset** (formerly Meta Pixel) if you want campaigns that optimise for signups or purchases.\n\nYou do **not** create a Meta developer app or copy a token. You only sign in with Facebook and pick the Page and ad account."
1381
+ },
1382
+ {
1383
+ "id": "set-up-meta-once-before-you-connect",
1384
+ "title": "Set up Meta (once, before you connect)",
1385
+ "kind": "steps",
1386
+ "markdown": "Skip any step you have already done.\n\n1. **Business portfolio.** Go to [business.facebook.com](https://business.facebook.com) and create a business portfolio under your company’s legal name.\n2. **Page.** In the portfolio, open **Settings → Accounts → Pages** and add the brand’s Page. Give yourself access that includes **Ads**.\n3. **Ad account.** Open **Settings → Accounts → Ad accounts → Add → Create a new ad account**. Pick the **currency** and **time zone** carefully: once the account has spent anything, neither can be changed. Choose the currency of the market you advertise in. Give yourself **Manage campaigns** or full control.\n4. **Payment method.** Open **Billing & payments** for that ad account and add a card. Sprid can connect an account without one, but no campaign can start until it has one.\n5. **Dataset (optional).** Open [Events Manager](https://business.facebook.com/events_manager2) → **Connect data sources → Web**, name it after your domain and copy its **dataset id**. Sprid only needs the id. The events themselves reach it from your site or app, through the Meta Pixel or the Conversions API, and without events a conversion campaign has nothing to optimise for."
1387
+ },
1388
+ {
1389
+ "id": "then-run",
1390
+ "title": "Then run",
1391
+ "kind": "command",
1392
+ "markdown": "```sh\nsprid connect meta-ads --account <slug>\n```\n\nReplace `<slug>` with your Sprid account slug. Add `--dataset <id>` if you created one in step 5. You can also use **Settings → Accounts → [account] → Meta Ads** in Sprid."
1393
+ },
1394
+ {
1395
+ "id": "click-path-the-connect",
1396
+ "title": "Click path (the connect)",
1397
+ "kind": "steps",
1398
+ "markdown": "1. Sign in with the Facebook profile that manages the Page and the ad account.\n2. Leave every requested permission switched on and click **Continue**.\n3. Back in Sprid, pick the **Page** your ads should appear from, then the **ad account**. Each list only appears when there is more than one to choose from.\n4. From the CLI, a profile with several Pages or ad accounts gets a message listing their ids. Run the command again with them:\n\n```sh\nsprid connect meta-ads --account <slug> --page <page id> --ad-account <ad account id> --dataset <dataset id>\n```\n\nWith several brands, connect each Sprid account separately and pick that brand’s own Page and ad account every time."
1399
+ },
1400
+ {
1401
+ "id": "check-the-connection",
1402
+ "title": "Check the connection",
1403
+ "kind": "verify",
1404
+ "markdown": "Run `sprid status` and check that Meta Ads shows the intended Page name, followed by “(Ads)”.\n\nA campaign Sprid prepares starts **paused**. Nothing is spent until you confirm the start."
1405
+ },
1406
+ {
1407
+ "id": "one-portfolio-or-several",
1408
+ "title": "One portfolio or several",
1409
+ "kind": "detail",
1410
+ "markdown": "Keep one business portfolio for your company and put one Page, one ad account and one dataset per brand inside it.\n\n- **A restriction on an ad account stays on that account.** The other brands keep running.\n- **Separate portfolios isolate less than they appear to.** Meta links portfolios run by the same people and paid with the same card, so a problem on one is often read as a problem on all of them. You also pay for the split: each portfolio has to be verified and set up separately, and a personal profile can only create a couple of them.\n- **What one portfolio risks:** if the portfolio itself is restricted, every brand in it stops at once. If one brand operates in a regulated category (housing, credit, employment, politics) and draws an enforcement action, move that brand to its own portfolio then.\n\nGive each ad account its own payment method where you can, and add a second admin to the portfolio so you are not locked out if your own profile has a problem."
1411
+ },
1412
+ {
1413
+ "id": "if-it-fails",
1414
+ "title": "If it fails",
1415
+ "kind": "troubleshooting",
1416
+ "markdown": "- **“No Facebook Pages found”:** the profile you signed in with has no Page access. Check the Page in the portfolio, give yourself access and reconnect.\n- **No ad account to choose, or the wrong ones:** only active ad accounts are listed. Check that the account is not disabled or closed in Ads Manager and that your profile is assigned to it.\n- **“Re-run the connect with --page / --ad-account”:** the profile manages several. Copy the right ids from the message and run the command again with them.\n- **“Already connected to another account”:** that ad account is connected to a different Sprid account. Check you picked the right one; move it only if you mean to.\n- **Campaign refuses to start with a conversion goal:** the connection has no dataset. Reconnect with `--dataset <id>`.\n- **Campaign refuses to start at all:** add a payment method to the ad account (step 4).\n- **“Invalid Scopes”, app unavailable or “URL blocked”:** contact [Sprid support](mailto:hello@sprid.studio). Only Sprid can fix these.\n- **Expiring connection in `sprid status`:** Meta’s sign-in lasts about 60 days. Run the connect again."
1417
+ },
1418
+ {
1419
+ "id": "sources",
1420
+ "title": "Sources",
1421
+ "kind": "sources",
1422
+ "markdown": "- [Create a business portfolio](https://www.facebook.com/business/help/1710077379203657)\n- [Add an ad account to your business portfolio](https://www.facebook.com/business/help/915885887059947)\n- [Ad account limits](https://www.facebook.com/business/help/1026272311098874)\n- [Add a payment method to an ad account](https://www.facebook.com/business/help/132073386867900)\n- [Assign business assets to people](https://www.facebook.com/business/help/325571851329683)\n- [Create a dataset in Events Manager](https://www.facebook.com/business/help/5818684664831465)\n- [About the Conversions API](https://www.facebook.com/business/help/AboutConversionsAPI)"
1423
+ }
1424
+ ],
1425
+ "markdown": "# Meta Ads\n\n**What Sprid does with this:** Prepare campaigns in your own Meta ad account, start them only when you confirm, and report what they spent and returned.\n\n## You need\n\n- A Facebook profile with two-factor authentication turned on. Meta will not let you add ad assets without it.\n- A **business portfolio** at [business.facebook.com](https://business.facebook.com). One per company is enough, even with several apps or brands. See **One portfolio or several** below.\n- A **Facebook Page** for the brand. Ads always run from a Page, including the ones shown on Instagram. To create one, see [Create your social accounts](https://sprid.studio/docs/connect/social-accounts).\n- An **ad account** with a payment method, one per brand.\n- Optional: a **dataset** (formerly Meta Pixel) if you want campaigns that optimise for signups or purchases.\n\nYou do **not** create a Meta developer app or copy a token. You only sign in with Facebook and pick the Page and ad account.\n\n## Set up Meta (once, before you connect)\n\nSkip any step you have already done.\n\n1. **Business portfolio.** Go to [business.facebook.com](https://business.facebook.com) and create a business portfolio under your company’s legal name.\n2. **Page.** In the portfolio, open **Settings → Accounts → Pages** and add the brand’s Page. Give yourself access that includes **Ads**.\n3. **Ad account.** Open **Settings → Accounts → Ad accounts → Add → Create a new ad account**. Pick the **currency** and **time zone** carefully: once the account has spent anything, neither can be changed. Choose the currency of the market you advertise in. Give yourself **Manage campaigns** or full control.\n4. **Payment method.** Open **Billing & payments** for that ad account and add a card. Sprid can connect an account without one, but no campaign can start until it has one.\n5. **Dataset (optional).** Open [Events Manager](https://business.facebook.com/events_manager2) → **Connect data sources → Web**, name it after your domain and copy its **dataset id**. Sprid only needs the id. The events themselves reach it from your site or app, through the Meta Pixel or the Conversions API, and without events a conversion campaign has nothing to optimise for.\n\n## Then run\n\n```sh\nsprid connect meta-ads --account <slug>\n```\n\nReplace `<slug>` with your Sprid account slug. Add `--dataset <id>` if you created one in step 5. You can also use **Settings → Accounts → [account] → Meta Ads** in Sprid.\n\n## Click path (the connect)\n\n1. Sign in with the Facebook profile that manages the Page and the ad account.\n2. Leave every requested permission switched on and click **Continue**.\n3. Back in Sprid, pick the **Page** your ads should appear from, then the **ad account**. Each list only appears when there is more than one to choose from.\n4. From the CLI, a profile with several Pages or ad accounts gets a message listing their ids. Run the command again with them:\n\n```sh\nsprid connect meta-ads --account <slug> --page <page id> --ad-account <ad account id> --dataset <dataset id>\n```\n\nWith several brands, connect each Sprid account separately and pick that brand’s own Page and ad account every time.\n\n## Check the connection\n\nRun `sprid status` and check that Meta Ads shows the intended Page name, followed by “(Ads)”.\n\nA campaign Sprid prepares starts **paused**. Nothing is spent until you confirm the start.\n\n## One portfolio or several\n\nKeep one business portfolio for your company and put one Page, one ad account and one dataset per brand inside it.\n\n- **A restriction on an ad account stays on that account.** The other brands keep running.\n- **Separate portfolios isolate less than they appear to.** Meta links portfolios run by the same people and paid with the same card, so a problem on one is often read as a problem on all of them. You also pay for the split: each portfolio has to be verified and set up separately, and a personal profile can only create a couple of them.\n- **What one portfolio risks:** if the portfolio itself is restricted, every brand in it stops at once. If one brand operates in a regulated category (housing, credit, employment, politics) and draws an enforcement action, move that brand to its own portfolio then.\n\nGive each ad account its own payment method where you can, and add a second admin to the portfolio so you are not locked out if your own profile has a problem.\n\n## If it fails\n\n- **“No Facebook Pages found”:** the profile you signed in with has no Page access. Check the Page in the portfolio, give yourself access and reconnect.\n- **No ad account to choose, or the wrong ones:** only active ad accounts are listed. Check that the account is not disabled or closed in Ads Manager and that your profile is assigned to it.\n- **“Re-run the connect with --page / --ad-account”:** the profile manages several. Copy the right ids from the message and run the command again with them.\n- **“Already connected to another account”:** that ad account is connected to a different Sprid account. Check you picked the right one; move it only if you mean to.\n- **Campaign refuses to start with a conversion goal:** the connection has no dataset. Reconnect with `--dataset <id>`.\n- **Campaign refuses to start at all:** add a payment method to the ad account (step 4).\n- **“Invalid Scopes”, app unavailable or “URL blocked”:** contact [Sprid support](mailto:hello@sprid.studio). Only Sprid can fix these.\n- **Expiring connection in `sprid status`:** Meta’s sign-in lasts about 60 days. Run the connect again.\n\n## Sources\n\n- [Create a business portfolio](https://www.facebook.com/business/help/1710077379203657)\n- [Add an ad account to your business portfolio](https://www.facebook.com/business/help/915885887059947)\n- [Ad account limits](https://www.facebook.com/business/help/1026272311098874)\n- [Add a payment method to an ad account](https://www.facebook.com/business/help/132073386867900)\n- [Assign business assets to people](https://www.facebook.com/business/help/325571851329683)\n- [Create a dataset in Events Manager](https://www.facebook.com/business/help/5818684664831465)\n- [About the Conversions API](https://www.facebook.com/business/help/AboutConversionsAPI)\n",
1426
+ "revision": "ee377cb02a88bda4"
1427
+ },
1428
+ {
1429
+ "id": "google-ads",
1430
+ "title": "Google Ads",
1431
+ "summary": "Promote your published YouTube videos from your own Google Ads account, start each campaign only when you confirm, and report what it spent and how many people watched.",
1432
+ "command": "sprid connect google-ads --account <slug>",
1433
+ "url": "https://sprid.studio/docs/connect/google-ads",
1434
+ "sections": [
1435
+ {
1436
+ "id": "you-need",
1437
+ "title": "You need",
1438
+ "kind": "requirements",
1439
+ "markdown": "- A **Google Ads account** with a payment method. It can be the same Google login as your YouTube channel or a different one.\n- Access to that ad account as **Standard** or **Admin**. Read-only and Billing access cannot create campaigns.\n- A **YouTube video published through Sprid** that is **public** or **unlisted**. Google does not run private videos as ads.\n- A **channel picture** on that YouTube channel. Google shows it as the ad’s logo and refuses an ad without one.\n\nYou do **not** create a Google Cloud project, a developer token or an API key. You only sign in with Google and pick the ad account."
1440
+ },
1441
+ {
1442
+ "id": "set-up-google-ads-once-before-you-connect",
1443
+ "title": "Set up Google Ads (once, before you connect)",
1444
+ "kind": "steps",
1445
+ "markdown": "Skip any step you have already done.\n\n1. **Ad account.** Go to [ads.google.com](https://ads.google.com) and create an account. Pick the **currency** and **time zone** carefully: neither can be changed later.\n2. **Payment method.** Open **Billing → Settings** in that account and add a payment method. Sprid can connect an account without one, but no campaign can start until it has one.\n3. **Access.** If someone else owns the ad account, ask them to add your Google login under **Admin → Access and security** with **Standard** or **Admin** access."
1446
+ },
1447
+ {
1448
+ "id": "then-run",
1449
+ "title": "Then run",
1450
+ "kind": "command",
1451
+ "markdown": "```sh\nsprid connect google-ads --account <slug>\n```\n\nReplace `<slug>` with your Sprid account slug."
1452
+ },
1453
+ {
1454
+ "id": "click-path-the-connect",
1455
+ "title": "Click path (the connect)",
1456
+ "kind": "steps",
1457
+ "markdown": "1. Sign in with the Google login that has access to the ad account.\n2. Google asks to let Sprid **see, edit, create and delete your Google Ads accounts and data**. Click **Continue**. Sprid only creates the campaigns you review, and creates them paused.\n3. If the login can manage more than one ad account, Sprid lists their ids. Connect again with the one this brand should use:\n\n```sh\nsprid connect google-ads --account <slug> --ad-account <customer id>\n```\n\nThe customer id is the ten-digit number at the top right of Google Ads, for example `123-456-7890`."
1458
+ },
1459
+ {
1460
+ "id": "check-the-connection",
1461
+ "title": "Check the connection",
1462
+ "kind": "verify",
1463
+ "markdown": "Run `sprid status` and check that the ad account’s name shows, followed by “(Google Ads)”.\n\nWhen you promote a YouTube video, Sprid prepares a **Demand Gen** campaign that shows the video on YouTube (in-stream, in-feed and Shorts) in the countries you pick, at the daily budget you review. It starts **paused**. Nothing is spent until you confirm the start. Google reviews the ad before it runs, usually within a day.\n\nWhat Sprid sends Google for the ad: the video, its title as the headline, the first line of its caption as the description, the channel name as the business name and the channel picture as the logo. Hashtags are left out."
1464
+ },
1465
+ {
1466
+ "id": "if-it-fails",
1467
+ "title": "If it fails",
1468
+ "kind": "troubleshooting",
1469
+ "markdown": "- **“Google Ads promotion is not switched on in Sprid yet”:** Sprid is waiting on Google’s approval of its own access. Nothing on your side needs to change.\n- **“This Google login has no active Google Ads account”:** the login you signed in with has no ad account, or only cancelled ones. Create one (step 1) or sign in with the login that has access.\n- **Your ad account is missing from the list:** only active accounts you can open directly, or directly under a manager account you can open, are listed. Ask the owner to add your login to the ad account itself.\n- **“Google returned no refresh token”:** remove Sprid at [myaccount.google.com/permissions](https://myaccount.google.com/permissions), then connect again.\n- **“This Google login cannot manage the connected Google Ads account”:** your access is read-only or billing-only. Ask for Standard access and reconnect.\n- **A campaign refuses to start:** add a payment method (step 2). Sprid shows this before you pick a budget when Google reports no approved billing.\n- **“Google Ads needs the YouTube channel’s picture as the ad’s logo”:** add a channel picture in **YouTube Studio → Customization → Branding**, reconnect YouTube, then promote again.\n- **“Google Ads cannot target …”:** Google does not advertise in that country. Remove it and try again.\n- **Political or gambling content:** Google requires advertiser verification or a certification Sprid cannot hold for you. Promote those from Google Ads directly.\n- **The ad is disapproved:** Sprid shows Google’s policy reason on the promotion. Open the campaign in Google Ads to appeal or edit.\n- **“Invalid scope”, app unavailable or “redirect_uri_mismatch”:** contact [Sprid support](mailto:hello@sprid.studio). Only Sprid can fix these."
1470
+ },
1471
+ {
1472
+ "id": "sources",
1473
+ "title": "Sources",
1474
+ "kind": "sources",
1475
+ "markdown": "- [Create a Google Ads account](https://support.google.com/google-ads/answer/6366720)\n- [Add a payment method](https://support.google.com/google-ads/answer/2375375)\n- [Access levels in your Google Ads account](https://support.google.com/google-ads/answer/9978556)\n- [About Demand Gen campaigns](https://support.google.com/google-ads/answer/13695777)\n- [Create a Demand Gen campaign (API)](https://developers.google.com/google-ads/api/docs/demand-gen/create-campaign)\n- [Google Ads API OAuth scope](https://developers.google.com/google-ads/api/docs/oauth/overview)"
1476
+ }
1477
+ ],
1478
+ "markdown": "# Google Ads\n\n**What Sprid does with this:** Promote your published YouTube videos from your own Google Ads account, start each campaign only when you confirm, and report what it spent and how many people watched.\n\n## You need\n\n- A **Google Ads account** with a payment method. It can be the same Google login as your YouTube channel or a different one.\n- Access to that ad account as **Standard** or **Admin**. Read-only and Billing access cannot create campaigns.\n- A **YouTube video published through Sprid** that is **public** or **unlisted**. Google does not run private videos as ads.\n- A **channel picture** on that YouTube channel. Google shows it as the ad’s logo and refuses an ad without one.\n\nYou do **not** create a Google Cloud project, a developer token or an API key. You only sign in with Google and pick the ad account.\n\n## Set up Google Ads (once, before you connect)\n\nSkip any step you have already done.\n\n1. **Ad account.** Go to [ads.google.com](https://ads.google.com) and create an account. Pick the **currency** and **time zone** carefully: neither can be changed later.\n2. **Payment method.** Open **Billing → Settings** in that account and add a payment method. Sprid can connect an account without one, but no campaign can start until it has one.\n3. **Access.** If someone else owns the ad account, ask them to add your Google login under **Admin → Access and security** with **Standard** or **Admin** access.\n\n## Then run\n\n```sh\nsprid connect google-ads --account <slug>\n```\n\nReplace `<slug>` with your Sprid account slug.\n\n## Click path (the connect)\n\n1. Sign in with the Google login that has access to the ad account.\n2. Google asks to let Sprid **see, edit, create and delete your Google Ads accounts and data**. Click **Continue**. Sprid only creates the campaigns you review, and creates them paused.\n3. If the login can manage more than one ad account, Sprid lists their ids. Connect again with the one this brand should use:\n\n```sh\nsprid connect google-ads --account <slug> --ad-account <customer id>\n```\n\nThe customer id is the ten-digit number at the top right of Google Ads, for example `123-456-7890`.\n\n## Check the connection\n\nRun `sprid status` and check that the ad account’s name shows, followed by “(Google Ads)”.\n\nWhen you promote a YouTube video, Sprid prepares a **Demand Gen** campaign that shows the video on YouTube (in-stream, in-feed and Shorts) in the countries you pick, at the daily budget you review. It starts **paused**. Nothing is spent until you confirm the start. Google reviews the ad before it runs, usually within a day.\n\nWhat Sprid sends Google for the ad: the video, its title as the headline, the first line of its caption as the description, the channel name as the business name and the channel picture as the logo. Hashtags are left out.\n\n## If it fails\n\n- **“Google Ads promotion is not switched on in Sprid yet”:** Sprid is waiting on Google’s approval of its own access. Nothing on your side needs to change.\n- **“This Google login has no active Google Ads account”:** the login you signed in with has no ad account, or only cancelled ones. Create one (step 1) or sign in with the login that has access.\n- **Your ad account is missing from the list:** only active accounts you can open directly, or directly under a manager account you can open, are listed. Ask the owner to add your login to the ad account itself.\n- **“Google returned no refresh token”:** remove Sprid at [myaccount.google.com/permissions](https://myaccount.google.com/permissions), then connect again.\n- **“This Google login cannot manage the connected Google Ads account”:** your access is read-only or billing-only. Ask for Standard access and reconnect.\n- **A campaign refuses to start:** add a payment method (step 2). Sprid shows this before you pick a budget when Google reports no approved billing.\n- **“Google Ads needs the YouTube channel’s picture as the ad’s logo”:** add a channel picture in **YouTube Studio → Customization → Branding**, reconnect YouTube, then promote again.\n- **“Google Ads cannot target …”:** Google does not advertise in that country. Remove it and try again.\n- **Political or gambling content:** Google requires advertiser verification or a certification Sprid cannot hold for you. Promote those from Google Ads directly.\n- **The ad is disapproved:** Sprid shows Google’s policy reason on the promotion. Open the campaign in Google Ads to appeal or edit.\n- **“Invalid scope”, app unavailable or “redirect_uri_mismatch”:** contact [Sprid support](mailto:hello@sprid.studio). Only Sprid can fix these.\n\n## Sources\n\n- [Create a Google Ads account](https://support.google.com/google-ads/answer/6366720)\n- [Add a payment method](https://support.google.com/google-ads/answer/2375375)\n- [Access levels in your Google Ads account](https://support.google.com/google-ads/answer/9978556)\n- [About Demand Gen campaigns](https://support.google.com/google-ads/answer/13695777)\n- [Create a Demand Gen campaign (API)](https://developers.google.com/google-ads/api/docs/demand-gen/create-campaign)\n- [Google Ads API OAuth scope](https://developers.google.com/google-ads/api/docs/oauth/overview)\n",
1479
+ "revision": "169d6e428b3a258c"
1480
+ },
1481
+ {
1482
+ "id": "tiktok-ads",
1483
+ "title": "TikTok Ads",
1484
+ "summary": "Promote a post that is already live on TikTok as a Spark Ad from your own TikTok ad account, start it only when you confirm, and report what it spent and reached.",
1485
+ "command": "sprid connect tiktok-ads --account <slug>",
1486
+ "url": "https://sprid.studio/docs/connect/tiktok-ads",
1487
+ "sections": [
1488
+ {
1489
+ "id": "you-need",
1490
+ "title": "You need",
1491
+ "kind": "requirements",
1492
+ "markdown": "- A **TikTok for Business** login at [ads.tiktok.com](https://ads.tiktok.com), with an **ad account** you manage. This is a different login from your TikTok app account, and a different connection from TikTok publishing in Sprid.\n- A **payment method** or a prepaid balance on that ad account.\n- For each post you promote, its **ad authorization code**, generated in the TikTok app by whoever owns the post. See **Get a post's ad authorization code** below. Once a code is applied, Sprid remembers the post until the code expires.\n\nYou do **not** create a TikTok developer app or copy a token. You only sign in to TikTok for Business and pick the ad account."
1493
+ },
1494
+ {
1495
+ "id": "set-up-tiktok-for-business-once-before-you-connect",
1496
+ "title": "Set up TikTok for Business (once, before you connect)",
1497
+ "kind": "steps",
1498
+ "markdown": "Skip any step you have already done.\n\n1. **Ad account.** Sign in at [ads.tiktok.com](https://ads.tiktok.com) and create an ad account. Pick the **currency** and **time zone** carefully: neither can be changed later. Sprid’s budget controls support currencies with two decimals (USD, EUR, SEK and most others).\n2. **Payment.** In TikTok Ads Manager open **Account → Payment** and add a card or funds. Sprid can connect an account without one, but nothing can deliver until it can pay.\n3. **Account review.** A new ad account is reviewed by TikTok before it can run anything. Sprid tells you when the account is still under review."
1499
+ },
1500
+ {
1501
+ "id": "then-run",
1502
+ "title": "Then run",
1503
+ "kind": "command",
1504
+ "markdown": "```sh\nsprid connect tiktok-ads --account <slug>\n```\n\nReplace `<slug>` with your Sprid account slug. You can also use **Settings → Accounts → [account] → TikTok Ads** in Sprid."
1505
+ },
1506
+ {
1507
+ "id": "click-path-the-connect",
1508
+ "title": "Click path (the connect)",
1509
+ "kind": "steps",
1510
+ "markdown": "1. Sign in with the TikTok for Business login that manages the ad account.\n2. Choose the ad account, leave every requested permission switched on and click **Confirm**.\n3. Back in Sprid, pick the **ad account** if the login manages more than one. The list only appears when there is a choice.\n\nWith several brands, connect each Sprid account separately and pick that brand’s own ad account every time."
1511
+ },
1512
+ {
1513
+ "id": "get-a-posts-ad-authorization-code",
1514
+ "title": "Get a post's ad authorization code",
1515
+ "kind": "detail",
1516
+ "markdown": "A Spark Ad runs the post as itself, under the creator’s account, so the creator has to allow it. In the TikTok app, signed in as the account that published the post:\n\n1. Open the post, tap **…** (or the share arrow), then **Ad settings**.\n2. Turn on **Ad authorization**. The first time, TikTok may ask you to turn it on for the account in **Settings and privacy → Creator tools → Ad settings**.\n3. Tap **Generate code**, pick how long the authorization should last and copy the code.\n4. In Sprid, open **Promote** on the post and paste the code when it asks for one.\n\nPick a duration longer than the promotion. Sprid refuses a code that ends before the promotion would, because the ad would stop when it expires. TikTok offers 7, 30, 60, 180 or 365 days *(from TikTok’s help pages; the exact labels in the app may differ)*."
1517
+ },
1518
+ {
1519
+ "id": "check-the-connection",
1520
+ "title": "Check the connection",
1521
+ "kind": "verify",
1522
+ "markdown": "Run `sprid status` and check that TikTok Ads shows the intended ad account name, followed by “(TikTok Ads)”.\n\nA promotion Sprid prepares starts **paused**. Nothing is spent until you confirm the start. It runs on TikTok only, to adults (18+), in the countries you choose."
1523
+ },
1524
+ {
1525
+ "id": "if-it-fails",
1526
+ "title": "If it fails",
1527
+ "kind": "troubleshooting",
1528
+ "markdown": "- **“TikTok promotion is not switched on in Sprid yet”:** Sprid has not enabled TikTok ads on this server. Contact [Sprid support](mailto:hello@sprid.studio).\n- **“This post needs its ad authorization code”:** generate one in the TikTok app (see above) and paste it in the Promote sheet.\n- **“That authorization code belongs to a different TikTok post”:** the code was generated on another post. Generate it on the post you are promoting.\n- **“The post’s ad authorization ends … before the promotion would”:** generate a new code with a longer duration, or shorten the promotion.\n- **“This TikTok ad account is not active” or still under review:** check the account’s status in TikTok Ads Manager.\n- **“TikTok cannot show ads in …”:** that country is not available to this ad account. Remove it from the promotion.\n- **The currency is not supported:** Sprid’s budgets need a two-decimal currency. Create an ad account in a supported currency.\n- **Political content:** TikTok does not allow political ads. Sprid refuses them.\n- **Housing, credit or employment:** declare the category in the Promote sheet. TikTok then limits targeting. Outside the US and Canada TikTok may refuse the category for your account; the message says so.\n- **The ad stopped with “creator authorization revoked”:** the creator turned ad authorization off or the code expired. Generate a new code and promote the post again."
1529
+ },
1530
+ {
1531
+ "id": "sources",
1532
+ "title": "Sources",
1533
+ "kind": "sources",
1534
+ "markdown": "- [TikTok API for Business: authorization](https://business-api.tiktok.com/portal/docs?id=1738373164380162)\n- [About Spark Ads](https://ads.tiktok.com/help/article/spark-ads)\n- [How to create Spark Ads in TikTok Ads Manager](https://ads.tiktok.com/help/article/spark-ads-creation-guide)\n- [Special ad categories on TikTok](https://ads.tiktok.com/help/article/special-ad-categories)"
1535
+ }
1536
+ ],
1537
+ "markdown": "# TikTok Ads\n\n**What Sprid does with this:** Promote a post that is already live on TikTok as a Spark Ad from your own TikTok ad account, start it only when you confirm, and report what it spent and reached.\n\n## You need\n\n- A **TikTok for Business** login at [ads.tiktok.com](https://ads.tiktok.com), with an **ad account** you manage. This is a different login from your TikTok app account, and a different connection from TikTok publishing in Sprid.\n- A **payment method** or a prepaid balance on that ad account.\n- For each post you promote, its **ad authorization code**, generated in the TikTok app by whoever owns the post. See **Get a post's ad authorization code** below. Once a code is applied, Sprid remembers the post until the code expires.\n\nYou do **not** create a TikTok developer app or copy a token. You only sign in to TikTok for Business and pick the ad account.\n\n## Set up TikTok for Business (once, before you connect)\n\nSkip any step you have already done.\n\n1. **Ad account.** Sign in at [ads.tiktok.com](https://ads.tiktok.com) and create an ad account. Pick the **currency** and **time zone** carefully: neither can be changed later. Sprid’s budget controls support currencies with two decimals (USD, EUR, SEK and most others).\n2. **Payment.** In TikTok Ads Manager open **Account → Payment** and add a card or funds. Sprid can connect an account without one, but nothing can deliver until it can pay.\n3. **Account review.** A new ad account is reviewed by TikTok before it can run anything. Sprid tells you when the account is still under review.\n\n## Then run\n\n```sh\nsprid connect tiktok-ads --account <slug>\n```\n\nReplace `<slug>` with your Sprid account slug. You can also use **Settings → Accounts → [account] → TikTok Ads** in Sprid.\n\n## Click path (the connect)\n\n1. Sign in with the TikTok for Business login that manages the ad account.\n2. Choose the ad account, leave every requested permission switched on and click **Confirm**.\n3. Back in Sprid, pick the **ad account** if the login manages more than one. The list only appears when there is a choice.\n\nWith several brands, connect each Sprid account separately and pick that brand’s own ad account every time.\n\n## Get a post's ad authorization code\n\nA Spark Ad runs the post as itself, under the creator’s account, so the creator has to allow it. In the TikTok app, signed in as the account that published the post:\n\n1. Open the post, tap **…** (or the share arrow), then **Ad settings**.\n2. Turn on **Ad authorization**. The first time, TikTok may ask you to turn it on for the account in **Settings and privacy → Creator tools → Ad settings**.\n3. Tap **Generate code**, pick how long the authorization should last and copy the code.\n4. In Sprid, open **Promote** on the post and paste the code when it asks for one.\n\nPick a duration longer than the promotion. Sprid refuses a code that ends before the promotion would, because the ad would stop when it expires. TikTok offers 7, 30, 60, 180 or 365 days *(from TikTok’s help pages; the exact labels in the app may differ)*.\n\n## Check the connection\n\nRun `sprid status` and check that TikTok Ads shows the intended ad account name, followed by “(TikTok Ads)”.\n\nA promotion Sprid prepares starts **paused**. Nothing is spent until you confirm the start. It runs on TikTok only, to adults (18+), in the countries you choose.\n\n## If it fails\n\n- **“TikTok promotion is not switched on in Sprid yet”:** Sprid has not enabled TikTok ads on this server. Contact [Sprid support](mailto:hello@sprid.studio).\n- **“This post needs its ad authorization code”:** generate one in the TikTok app (see above) and paste it in the Promote sheet.\n- **“That authorization code belongs to a different TikTok post”:** the code was generated on another post. Generate it on the post you are promoting.\n- **“The post’s ad authorization ends … before the promotion would”:** generate a new code with a longer duration, or shorten the promotion.\n- **“This TikTok ad account is not active” or still under review:** check the account’s status in TikTok Ads Manager.\n- **“TikTok cannot show ads in …”:** that country is not available to this ad account. Remove it from the promotion.\n- **The currency is not supported:** Sprid’s budgets need a two-decimal currency. Create an ad account in a supported currency.\n- **Political content:** TikTok does not allow political ads. Sprid refuses them.\n- **Housing, credit or employment:** declare the category in the Promote sheet. TikTok then limits targeting. Outside the US and Canada TikTok may refuse the category for your account; the message says so.\n- **The ad stopped with “creator authorization revoked”:** the creator turned ad authorization off or the code expired. Generate a new code and promote the post again.\n\n## Sources\n\n- [TikTok API for Business: authorization](https://business-api.tiktok.com/portal/docs?id=1738373164380162)\n- [About Spark Ads](https://ads.tiktok.com/help/article/spark-ads)\n- [How to create Spark Ads in TikTok Ads Manager](https://ads.tiktok.com/help/article/spark-ads-creation-guide)\n- [Special ad categories on TikTok](https://ads.tiktok.com/help/article/special-ad-categories)\n",
1538
+ "revision": "41a9c611fbe927bd"
1300
1539
  }
1301
1540
  ];
1302
1541
  export const CONTENT_GUIDES = [
@@ -1348,6 +1587,14 @@ export const CONTENT_GUIDES = [
1348
1587
  "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
1588
  "revision": "6799e74c42287dc5"
1350
1589
  },
1590
+ {
1591
+ "id": "ads",
1592
+ "title": "Ads: pay to show more people what already worked",
1593
+ "summary": "Explains how Sprid thinks about paid ads on Instagram, Facebook, YouTube and TikTok: when to spend, how a promotion runs from draft to result, and which numbers to believe along the way.",
1594
+ "url": "https://sprid.studio/docs/ads",
1595
+ "markdown": "# Ads: pay to show more people what already worked\n\n**What this guide does:** Explains how Sprid thinks about paid ads on\nInstagram, Facebook, YouTube and TikTok: when to spend, how a promotion runs\nfrom draft to result, and which numbers to believe along the way.\n\nAds don't rescue a post nobody wanted. They amplify, and they amplify\nwhatever you give them, including a weak idea. So the order Sprid follows is\norganic first: publish, see which posts people actually watched and saved, and\nput money behind the one that already beat your normal. That post has passed\nthe hardest test for free.\n\n## What we believe\n\n**Promote proof, not hope.** A post that did twice your median has evidence\nbehind it. A brand-new creative has only an opinion. Start with the post, and\nmake new creatives once you know what the winning one had.\n\n**Nothing spends without you.** Everything Sprid or your agent prepares is\npaused. Exactly one step starts spending, and it waits for your yes, showing\nwhat will run, what it costs a day and for how long. An agent never moves a\nbudget between reads on its own.\n\n**Keep the words, vary the picture.** We read 3,549 live ads across five\nmarkets. Among advertisers running eight or more, the median was 2.9 ads per\nline of copy, and the largest wellness advertiser ran 109 ads on essentially one\nline. The ad library counts placements separately, so the exact ratio overstates,\nbut the shape holds: the people who keep paying hold the copy and change the\ncreative. A new version changes one thing, or the result teaches nothing.\n\n**Give it time to be readable.** Meta's delivery settles after roughly 50\noptimisation events in a week, and Google and TikTok need a similar learning\nperiod. Before that, a result is noise, and the most\ncommon way to waste money on ads is to stop an ad set before it has said\nanything, then decide the idea doesn't work. Sprid says whether a result is\nreadable before it says whether it worked.\n\n**Judge by what happens in your product.** Cost per install or subscriber comes\nfirst and impressions come last, because the order a report puts numbers in is\nthe order people believe them in. When your app reports who became a paying\ncustomer, that outranks anything the platform can see.\n\n## Two ways to start\n\n**Promote a post.** Pick a post already live, set a daily budget and an end\ndate, and confirm. The ad runs only where that post lives, through the network\nbehind it:\n\n| Post live on | Runs as | Paid by |\n|---|---|---|\n| Instagram | a Meta ad on Instagram only | your Meta ad account |\n| Facebook | a Meta ad on Facebook only | your Meta ad account |\n| YouTube | a Google Ads video campaign (YouTube, Shorts, Discover, Gmail) | your Google Ads account |\n| TikTok | a Spark Ad on TikTok | your TikTok ad account |\n\nSprid checks the ad account can pay before you choose a budget, the network\nreviews the ad, and you get an email when it goes live, is rejected (with the\nnetwork's reason) or ends (with what the money bought). The budget and end date\ncan change while it runs, from Sprid. A TikTok post also needs the creator's\npermission for that one post: a code the TikTok app makes under the post's Ad\nsettings, pasted into Sprid.\n\n**Run a test.** For finding out what to say, not just saying it louder. Tests\nrun on Meta. Every\ncreative is built from a named shape, such as the screen that shows the app's\nverdict about the reader, two columns where the reader does the arithmetic, or\none sourced number. The shape is what makes a result transferable: \"showing the\nverdict beat listing features\" is something you can build again, \"ad 7 beat\nad 3\" is not.\n\n## The checks before money moves\n\n- **The copy check.** Banned words, your own never-say list, invented urgency,\n and openings that name an identity (\"for anxious people\") instead of a moment\n (\"you reread the message four times\"). Warnings pass, errors block the launch.\n- **Regulated categories.** Housing, credit, employment and political ads must\n be declared. An undeclared one isn't quietly rejected, it's pulled with the\n ad account attached. Anything about where people live, what they can borrow or\n who gets hired counts, even when the product doesn't look like it.\n- **The payment method**, before a budget is chosen rather than after Meta has\n accepted the campaign and refused the ad.\n\n## Reading the result\n\nAsk for an ad set's verdict once a week and it answers three questions: did it\nwork, why, and what next.\nWhen a test ends, it writes down what the hypothesis was, what happened and\nwhich shape carried it, so the next test starts from that instead of from zero.\nA losing set is data: close it and say what it ruled out.\n\nTwo rules for the numbers. A campaign's totals and its ad sets' totals are\nseparate readings of the same money, never added together. And a period that\nends today and one that ended last week are two different readings, so every\nnumber says which window it covers.\n\n## What competitors can and can't tell you\n\nMeta's Ad Library shows every active ad: the creative, the copy and the date it\nstarted. It shows no spend, no clicks and no results. Days live means somebody\nkeeps paying, not that it works, so a competitor's ad is \"longest-running\",\nnever \"best-performing\". Sprid's scout research reads the library for your own\nmarket, which is where the shapes above came from.\n\n## You need\n\n- An ad account with a card on it, connected to Sprid, for the network behind\n the post: [Meta Ads](https://sprid.studio/docs/connect/meta-ads) for\n Instagram and Facebook, [Google Ads](https://sprid.studio/docs/connect/google-ads)\n for YouTube, [TikTok Ads](https://sprid.studio/docs/connect/tiktok-ads) for\n TikTok.\n- A published post to promote, or, on Meta, an image or video to build a\n creative from.\n- For an agent to launch anything, a token that holds the `ads:spend`\n permission. It's granted deliberately, on a token made for it.\n",
1596
+ "revision": "a6578de65ac0525b"
1597
+ },
1351
1598
  {
1352
1599
  "id": "reel-first-seconds",
1353
1600
  "title": "The first seconds of a reel",
package/src/docs/help.mjs CHANGED
@@ -7,6 +7,7 @@ export const HELP_TOPICS = {
7
7
  'SPRID_PAT: personal access token; overrides ~/.sprid/credentials.json.',
8
8
  'SPRID_URL: API address; overrides the saved address. HTTPS except on loopback.',
9
9
  'SPRID_APP_URL: browser app address; otherwise derived from the API address.',
10
+ 'DO_NOT_TRACK=1 or SPRID_TELEMETRY=0: send no usage telemetry, whatever sprid telemetry says.',
10
11
  'Workspace: --workspace/-w > .sprid/app.json workspaceId > saved choice > sole workspace > local workspace slug.',
11
12
  'Local media can pin tokenEnv in sprid.config; that token is required when configured.',
12
13
  'Never paste credentials into a command example or an agent conversation. Connect with --key <file>.',
@@ -0,0 +1,140 @@
1
+ // Usage telemetry: after a command finishes, one small POST to the Sprid API
2
+ // saying which command ran, how it ended and how long it took. What is sent
3
+ // is exactly `commandEvent`'s fields: the command, a subcommand only when it
4
+ // is one this CLI documents, the exit code, an error KIND (an HTTP status or
5
+ // usage/network/other, never the message), the duration, the CLI and Node
6
+ // versions, the OS and whether it ran in CI. Never arguments, file paths,
7
+ // slugs, output or anything read from disk.
8
+ //
9
+ // It goes to the API the CLI is already signed in to, under the same login,
10
+ // and nowhere else. Signed out means nothing is sent. Off with
11
+ // `sprid telemetry off`, DO_NOT_TRACK=1 or SPRID_TELEMETRY=0. The first run
12
+ // that could send only prints the notice; nothing leaves before the person
13
+ // has been told.
14
+
15
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
16
+ import { homedir } from 'node:os';
17
+ import { dirname, join } from 'node:path';
18
+ import { COMMAND_GROUPS } from './docs/commands.mjs';
19
+ import { UsageError } from './args.mjs';
20
+
21
+ const SEND_TIMEOUT_MS = 1000;
22
+ /** Commands that report nothing: they are about the CLI itself, or run for hours. */
23
+ const SILENT = new Set(['help', 'version', 'completion', 'mcp', 'telemetry']);
24
+
25
+ export const NOTICE = 'Sprid CLI sends usage telemetry to your Sprid account: the command name, exit code and duration, never arguments or file contents. Turn it off with `sprid telemetry off` or DO_NOT_TRACK=1.';
26
+
27
+ export function settingsPath(env = process.env) {
28
+ return join(env.HOME || homedir(), '.sprid', 'telemetry.json');
29
+ }
30
+
31
+ export function readSettings(env = process.env) {
32
+ try { return JSON.parse(readFileSync(settingsPath(env), 'utf8')) ?? {}; } catch { return {}; }
33
+ }
34
+
35
+ export function writeSettings(settings, env = process.env) {
36
+ const path = settingsPath(env);
37
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
38
+ writeFileSync(path, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
39
+ }
40
+
41
+ const truthy = value => ['1', 'true', 'yes'].includes(String(value ?? '').trim().toLowerCase());
42
+ const falsy = value => ['0', 'false', 'no', 'off'].includes(String(value ?? '').trim().toLowerCase());
43
+
44
+ /** Whether telemetry is on, and which setting decided it. The environment wins over the file. */
45
+ export function telemetryState(env = process.env) {
46
+ if (truthy(env.DO_NOT_TRACK)) return { enabled: false, reason: 'DO_NOT_TRACK is set' };
47
+ if (falsy(env.SPRID_TELEMETRY)) return { enabled: false, reason: 'SPRID_TELEMETRY is off' };
48
+ const settings = readSettings(env);
49
+ if (settings.enabled === false) return { enabled: false, reason: 'turned off with sprid telemetry off' };
50
+ return { enabled: true, reason: settings.enabled === true ? 'turned on with sprid telemetry on' : 'on by default', noticed: Boolean(settings.noticedAt) };
51
+ }
52
+
53
+ let documented;
54
+ /** The subcommands the help catalog names for each command, e.g. post → create, get, build... */
55
+ export function documentedSubcommands() {
56
+ if (documented) return documented;
57
+ documented = new Map();
58
+ for (const { command, usage } of COMMAND_GROUPS.flatMap(group => group.entries)) {
59
+ const second = usage.split(/\s+/)[2] ?? '';
60
+ const words = second.replace(/[[\]]/g, '').split('|').filter(word => /^[a-z][a-z0-9-]*$/.test(word));
61
+ if (!documented.has(command)) documented.set(command, new Set());
62
+ for (const word of words) documented.get(command).add(word);
63
+ }
64
+ return documented;
65
+ }
66
+
67
+ export function subcommandOf(command, words) {
68
+ const first = (words ?? []).find(word => !String(word).startsWith('-'));
69
+ return first && documentedSubcommands().get(command)?.has(first) ? first : null;
70
+ }
71
+
72
+ export function errorKind(error) {
73
+ if (!error) return null;
74
+ if (error.name === 'UsageError' || error.exitCode === 2) return 'usage';
75
+ if (typeof error.status === 'number') return error.status === 0 ? 'network' : error.status;
76
+ return 'other';
77
+ }
78
+
79
+ export function commandEvent({ command, words, exitCode, durationMs, error, version, env = process.env, workspaceId = null, now = new Date() }) {
80
+ return {
81
+ command,
82
+ subcommand: subcommandOf(command, words),
83
+ exitCode: Math.max(0, Math.min(255, Number(exitCode) || 0)),
84
+ durationMs: Math.max(0, Math.round(durationMs)),
85
+ error: errorKind(error),
86
+ version,
87
+ platform: process.platform,
88
+ node: process.version,
89
+ ci: Boolean(env.CI && !falsy(env.CI)),
90
+ workspaceId,
91
+ at: now.toISOString(),
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Report one finished command. Never throws and never waits longer than
97
+ * SEND_TIMEOUT_MS: a slow network costs the person at most a second, and a
98
+ * failure costs them nothing.
99
+ */
100
+ export async function reportCommand(ctx, { command, words, exitCode, durationMs, error }) {
101
+ try {
102
+ if (SILENT.has(command)) return false;
103
+ const state = telemetryState(ctx.env);
104
+ if (!state.enabled) return false;
105
+ let auth;
106
+ try { auth = ctx.auth(); } catch { return false; }
107
+ if (!state.noticed) {
108
+ ctx.warn(` ${NOTICE}`);
109
+ try { writeSettings({ ...readSettings(ctx.env), noticedAt: new Date().toISOString() }, ctx.env); } catch { /* A read-only home only means the notice repeats. */ }
110
+ return false;
111
+ }
112
+ const workspaceId = ctx._workspace?.id ?? auth.workspace?.id ?? null;
113
+ const event = commandEvent({ command, words, exitCode, durationMs, error, version: ctx.version, env: ctx.env, workspaceId });
114
+ const res = await ctx.fetch(`${auth.apiUrl}/api/cli/events`, {
115
+ method: 'POST',
116
+ redirect: 'error',
117
+ signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
118
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${auth.token}`, 'User-Agent': `sprid/${ctx.version}` },
119
+ body: JSON.stringify({ events: [event] }),
120
+ });
121
+ await res.body?.cancel?.().catch?.(() => {});
122
+ return res.ok;
123
+ } catch {
124
+ return false;
125
+ }
126
+ }
127
+
128
+ /** sprid telemetry [status|on|off] */
129
+ export async function telemetry(ctx) {
130
+ const action = ctx.positionals[0] ?? 'status';
131
+ if (action === 'on' || action === 'off') {
132
+ writeSettings({ ...readSettings(ctx.env), enabled: action === 'on', noticedAt: readSettings(ctx.env).noticedAt ?? new Date().toISOString() }, ctx.env);
133
+ } else if (action !== 'status') {
134
+ throw new UsageError(`Unknown telemetry action "${action}". Use status, on or off.`);
135
+ }
136
+ const state = telemetryState(ctx.env);
137
+ if (ctx.json) ctx.out({ enabled: state.enabled, reason: state.reason, settings: settingsPath(ctx.env) });
138
+ else ctx.print(` Telemetry is ${state.enabled ? 'on' : 'off'} (${state.reason}).${state.enabled ? ' Sent: command, exit code, duration, versions, OS. Never arguments or file contents.' : ''}`);
139
+ return 0;
140
+ }