@sprid/cli 1.1.4 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
3
3
  sprid follows semver: a breaking change to a command's arguments, its output
4
4
  shape or its exit codes is a major release.
5
5
 
6
+ ## 1.2.1 - 2026-10-07
7
+
8
+ - Output and command help say a post is scheduled, never booked: `queue cancel` now reports “unscheduled”.
9
+
10
+ ## 1.2.0 - 2026-10-07
11
+
12
+ - Pinterest account-wide analytics and top Pins, board descriptions and section management.
13
+ - Connect Pinterest Ads separately; prepare reviewed paused Pin promotions, open app approval to start, and pause, end or sync campaign results.
14
+ - Pinterest metadata and metrics commands accept UUID identifiers.
15
+
6
16
  ## 1.1.4 - 2026-10-07
7
17
 
8
18
  - `sprid release` carries @sprid/release 1.2.0: `playScreenshots.tablet` uploads a tablet
package/README-post.md CHANGED
@@ -542,7 +542,7 @@ part reports a skip. `sync` re-sends a slice that changed after the draft, and
542
542
 
543
543
  ## `sync`: a re-render, into posts that already exist
544
544
 
545
- **A booked post is never edited. The source is re-rendered and the queue catches
545
+ **A scheduled post is never edited. The source is re-rendered and the queue catches
546
546
  up.** That is the design decision, and the alternative is worse than it sounds:
547
547
  reaching into Sprid to swap a file on a scheduled post makes the queue the
548
548
  source of truth for content, and then a spec and a published post can disagree
@@ -568,7 +568,7 @@ its slot. Three rules, two of them refusals:
568
568
  - **It does the minimum.** Media changed, caption changed, or neither. A slug
569
569
  whose caption moved does not get its video re-uploaded.
570
570
 
571
- Changing media across a booked queue requires reconciling both uploaded media and post metadata.
571
+ Changing media across a scheduled queue requires reconciling both uploaded media and post metadata.
572
572
  `sync` keeps that operation reproducible.
573
573
 
574
574
  **`mediaShaAtPush` is what makes it possible**, and it is the counterpart of
@@ -612,7 +612,7 @@ holding your posts; `doctor` is the command that can tell you which.
612
612
  **The queue lives in Sprid, and until `pull` nothing local could read it.** A
613
613
  post is committed; when it publishes was not. So the repo could not answer "what
614
614
  goes out tomorrow" or "which one went out last" without the API being up, and a
615
- booking made weeks ago left no trace anybody could review in a diff.
615
+ schedule made weeks ago left no trace anybody could review in a diff.
616
616
 
617
617
  `pull` writes each post's status and dates into `sprid.remote` on its manifest,
618
618
  which is what lets `status` print a calendar and work offline.
@@ -661,7 +661,7 @@ instagram` records a post's selection. With no selection, the existing Instagram
661
661
  TikTok and YouTube checks remain the default. This selects validation targets;
662
662
  publishing destinations are still chosen when scheduling in Sprid. Local projection
663
663
  rules cover Instagram, TikTok, YouTube and Pinterest; other platforms report a
664
- skip, and `--deep` reads the booked post’s server projection. Media checks retain
664
+ skip, and `--deep` reads the scheduled post’s server projection. Media checks retain
665
665
  their existing defaults and can be customized with `lane.expect`.
666
666
 
667
667
  `sprid post <command> --json` writes one JSON result to stdout and diagnostics to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprid/cli",
3
- "version": "1.1.4",
3
+ "version": "1.2.1",
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",
@@ -10,6 +10,8 @@ import { confirm } from "./reviews.mjs";
10
10
 
11
11
  const USAGE = [
12
12
  "sprid ads [overview] --account <slug>",
13
+ "sprid ads promotions|promote --account <slug> [--file <promotion.json>]",
14
+ "sprid ads promotion start|pause|end|sync <id> --account <slug>",
13
15
  "sprid ads status|council|metrics <adSetId> --account <slug> [--days N]",
14
16
  "sprid ads create campaign|set|creative|ad|batch --file <args.json> --account <slug>",
15
17
  "sprid ads launch <adSetId> --account <slug>",
@@ -36,6 +38,24 @@ export async function ads(ctx) {
36
38
  show(ctx, await callTool(ctx, "ads_overview", { account }));
37
39
  return 0;
38
40
  }
41
+ if (sub === "promotions") {
42
+ show(ctx, await callTool(ctx, "list_promotions", { account, days: Number(ctx.flags.days ?? 30) }));
43
+ return 0;
44
+ }
45
+ if (sub === "promote") {
46
+ if (typeof ctx.flags.file !== "string") throw new UsageError("sprid ads promote --account <slug> --file <promotion.json>");
47
+ show(ctx, await callTool(ctx, "create_promotion", { account, promotion: await readJson(ctx, ctx.flags.file) }));
48
+ return 0;
49
+ }
50
+ if (sub === "promotion") {
51
+ const id = needId(ctx.positionals[2], "sprid ads promotion start|pause|end|sync <id> --account <slug>");
52
+ const verbs = { start: "start_promotion", pause: "pause_promotion", end: "end_promotion", sync: "sync_promotion" };
53
+ if (!verbs[arg]) throw new UsageError("Choose start, pause, end or sync");
54
+ const result = await callTool(ctx, verbs[arg], { account, id });
55
+ if (arg === "start") return handOff(ctx, result);
56
+ show(ctx, result);
57
+ return 0;
58
+ }
39
59
  if (READS[sub]) {
40
60
  const adSetId = needId(arg, `sprid ads ${sub} <adSetId> --account <slug>`);
41
61
  const days = ctx.flags.days && sub !== "status" ? { days: Number(ctx.flags.days) } : {};
@@ -30,6 +30,8 @@ const AD_CHANNELS = {
30
30
  meta_ads: "meta_ads",
31
31
  "google-ads": "google_ads",
32
32
  google_ads: "google_ads",
33
+ "pinterest-ads": "pinterest_ads",
34
+ pinterest_ads: "pinterest_ads",
33
35
  "tiktok-ads": "tiktok_ads",
34
36
  tiktok_ads: "tiktok_ads",
35
37
  };
@@ -242,7 +244,7 @@ async function connectChannel(ctx, platform) {
242
244
  const adAccountId = ctx.flags["ad-account"];
243
245
  const pixelId = ctx.flags.dataset;
244
246
  if (pageId && platform !== "facebook" && platform !== "meta_ads") throw new UsageError("--page is only supported for Facebook and Meta Ads connections.");
245
- if (adAccountId && !["meta_ads", "google_ads", "tiktok_ads"].includes(platform)) throw new UsageError("--ad-account is only supported for Meta Ads, Google Ads and TikTok Ads connections.");
247
+ if (adAccountId && !["meta_ads", "google_ads", "tiktok_ads", "pinterest_ads"].includes(platform)) throw new UsageError("--ad-account is only supported for Meta Ads, Google Ads, TikTok Ads and Pinterest Ads connections.");
246
248
  if (pixelId && platform !== "meta_ads") throw new UsageError("--dataset is only supported for Meta Ads connections.");
247
249
  const selection = {
248
250
  ...(pageId ? { pageId: String(pageId) } : {}),
@@ -278,7 +280,7 @@ async function connectChannel(ctx, platform) {
278
280
  if (s === "connected") {
279
281
  pending.clear();
280
282
  const handle = !last.channel?.displayName ? label(platform)
281
- : ["meta_ads", "google_ads", "tiktok_ads"].includes(platform) ? last.channel.displayName : `@${last.channel.displayName}`;
283
+ : ["meta_ads", "google_ads", "tiktok_ads", "pinterest_ads"].includes(platform) ? last.channel.displayName : `@${last.channel.displayName}`;
282
284
  if (ctx.json) ctx.out({ ok: true, status: s, channel: last.channel, account });
283
285
  else ctx.print(` ${C.ok(OK)} ${handle} connected to ${account}`);
284
286
  return 0;
@@ -298,7 +300,7 @@ async function connectChannel(ctx, platform) {
298
300
  }
299
301
 
300
302
  function label(platform) {
301
- return { instagram: "Instagram", tiktok: "TikTok", youtube: "YouTube", linkedin: "LinkedIn", facebook: "Facebook", pinterest: "Pinterest", x: "X", meta_ads: "Meta Ads", google_ads: "Google Ads", tiktok_ads: "TikTok Ads", tiktok_accounts: "TikTok comments" }[platform] ?? platform;
303
+ return { instagram: "Instagram", tiktok: "TikTok", youtube: "YouTube", linkedin: "LinkedIn", facebook: "Facebook", pinterest: "Pinterest", x: "X", meta_ads: "Meta Ads", google_ads: "Google Ads", tiktok_ads: "TikTok Ads", pinterest_ads: "Pinterest Ads", tiktok_accounts: "TikTok comments" }[platform] ?? platform;
302
304
  }
303
305
 
304
306
  /** The server names the fields to re-run with; here they are flags. */
@@ -20,7 +20,43 @@ async function channel(ctx) {
20
20
  }
21
21
 
22
22
  export async function pinterest(ctx) {
23
- const [verb, ref] = ctx.positionals;
23
+ const [verb, ref, sectionId] = ctx.positionals;
24
+ if (verb === "overview") {
25
+ const selected = await channel(ctx);
26
+ const days = Number(ctx.flags.days ?? 30);
27
+ if (!Number.isInteger(days) || days < 1 || days > 90) throw new UsageError("--days must be 1 to 90");
28
+ const query = new URLSearchParams({ days: String(days) });
29
+ if (ctx.flags.end) query.set("end", String(ctx.flags.end));
30
+ const result = await ctx.api().get(`/api/channels/${selected.channel.id}/pinterest/analytics?${query}`);
31
+ if (ctx.json) ctx.out(result);
32
+ else {
33
+ ctx.print(` Pinterest account results · ${result.from} to ${result.to} UTC`);
34
+ ctx.print(` Outbound clicks ${result.outboundClicks ?? "unavailable"} · saves ${result.saves ?? "unavailable"} · impressions ${result.impressions ?? "unavailable"}`);
35
+ ctx.print(` Coverage ${result.observedDays}/${result.expectedDays} days`);
36
+ for (const pin of result.topPins ?? []) ctx.print(` ${pin.id} · ${pin.outboundClicks ?? "unavailable"} outbound clicks · ${pin.url}`);
37
+ for (const note of result.notes ?? []) ctx.print(C.dim(` ${note}`));
38
+ }
39
+ return 0;
40
+ }
41
+ if (["update", "section-create", "section-update"].includes(verb)) {
42
+ if (!ref || !/^\d+$/.test(ref)) throw new UsageError("Choose the numeric board id from sprid pinterest boards");
43
+ if (verb === "section-update" && (!sectionId || !/^\d+$/.test(sectionId))) throw new UsageError("Choose a numeric section id to rename");
44
+ const selected = await channel(ctx);
45
+ const path = `/api/channels/${selected.channel.id}/pinterest/boards/${ref}`;
46
+ let body;
47
+ if (verb === "update") {
48
+ body = {};
49
+ if (ctx.flags.name !== undefined) body.name = required(ctx, "name").trim();
50
+ if (ctx.flags.description !== undefined) body.description = String(ctx.flags.description);
51
+ if (!Object.keys(body).length) throw new UsageError("Pass --name or --description for the board");
52
+ } else body = { name: required(ctx, "name").trim() };
53
+ const result = verb === "section-create" ? await ctx.api().post(`${path}/sections`, body)
54
+ : await ctx.api().patch(verb === "update" ? path : `${path}/sections/${sectionId}`, body);
55
+ if (ctx.json) ctx.out(result);
56
+ else ctx.print(` ${C.ok("✓")} Saved ${result.name} (${result.id}).`);
57
+ return 0;
58
+ }
59
+
24
60
  if (verb === "boards") {
25
61
  const selected = await channel(ctx);
26
62
  const bookmark = ctx.flags.bookmark ? `?bookmark=${encodeURIComponent(String(ctx.flags.bookmark))}` : "";
@@ -56,7 +92,7 @@ export async function pinterest(ctx) {
56
92
  }
57
93
 
58
94
  if (verb === "set") {
59
- if (!ref || !/^\d+$/.test(ref)) throw new UsageError("sprid pinterest set <postId> --board <id> --link <url> [metadata]");
95
+ if (!ref || !/^(?:\d+|[0-9a-f]{8}-[0-9a-f-]{27}|[A-Za-z][A-Za-z0-9]{1,4}-[Rr]?\d+)$/i.test(ref)) throw new UsageError("sprid pinterest set <postId> --board <id> --link <url> [metadata]");
60
96
  const post = await ctx.api().get(`/api/posts/${ref}`);
61
97
  const previous = post.pinterestOptions ?? {};
62
98
  const hasFlag = (name) => Object.prototype.hasOwnProperty.call(ctx.flags, name);
@@ -98,7 +134,7 @@ export async function pinterest(ctx) {
98
134
  }
99
135
 
100
136
  if (verb === "metrics") {
101
- if (!ref || !/^\d+$/.test(ref)) throw new UsageError("sprid pinterest metrics <publishId> --account <slug> [--days 30] [--refresh]");
137
+ if (!ref || !/^(?:\d+|[0-9a-f]{8}-[0-9a-f-]{27})$/i.test(ref)) throw new UsageError("sprid pinterest metrics <publishId> --account <slug> [--days 30] [--refresh]");
102
138
  const account = required(ctx, "account");
103
139
  const params = new URLSearchParams({ account, publishId: ref, days: String(ctx.flags.days ?? 30) });
104
140
  if (ctx.flags.refresh) params.set("refresh", "true");
@@ -116,5 +152,5 @@ export async function pinterest(ctx) {
116
152
  return 0;
117
153
  }
118
154
 
119
- throw new UsageError("sprid pinterest boards --account <slug> [--channel <id>] [--bookmark <token>] | create --account <slug> --name <name> [--description <text>] [--privacy public|secret] | set <postId> --board <id> --link <url> | metrics <publishId> --account <slug>");
155
+ throw new UsageError("sprid pinterest boards --account <slug> [--channel <id>] [--bookmark <token>] | overview --account <slug> [--days 30] [--end YYYY-MM-DD] | update <boardId> --name <name> | section-create <boardId> --name <name> | section-update <boardId> <sectionId> --name <name> | create --account <slug> --name <name> [--description <text>] [--privacy public|secret] | set <postId> --board <id> --link <url> | metrics <publishId> --account <slug>");
120
156
  }
@@ -96,7 +96,7 @@ async function render(ctx, post) {
96
96
  if (ctx.json) ctx.out({ quote, render: quote.existingRender, reused: true });
97
97
  else {
98
98
  ctx.print(` ${C.ok(OK)} Already rendered at this version, nothing to pay: ${renderLine(quote.existingRender)}`);
99
- ctx.print(C.dim(` Book it: sprid post schedule ${post} --video-render ${quote.existingRender.id} …`));
99
+ ctx.print(C.dim(` Schedule it: sprid post schedule ${post} --video-render ${quote.existingRender.id} …`));
100
100
  }
101
101
  return 0;
102
102
  }
@@ -138,7 +138,7 @@ async function render(ctx, post) {
138
138
  }
139
139
  ctx.print(` ${C.ok(OK)} ${res.reused ? "Reused" : "Started"} ${renderLine(res)}`);
140
140
  ctx.print(C.dim(` Follow it: sprid post video ${post}`));
141
- if (mode === "final") ctx.print(C.dim(` Book it: sprid post schedule ${post} --video-render ${res.id} …`));
142
- else ctx.print(C.dim(" A preview is for review. Bookings publish a --final render."));
141
+ if (mode === "final") ctx.print(C.dim(` Schedule it: sprid post schedule ${post} --video-render ${res.id} …`));
142
+ else ctx.print(C.dim(" A preview is for review. Scheduled posts publish a --final render."));
143
143
  return 0;
144
144
  }
@@ -198,7 +198,7 @@ export async function queue(ctx) {
198
198
  const id = needId(arg, "sprid queue cancel <publishId>");
199
199
  await ctx.api().del(`/api/publishes/${id}`);
200
200
  if (ctx.json) ctx.out({ ok: true, publishId: id });
201
- else ctx.print(` ${C.ok(OK)} Booking ${id} cancelled. The post stays as it is.`);
201
+ else ctx.print(` ${C.ok(OK)} ${id} unscheduled. The post stays as it is.`);
202
202
  return 0;
203
203
  }
204
204
  if (sub) throw usage("sprid queue [retry|cancel <publishId>]");
@@ -34,7 +34,7 @@ export const COMMAND_GROUPS = [
34
34
  ['post', 'sprid post preview --id <postId>', 'Preview a shared post. Without --id, historical local preview arguments are unchanged.'],
35
35
  ['post', 'sprid post deliveries <postId>', 'Read scheduled, failed and delivered publications with platform receipts.'],
36
36
  ['post', 'sprid post video <postId>', 'Read the post\'s own video, compiled from its slides: its revision and hash, anything that stops it rendering, each slide\'s start, end and whether its length is pinned, which outputs are on, and the renders of this exact version. Edit it with sprid tool call edit_post_video.'],
37
- ['post', 'sprid post video render <postId> [--confirm] [--ceiling N] [--final]', 'Quote a render of the video as it is now, then render it with --confirm, spending at most --ceiling credits (default 30). A render of the same version is returned without charge. Bookings publish a --final render; pass its id to sprid post schedule --video-render.'],
37
+ ['post', 'sprid post video render <postId> [--confirm] [--ceiling N] [--final]', 'Quote a render of the video as it is now, then render it with --confirm, spending at most --ceiling credits (default 30). A render of the same version is returned without charge. Scheduled posts publish a --final render; pass its id to sprid post schedule --video-render.'],
38
38
  ['post', 'sprid post handoff <postId>', 'Print the prompt to paste into an agent: what the post currently is, which verbs to call, and an empty line for the ask. Same string the editor\'s "Hand off to agent" button copies.'],
39
39
  ] },
40
40
  { title: 'Installation and updates', entries: [
@@ -77,6 +77,10 @@ export const COMMAND_GROUPS = [
77
77
  ['connect', 'sprid connect tiktok-comments --account <slug>', 'Let the Inbox read and answer comments on your TikTok posts. Sign in with the TikTok account that publishes them; this is separate from TikTok publishing and from TikTok Ads.'],
78
78
  ['connect', 'sprid connect github --app <slug> --key-from-clipboard --repo <owner/repo>[,<owner/repo>]', 'Read a repository’s views, clones, referrers and stars. GitHub keeps 14 days of traffic, so Sprid copies it daily and the history starts the day you connect. --repo replaces the list, so name every repo.'],
79
79
  ['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.'],
80
+ ['pinterest', 'sprid pinterest overview --account <slug> [--channel <id>] [--days N] [--end YYYY-MM-DD]', 'Read live organic account analytics and top Pins, including Pins outside Sprid. End is inclusive and must be a completed UTC day.'],
81
+ ['pinterest', 'sprid pinterest update <boardId> --account <slug> [--name <name>] [--description <text>]', 'Rename a board or change its description on the exact selected Pinterest channel.'],
82
+ ['pinterest', 'sprid pinterest section-create <boardId> --account <slug> --name <name>', 'Create a board section.'],
83
+ ['pinterest', 'sprid pinterest section-update <boardId> <sectionId> --account <slug> --name <name>', 'Rename a section on its board.'],
80
84
  ['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.'],
81
85
  ['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.'],
82
86
  ['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.'],
@@ -102,9 +106,9 @@ export const COMMAND_GROUPS = [
102
106
  ['marketing-review', 'sprid marketing-review query --app <slug> --query-file <query.sql>', 'Run a read-only PostHog query through the app’s stored connection.'],
103
107
  ['queue', 'sprid queue', 'Show what is scheduled and what failed.'],
104
108
  ['queue', 'sprid queue retry <publishId>', 'Retry a failed or missed publish.'],
105
- ['queue', 'sprid queue cancel <publishId>', 'Cancel a booking. The post itself is unchanged and can be scheduled again.'],
106
- ['post', 'sprid post schedule <postId> (--at ISO|--next|--queue-end) [--platforms a,b] [--time HH:MM] [--shift-later] [--options <options.json>] [--video-render <renderId>]', 'Book a post: at a time, the next free slot, or after everything already booked. --shift-later moves later bookings down a slot. A destination that goes out as the post\'s video needs --video-render, a final render of the video as it is now. TikTok goes as a draft to the TikTok inbox: {"tiktok": {"postMode": "MEDIA_UPLOAD"}} in --options. A direct TikTok post is answered and confirmed by the person on the review link the refusal prints. A Pinterest Pin needs its board and destination link saved first with sprid pinterest set.'],
107
- ['post', 'sprid post schedule-batch --account <slug> --ids SPR-9,SPR-10 [--cadence daily|every-other-day|weekdays] [--times 09:00] [--start YYYY-MM-DD] [--platforms a,b] [--skip-busy] [--dry-run]', 'Place a list of posts on a cadence in one call. --dry-run returns the plan without booking anything.'],
109
+ ['queue', 'sprid queue cancel <publishId>', 'Unschedule one destination. The post itself is unchanged and can be scheduled again.'],
110
+ ['post', 'sprid post schedule <postId> (--at ISO|--next|--queue-end) [--platforms a,b] [--time HH:MM] [--shift-later] [--options <options.json>] [--video-render <renderId>]', 'Schedule a post: at a time, the next free slot, or after everything already scheduled. --shift-later moves later scheduled posts down a slot. A destination that goes out as the post\'s video needs --video-render, a final render of the video as it is now. TikTok goes as a draft to the TikTok inbox: {"tiktok": {"postMode": "MEDIA_UPLOAD"}} in --options. A direct TikTok post is answered and confirmed by the person on the review link the refusal prints. A Pinterest Pin needs its board and destination link saved first with sprid pinterest set.'],
111
+ ['post', 'sprid post schedule-batch --account <slug> --ids SPR-9,SPR-10 [--cadence daily|every-other-day|weekdays] [--times 09:00] [--start YYYY-MM-DD] [--platforms a,b] [--skip-busy] [--dry-run]', 'Place a list of posts on a cadence in one call. --dry-run returns the plan without scheduling anything.'],
108
112
  ['post', 'sprid post publish <postId> [--platforms a,b] [--options <options.json>] [--video-render <renderId>] [--yes]', 'Publish now. Public and not reversible, so it asks unless --yes. Read the receipts with sprid post deliveries.'],
109
113
  ['inbox', 'sprid inbox [--view needs_you|all|done] [--kind all|comment|review] [--accounts a,b] [--app <slug>] [--intent <intent>] [--limit N]', 'Comments on posts and ads plus store reviews, as one ranked queue.'],
110
114
  ['inbox', 'sprid inbox draft <commentId> [--count N]', 'Draft replies in the account’s reply voice. Nothing is sent.'],
@@ -115,6 +119,9 @@ export const COMMAND_GROUPS = [
115
119
  ['reviews', 'sprid reviews reply <id> [--text "…"] [--yes]', 'Review and send a public reply. --yes skips the confirmation.'],
116
120
  ] },
117
121
  { title: 'Paid ads', entries: [
122
+ ['ads', 'sprid ads promotions --account <slug> [--days N]', 'List promotions across connected advertising networks.'],
123
+ ['ads', 'sprid ads promote --account <slug> --file <promotion.json>', 'Prepare a paused promotion. File carries requestKey, publishId, name, currency, dailyBudgetCents, durationDays, countries and specialAdCategories. Pinterest also requires maxBidCents.'],
124
+ ['ads', 'sprid ads promotion start|pause|end|sync <id> --account <slug>', 'Start opens the in-app spending approval. Pause and end stop delivery; sync reads campaign results.'],
118
125
  ['ads', 'sprid ads [overview] --account <slug>', 'Spend against the cap, what is live and what the connected Meta Ads channel can do.'],
119
126
  ['ads', 'sprid ads status|council|metrics <adSetId> --account <slug> [--days N]', 'Delivery and rejections from Meta, the weekly judgement, or the numbers for one ad set.'],
120
127
  ['ads', 'sprid ads create campaign|set|creative|ad|batch --file <args.json> --account <slug>', 'Create paused objects. The file holds the tool’s arguments; sprid tool describe create_ad_set shows them.'],
@@ -43,7 +43,8 @@ export const GUIDE_GROUPS = [
43
43
  "slugs": [
44
44
  "meta-ads",
45
45
  "google-ads",
46
- "tiktok-ads"
46
+ "tiktok-ads",
47
+ "pinterest-ads"
47
48
  ]
48
49
  }
49
50
  ];
@@ -54,6 +55,7 @@ export const GUIDE_ALIASES = {
54
55
  "ga4": "google-analytics",
55
56
  "ga": "google-analytics",
56
57
  "google-analytics-4": "google-analytics",
58
+ "pinterest_ads": "pinterest-ads",
57
59
  "meta_ads": "meta-ads",
58
60
  "meta": "meta-ads",
59
61
  "google_ads": "google-ads",
@@ -1078,7 +1080,7 @@ export const GUIDES = [
1078
1080
  "id": "before-publishing",
1079
1081
  "title": "Before publishing",
1080
1082
  "kind": "detail",
1081
- "markdown": "TikTok 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."
1083
+ "markdown": "TikTok 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 post keeps its original choices."
1082
1084
  },
1083
1085
  {
1084
1086
  "id": "if-it-fails",
@@ -1099,8 +1101,8 @@ export const GUIDES = [
1099
1101
  "markdown": "- [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)"
1100
1102
  }
1101
1103
  ],
1102
- "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",
1103
- "revision": "710f233b9a644bb3"
1104
+ "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 post 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",
1105
+ "revision": "c8c6bf6eafb18eda"
1104
1106
  },
1105
1107
  {
1106
1108
  "id": "tiktok-comments",
@@ -1376,7 +1378,7 @@ export const GUIDES = [
1376
1378
  {
1377
1379
  "id": "pinterest",
1378
1380
  "title": "Pinterest",
1379
- "summary": "Publish the image and video Pins you select to the right boards, keep their destination links intact and read their daily results.",
1381
+ "summary": "Publish the image and video Pins you select to the right boards, keep their destination links intact and read their daily and account-wide results. Rename boards, edit descriptions and create or rename sections.",
1380
1382
  "command": "sprid connect pinterest --account <slug>",
1381
1383
  "url": "https://sprid.studio/docs/connect/pinterest",
1382
1384
  "sections": [
@@ -1427,10 +1429,16 @@ export const GUIDES = [
1427
1429
  "title": "Sources",
1428
1430
  "kind": "sources",
1429
1431
  "markdown": "- [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)"
1432
+ },
1433
+ {
1434
+ "id": "account-wide-results-and-board-management",
1435
+ "title": "Account-wide results and board management",
1436
+ "kind": "detail",
1437
+ "markdown": "Open **Insights → From social → Pinterest** to read organic results for the entire connected Pinterest account, including Pins created outside Sprid. The window uses completed UTC days and exposes missing coverage. Do not add account totals to per-post totals. Account analytics are read live without server retention.\n\n```sh\nsprid pinterest overview --account <slug> --days 30\nsprid pinterest update <boardId> --account <slug> --name \"Useful guides\" --description \"Searchable guides\"\nsprid pinterest section-create <boardId> --account <slug> --name \"Getting started\"\nsprid pinterest section-update <boardId> <sectionId> --account <slug> --name \"First steps\"\n```\n\nIn a Pin’s board picker, **Manage board and sections** opens the same operations. Creating a section selects it as that Pin’s destination. Public and secret boards require their respective write scopes; reconnect if the grant predates them.\n\nPinterest ads use a separate [Pinterest Ads connection](https://sprid.studio/docs/connect/pinterest-ads). Connecting ordinary Pinterest does not request advertising permissions."
1430
1438
  }
1431
1439
  ],
1432
- "markdown": "# Pinterest\n\n**What Sprid does with this:** Publish the image and video Pins you select to the right boards, keep their destination links intact and read their daily results.\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## 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- **Sprid says Pinterest is unavailable:** Pinterest is not enabled on the Sprid server you are using. Contact [Sprid support](mailto:hello@sprid.studio); 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 publish public Pins”:** the saved connection cannot publish. Reconnect once with the same Pinterest account.\n- **Analytics unavailable:** use a business account and check the Pin is public. Unavailable data is unavailable, not zero.\n\n## Investigate with this connection\n\nOperation `posts`, read from Sprid’s stored Pin publishes and collected daily Pin metrics: impressions, saves, Pin clicks, outbound clicks and video views, summed up to the exclusive end with the days actually observed. There is no `comments` operation: Sprid does not collect Pinterest comments. Compare Pins at equal ages, such as 30, 60 and 90 days. An outbound click is someone leaving Pinterest, not a website session or an install. Live results for one Pin come from `get_pinterest_metrics` (MCP) or `GET /api/metrics/pinterest`.\n\n```sh\nsprid marketing-review capabilities --app <slug> --source pinterest --json\n```\n\nSee [connected queries](https://sprid.studio/docs/queries).\n\n## Sources\n\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",
1433
- "revision": "7adfb46a92aa6042"
1440
+ "markdown": "# Pinterest\n\n**What Sprid does with this:** Publish the image and video Pins you select to the right boards, keep their destination links intact and read their daily and account-wide results. Rename boards, edit descriptions and create or rename sections.\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## 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- **Sprid says Pinterest is unavailable:** Pinterest is not enabled on the Sprid server you are using. Contact [Sprid support](mailto:hello@sprid.studio); 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 publish public Pins”:** the saved connection cannot publish. Reconnect once with the same Pinterest account.\n- **Analytics unavailable:** use a business account and check the Pin is public. Unavailable data is unavailable, not zero.\n\n## Investigate with this connection\n\nOperation `posts`, read from Sprid’s stored Pin publishes and collected daily Pin metrics: impressions, saves, Pin clicks, outbound clicks and video views, summed up to the exclusive end with the days actually observed. There is no `comments` operation: Sprid does not collect Pinterest comments. Compare Pins at equal ages, such as 30, 60 and 90 days. An outbound click is someone leaving Pinterest, not a website session or an install. Live results for one Pin come from `get_pinterest_metrics` (MCP) or `GET /api/metrics/pinterest`.\n\n```sh\nsprid marketing-review capabilities --app <slug> --source pinterest --json\n```\n\nSee [connected queries](https://sprid.studio/docs/queries).\n\n## Sources\n\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\n## Account-wide results and board management\n\nOpen **Insights → From social → Pinterest** to read organic results for the entire connected Pinterest account, including Pins created outside Sprid. The window uses completed UTC days and exposes missing coverage. Do not add account totals to per-post totals. Account analytics are read live without server retention.\n\n```sh\nsprid pinterest overview --account <slug> --days 30\nsprid pinterest update <boardId> --account <slug> --name \"Useful guides\" --description \"Searchable guides\"\nsprid pinterest section-create <boardId> --account <slug> --name \"Getting started\"\nsprid pinterest section-update <boardId> <sectionId> --account <slug> --name \"First steps\"\n```\n\nIn a Pin’s board picker, **Manage board and sections** opens the same operations. Creating a section selects it as that Pin’s destination. Public and secret boards require their respective write scopes; reconnect if the grant predates them.\n\nPinterest ads use a separate [Pinterest Ads connection](https://sprid.studio/docs/connect/pinterest-ads). Connecting ordinary Pinterest does not request advertising permissions.\n",
1441
+ "revision": "8ec74ac68439d71a"
1434
1442
  },
1435
1443
  {
1436
1444
  "id": "meta-ads",
@@ -1602,6 +1610,53 @@ export const GUIDES = [
1602
1610
  ],
1603
1611
  "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",
1604
1612
  "revision": "41a9c611fbe927bd"
1613
+ },
1614
+ {
1615
+ "id": "pinterest-ads",
1616
+ "title": "Pinterest Ads",
1617
+ "summary": "Promote a public image or video Pin through your Pinterest ad account, prepare it paused, and read its campaign spend and attributed checkouts. Only your confirmation in the Sprid app starts spending.",
1618
+ "command": "sprid connect pinterest-ads --account <slug>",
1619
+ "url": "https://sprid.studio/docs/connect/pinterest-ads",
1620
+ "sections": [
1621
+ {
1622
+ "id": "you-need",
1623
+ "title": "You need",
1624
+ "kind": "requirements",
1625
+ "markdown": "- A Pinterest business account with an advertising account you can manage, set up in Pinterest Ads Manager.\n- A public Pin published through the ordinary Pinterest connection on the same Sprid account. Its owner must match the advertising account’s owner.\n- A reviewed daily budget, maximum cost per click, run length and audience countries. Sprid supports advertising currencies with two decimal places."
1626
+ },
1627
+ {
1628
+ "id": "then-run",
1629
+ "title": "Then run",
1630
+ "kind": "command",
1631
+ "markdown": "```sh\nsprid connect pinterest-ads --account <slug>\n```\n\nOr open **Settings → Accounts → [account] → Ads → Pinterest Ads**."
1632
+ },
1633
+ {
1634
+ "id": "click-path",
1635
+ "title": "Click path",
1636
+ "kind": "steps",
1637
+ "markdown": "1. Sign in to the Pinterest login that manages your advertising account.\n2. Authorize reading your profile, Pins and boards, and reading and managing ads. Ordinary publishing remains a separate connection.\n3. If Pinterest returns several manageable ad accounts, select the one that pays for this brand. Sprid never chooses one for you.\n4. In Sprid, open a published Pin and choose **Promote**. Set audience countries, daily budget, maximum cost per click and run length. Declare any regulated category; these campaigns must be run in Pinterest Ads Manager instead.\n5. Save a paused draft, review the amounts, then confirm Start in the app. Agents and tokens can prepare or pause, but cannot start or increase spending.\n\nSprid uses consideration campaigns with a fixed daily campaign budget, manual maximum-CPC bidding, adults-only targeting and a campaign end date. Run length counts UTC days including the creation day and ends at midnight UTC, so the reviewed total cannot span an extra daily budget. Pause stops the parent campaign before Sprid records it as paused. Changing budget or end date also requires app approval."
1638
+ },
1639
+ {
1640
+ "id": "check-the-connection",
1641
+ "title": "Check the connection",
1642
+ "kind": "verify",
1643
+ "markdown": "Confirm the advertiser name and id in Settings. Check the reviewed Pin is public and owned by that advertiser before preparing it. Sprid checks identity again at creation and rejects historical test Pins.\n\nCampaign reports cover spend, paid impressions, paid Pin clicks, paid outbound clicks and Pinterest-attributed checkouts, with a 30-day click and 1-day view window, reported on conversion date. Earned delivery is excluded. Pin clicks reach content on or off Pinterest; outbound clicks leave Pinterest. Missing metrics stay unavailable. Checkouts are provider-attributed actions, not verified new customers; Pinterest needs your conversion tracking configured to report them. Campaign and child totals overlap and must never be added."
1644
+ },
1645
+ {
1646
+ "id": "if-it-fails",
1647
+ "title": "If it fails",
1648
+ "kind": "troubleshooting",
1649
+ "markdown": "- **No manageable ad accounts:** set up an advertiser in Pinterest Ads Manager or use a login with Owner, Admin or Campaign Manager access.\n- **Choose the account that owns the Pin:** ordinary Pinterest and Pinterest Ads must refer to the same Pin owner. A public Pin on a different profile cannot be promoted by this advertiser.\n- **Reconnect:** the grant expired or lacks `ads:read` / `ads:write`. Connect Pinterest Ads again; ordinary Pinterest consent does not add these permissions.\n- **Setup interrupted:** check Pinterest Ads Manager before retrying. Pinterest creation has no idempotency key; an interrupted request may have created a paused entity. Sprid keeps an uncertain setup for review rather than silently retrying it.\n- **Billing refused:** add or fix payment details in Pinterest Ads Manager. Sprid does not request billing permissions or manage your payment methods."
1650
+ },
1651
+ {
1652
+ "id": "sources",
1653
+ "title": "Sources",
1654
+ "kind": "sources",
1655
+ "markdown": "[Pinterest campaign and ad-group API](https://developer.pinterest.com/docs/work-with-ads/create-campaigns-and-ad-groups/) and [Pinterest API description](https://github.com/pinterest/api-description/blob/main/v5/openapi.json)."
1656
+ }
1657
+ ],
1658
+ "markdown": "# Pinterest Ads\n\n**What Sprid does with this:** Promote a public image or video Pin through your Pinterest ad account, prepare it paused, and read its campaign spend and attributed checkouts. Only your confirmation in the Sprid app starts spending.\n\n## You need\n\n- A Pinterest business account with an advertising account you can manage, set up in Pinterest Ads Manager.\n- A public Pin published through the ordinary Pinterest connection on the same Sprid account. Its owner must match the advertising account’s owner.\n- A reviewed daily budget, maximum cost per click, run length and audience countries. Sprid supports advertising currencies with two decimal places.\n\n## Then run\n\n```sh\nsprid connect pinterest-ads --account <slug>\n```\n\nOr open **Settings → Accounts → [account] → Ads → Pinterest Ads**.\n\n## Click path\n\n1. Sign in to the Pinterest login that manages your advertising account.\n2. Authorize reading your profile, Pins and boards, and reading and managing ads. Ordinary publishing remains a separate connection.\n3. If Pinterest returns several manageable ad accounts, select the one that pays for this brand. Sprid never chooses one for you.\n4. In Sprid, open a published Pin and choose **Promote**. Set audience countries, daily budget, maximum cost per click and run length. Declare any regulated category; these campaigns must be run in Pinterest Ads Manager instead.\n5. Save a paused draft, review the amounts, then confirm Start in the app. Agents and tokens can prepare or pause, but cannot start or increase spending.\n\nSprid uses consideration campaigns with a fixed daily campaign budget, manual maximum-CPC bidding, adults-only targeting and a campaign end date. Run length counts UTC days including the creation day and ends at midnight UTC, so the reviewed total cannot span an extra daily budget. Pause stops the parent campaign before Sprid records it as paused. Changing budget or end date also requires app approval.\n\n## Check the connection\n\nConfirm the advertiser name and id in Settings. Check the reviewed Pin is public and owned by that advertiser before preparing it. Sprid checks identity again at creation and rejects historical test Pins.\n\nCampaign reports cover spend, paid impressions, paid Pin clicks, paid outbound clicks and Pinterest-attributed checkouts, with a 30-day click and 1-day view window, reported on conversion date. Earned delivery is excluded. Pin clicks reach content on or off Pinterest; outbound clicks leave Pinterest. Missing metrics stay unavailable. Checkouts are provider-attributed actions, not verified new customers; Pinterest needs your conversion tracking configured to report them. Campaign and child totals overlap and must never be added.\n\n## If it fails\n\n- **No manageable ad accounts:** set up an advertiser in Pinterest Ads Manager or use a login with Owner, Admin or Campaign Manager access.\n- **Choose the account that owns the Pin:** ordinary Pinterest and Pinterest Ads must refer to the same Pin owner. A public Pin on a different profile cannot be promoted by this advertiser.\n- **Reconnect:** the grant expired or lacks `ads:read` / `ads:write`. Connect Pinterest Ads again; ordinary Pinterest consent does not add these permissions.\n- **Setup interrupted:** check Pinterest Ads Manager before retrying. Pinterest creation has no idempotency key; an interrupted request may have created a paused entity. Sprid keeps an uncertain setup for review rather than silently retrying it.\n- **Billing refused:** add or fix payment details in Pinterest Ads Manager. Sprid does not request billing permissions or manage your payment methods.\n\n## Sources\n\n[Pinterest campaign and ad-group API](https://developer.pinterest.com/docs/work-with-ads/create-campaigns-and-ad-groups/) and [Pinterest API description](https://github.com/pinterest/api-description/blob/main/v5/openapi.json).\n",
1659
+ "revision": "f49777262b1c62e4"
1605
1660
  }
1606
1661
  ];
1607
1662
  export const CONTENT_GUIDES = [
@@ -1610,8 +1665,8 @@ export const CONTENT_GUIDES = [
1610
1665
  "title": "Use Sprid in chat",
1611
1666
  "summary": "Take an app from setup to a reviewed post and confirmed delivery using only your chat app and a browser. No CLI or installed skills needed.",
1612
1667
  "url": "https://sprid.studio/docs/chat",
1613
- "markdown": "# Use Sprid in chat\n\n**What this guide does:** Take an app from setup to a reviewed post and confirmed delivery using only your chat app and a browser. No CLI or installed skills needed.\n\nYou tell the agent what you want; it handles the tool calls below. The tool names are here so the agent knows the order. Setup should only ask for the connection the current task needs.\n\n## Connect Sprid\n\nChoose your chat at `https://sprid.studio/start#chat` and follow its steps. Your chat may call this an app, connector or integration. The address is `https://api.sprid.studio/api/mcp?surface=core`. Sign in to Sprid and authorize the right workspace.\n\nAgent: when available, start with `get_marketing_plan` and reuse its prepared work and next step rather than starting over. Discovery can start from a website or store URL, with no social account.\n\n## Pick the app and account\n\nAgent: call `get_capabilities`, then `list_apps` and `list_accounts`, and reuse what exists. If the app is missing, get its description or website/store URL, confirm it and create it with `upsert_app_profile`. A content account owns voice and media; a channel is one exact social destination. Never pick a handle just because it is first in a list. A missing account is created in the dashboard.\n\n## Connect a destination or data source\n\n- **Social channel:** `get_documentation` with the platform name, then `connect_channel`. The person opens the returned link and approves in the browser; `channel_status` confirms the saved result.\n- **Store or analytics:** the person enters identifiers and the key in the secure form at `https://app.sprid.studio/settings/app-profiles`. Credentials never go in chat, tool arguments or reports. A saved-key indicator only proves configuration; `get_marketing_review` does a live read.\n\n## Bring images or a finished video\n\nDraft copy in the chat, in the account’s voice. No extra model key needed.\n\nIf the chat exposes the attached files, pass them to `import_assets`: each file has `download_url` and `file_id`, optionally `mime_type` and `file_name`. Public HTTPS URLs and existing `{kind, id}` library references also work. Limits: PNG, JPEG, WebP or finished MP4, up to 32 MiB each. Each position reports on its own; retry the missing ones before building the post. Files are copied to the account and deduplicated by bytes; temporary download URLs are not kept.\n\nA `sandbox:` URL or local path can’t be fetched. For an image the chat generated, use the existing file if the host exposes it (not verified for every host); otherwise download it and use the browser upload. Never ask the agent to recreate an image or transcribe binary data.\n\n**Fallback: browser upload.** Call `create_upload_session` with the account and a new UUID `requestId`. The person opens the link and picks the files. Then read `get_studio_job` with the returned ID; its result keys are zero-based positions. The same session and asset IDs work from any other chat. No image-generation charge.\n\n## Create and review the post\n\nCall `create_post` with a new UUID `requestId`, the account, ordered `{kind, id}` assets, captions and aspect ratio. Reuse the same ID and inputs if a response is lost.\n\n- `presentation: \"finished\"` keeps artwork as-is: no text overlay, no added music. Images fit inside the canvas, which can add margins.\n- `presentation: \"editable\"` allows `text` and `subtitle` on image slides.\n- One finished video becomes one reel. Never mix a video with image slides.\n\nEdit with `update_post` (captions) and `update_slides` (slides). Call `preview_post` and check every slide or the video: captions, order, crop, legibility. It returns a small image of each slide for the agent to look at and an embedded preview for the person; a chat without embedded apps still shows the images. A preview is not approval to publish.\n\nAgent: show the person the preview, and link every post you mention with the `links` the tools return, as a Markdown link on its ref or title: `links.review` when the ask is to approve or schedule, `links.editor` when it is to change something. Never draw the slides in your own widget or artifact: those sandboxes block Sprid's media host and the images show broken.\n\n## Make a reel from photos or text\n\nCheck `get_capabilities` for hosted creation first. `create_reel` takes ordered scenes (`imageId`, `text`, `durationMs`), up to 120 seconds, and makes a silent MP4. It costs 3 Sprid credits per started render minute, or uses the plan allowance: check `spend_status`, tell the person, and get their go-ahead before sending `confirmed: true`. Save the job ID and read `get_studio_job`. Retrying with the same ID never starts a second render; a stopped job reports `needs_attention` instead of charging again.\n\nTo turn an existing post's slides into a video instead, use its own video: `get_video_capabilities`, then `get_post_video` and `edit_post_video` for transitions and motion, and `update_post {outputs}` for which destinations get it. Rendering it is metered the same way: `quote_post_video_render`, the person's go-ahead, then `render_post_video` with `confirmed: true`. Book a video destination with the render's `videoRenderId`.\n\nApp captures, simulator recordings and your own build scripts run outside remote MCP. Upload their finished files.\n\n## Approve and confirm delivery\n\nPublishing and scheduling require the person's approval of the preview and intended destinations. The browser review link remains available for picking channels, time and platform options.\n\nTikTok requires an explicit `postMode`; nothing defaults to a direct post or a draft:\n\n- `MEDIA_UPLOAD` sends the media to the creator's TikTok inbox. They finish the post in TikTok; report it as a draft.\n- `DIRECT_POST` requires `privacyLevel`, `disableComment`, `disableDuet`, `disableStitch`, `yourBrandToggle`, `brandedContentToggle` and `confirmed: true` in `options.tiktok`. Fetch fresh choices with `list_connections {includeTikTokCreatorInfo: true}` and show the creator identity, media and caption. The person chooses every setting for this post. Photo posts use `disableDuet: true` and `disableStitch: true`; comments respect the creator's restriction. Branded content cannot use `SELF_ONLY`.\n\nBefore sending `confirmed: true`, get the person's approval of this exact post and their agreement to TikTok's [Music Usage Confirmation](https://www.tiktok.com/legal/page/global/music-usage-confirmation/en), plus the [Branded Content Policy](https://www.tiktok.com/legal/page/global/bc-policy/en) for branded content. That flag is a caller's attestation, not proof of a click in TikTok's prescribed UI. Never invent choices or carry usual preferences into a new post. If choices are missing, open the returned `approvalUrl` for the person to answer in Sprid.\n\nAn approval covers the version the person saw. If you edit a booked direct post afterwards, the edit's result carries `tiktokApproval`: the booking will not publish as it is. Show the person the change and the settings in its `rebook` call, ask whether to post this version, and only after they agree call `rebook` with `options.tiktok.confirmed: true` added. `post_queue` returns the same call while the approval is outstanding.\n\nTikTok's published guidelines specify a privacy dropdown and posting controls. Acceptance of this chat-based confirmation flow has not been verified with TikTok. Keep that distinction explicit in review submissions.\n\nAfter approval, read `post_queue`. Keep draft, scheduled, failed and delivered apart, and report delivery only with the platform receipt. Read a failure before retrying. Never repeat a publish call just because a response was lost.\n\nFor results, read `get_documentation` for `marketing-review`. Keep the same app, account and post IDs across clients. `next_actions` gives the next step; an empty `verb` means follow its browser link.\n\n## Limits and troubleshooting\n\n- **Attachment can’t be read:** use the browser upload, not the CLI.\n- **Missing account or service:** set it up in the dashboard. Never paste credentials into chat.\n- **Hosted creation:** check `get_capabilities` before promising it. Store screenshots and store releases are local unless it reports otherwise.\n- **File transfer:** whether a host exposes uploaded or generated files has to be checked per host. A supported schema doesn’t prove it.\n",
1614
- "revision": "d01ea35e75ec5ecd"
1668
+ "markdown": "# Use Sprid in chat\n\n**What this guide does:** Take an app from setup to a reviewed post and confirmed delivery using only your chat app and a browser. No CLI or installed skills needed.\n\nYou tell the agent what you want; it handles the tool calls below. The tool names are here so the agent knows the order. Setup should only ask for the connection the current task needs.\n\n## Connect Sprid\n\nChoose your chat at `https://sprid.studio/start#chat` and follow its steps. Your chat may call this an app, connector or integration. The address is `https://api.sprid.studio/api/mcp?surface=core`. Sign in to Sprid and authorize the right workspace.\n\nAgent: when available, start with `get_marketing_plan` and reuse its prepared work and next step rather than starting over. Discovery can start from a website or store URL, with no social account.\n\n## Pick the app and account\n\nAgent: call `get_capabilities`, then `list_apps` and `list_accounts`, and reuse what exists. If the app is missing, get its description or website/store URL, confirm it and create it with `upsert_app_profile`. A content account owns voice and media; a channel is one exact social destination. Never pick a handle just because it is first in a list. A missing account is created in the dashboard.\n\n## Connect a destination or data source\n\n- **Social channel:** `get_documentation` with the platform name, then `connect_channel`. The person opens the returned link and approves in the browser; `channel_status` confirms the saved result.\n- **Store or analytics:** the person enters identifiers and the key in the secure form at `https://app.sprid.studio/settings/app-profiles`. Credentials never go in chat, tool arguments or reports. A saved-key indicator only proves configuration; `get_marketing_review` does a live read.\n\n## Bring images or a finished video\n\nDraft copy in the chat, in the account’s voice. No extra model key needed.\n\nIf the chat exposes the attached files, pass them to `import_assets`: each file has `download_url` and `file_id`, optionally `mime_type` and `file_name`. Public HTTPS URLs and existing `{kind, id}` library references also work. Limits: PNG, JPEG, WebP or finished MP4, up to 32 MiB each. Each position reports on its own; retry the missing ones before building the post. Files are copied to the account and deduplicated by bytes; temporary download URLs are not kept.\n\nA `sandbox:` URL or local path can’t be fetched. For an image the chat generated, use the existing file if the host exposes it (not verified for every host); otherwise download it and use the browser upload. Never ask the agent to recreate an image or transcribe binary data.\n\n**Fallback: browser upload.** Call `create_upload_session` with the account and a new UUID `requestId`. The person opens the link and picks the files. Then read `get_studio_job` with the returned ID; its result keys are zero-based positions. The same session and asset IDs work from any other chat. No image-generation charge.\n\n## Create and review the post\n\nCall `create_post` with a new UUID `requestId`, the account, ordered `{kind, id}` assets, captions and aspect ratio. Reuse the same ID and inputs if a response is lost.\n\n- `presentation: \"finished\"` keeps artwork as-is: no text overlay, no added music. Images fit inside the canvas, which can add margins.\n- `presentation: \"editable\"` allows `text` and `subtitle` on image slides.\n- One finished video becomes one reel. Never mix a video with image slides.\n\nEdit with `update_post` (captions) and `update_slides` (slides). Call `preview_post` and check every slide or the video: captions, order, crop, legibility. It returns a small image of each slide for the agent to look at and an embedded preview for the person; a chat without embedded apps still shows the images. A preview is not approval to publish.\n\nAgent: show the person the preview, and link every post you mention with the `links` the tools return, as a Markdown link on its ref or title: `links.review` when the ask is to approve or schedule, `links.editor` when it is to change something. Never draw the slides in your own widget or artifact: those sandboxes block Sprid's media host and the images show broken.\n\n## Make a reel from photos or text\n\nCheck `get_capabilities` for hosted creation first. `create_reel` takes ordered scenes (`imageId`, `text`, `durationMs`), up to 120 seconds, and makes a silent MP4. It costs 3 Sprid credits per started render minute, or uses the plan allowance: check `spend_status`, tell the person, and get their go-ahead before sending `confirmed: true`. Save the job ID and read `get_studio_job`. Retrying with the same ID never starts a second render; a stopped job reports `needs_attention` instead of charging again.\n\nTo turn an existing post's slides into a video instead, use its own video: `get_video_capabilities`, then `get_post_video` and `edit_post_video` for transitions and motion, and `update_post {outputs}` for which destinations get it. Rendering it is metered the same way: `quote_post_video_render`, the person's go-ahead, then `render_post_video` with `confirmed: true`. Schedule a video destination with the render's `videoRenderId`.\n\nApp captures, simulator recordings and your own build scripts run outside remote MCP. Upload their finished files.\n\n## Approve and confirm delivery\n\nPublishing and scheduling require the person's approval of the preview and intended destinations. The browser review link remains available for picking channels, time and platform options.\n\nTikTok requires an explicit `postMode`; nothing defaults to a direct post or a draft:\n\n- `MEDIA_UPLOAD` sends the media to the creator's TikTok inbox. They finish the post in TikTok; report it as a draft.\n- `DIRECT_POST` requires `privacyLevel`, `disableComment`, `disableDuet`, `disableStitch`, `yourBrandToggle`, `brandedContentToggle` and `confirmed: true` in `options.tiktok`. Fetch fresh choices with `list_connections {includeTikTokCreatorInfo: true}` and show the creator identity, media and caption. The person chooses every setting for this post. Photo posts use `disableDuet: true` and `disableStitch: true`; comments respect the creator's restriction. Branded content cannot use `SELF_ONLY`.\n\nBefore sending `confirmed: true`, get the person's approval of this exact post and their agreement to TikTok's [Music Usage Confirmation](https://www.tiktok.com/legal/page/global/music-usage-confirmation/en), plus the [Branded Content Policy](https://www.tiktok.com/legal/page/global/bc-policy/en) for branded content. That flag is a caller's attestation, not proof of a click in TikTok's prescribed UI. Never invent choices or carry usual preferences into a new post. If choices are missing, open the returned `approvalUrl` for the person to answer in Sprid.\n\nAn approval covers the version the person saw. If you edit a scheduled direct post afterwards, the edit's result carries `tiktokApproval`: the scheduled post will not publish as it is. Show the person the change and the settings in its `rebook` call, ask whether to post this version, and only after they agree call `rebook` with `options.tiktok.confirmed: true` added. `post_queue` returns the same call while the approval is outstanding.\n\nTikTok's published guidelines specify a privacy dropdown and posting controls. Acceptance of this chat-based confirmation flow has not been verified with TikTok. Keep that distinction explicit in review submissions.\n\nAfter approval, read `post_queue`. Keep draft, scheduled, failed and delivered apart, and report delivery only with the platform receipt. Read a failure before retrying. Never repeat a publish call just because a response was lost.\n\nFor results, read `get_documentation` for `marketing-review`. Keep the same app, account and post IDs across clients. `next_actions` gives the next step; an empty `verb` means follow its browser link.\n\n## Limits and troubleshooting\n\n- **Attachment can’t be read:** use the browser upload, not the CLI.\n- **Missing account or service:** set it up in the dashboard. Never paste credentials into chat.\n- **Hosted creation:** check `get_capabilities` before promising it. Store screenshots and store releases are local unless it reports otherwise.\n- **File transfer:** whether a host exposes uploaded or generated files has to be checked per host. A supported schema doesn’t prove it.\n",
1669
+ "revision": "bcf0b60a52e82938"
1615
1670
  },
1616
1671
  {
1617
1672
  "id": "local",
@@ -1642,8 +1697,8 @@ export const CONTENT_GUIDES = [
1642
1697
  "title": "Pinterest content and publishing",
1643
1698
  "summary": "Plan searchable Pins, approve a useful batch, let Sprid publish the selected Pins on schedule and judge the result by qualified traffic and activation.",
1644
1699
  "url": "https://sprid.studio/docs/pinterest",
1645
- "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. The plain destination URL. When it publishes, Sprid adds `utm_source=pinterest` and the Pin’s publish ID, so visits trace back to the exact Pin. A link that already carries its own `utm_source` goes out as written and loses that per-Pin tagging; add your own only for your own campaign names.\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\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\nEach is dated daily activity, kept as its own number. Pinterest reports no likes, comments or shares here, and Sprid does not collect Pin comments, so there is nothing for a Pin in the Inbox.\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",
1646
- "revision": "39dac00685dfe5f8"
1700
+ "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. The plain destination URL. When it publishes, Sprid adds `utm_source=pinterest` and the Pin’s publish ID, so visits trace back to the exact Pin. A link that already carries its own `utm_source` goes out as written and loses that per-Pin tagging; add your own only for your own campaign names.\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\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 scheduled. 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\nEach is dated daily activity, kept as its own number. Pinterest reports no likes, comments or shares here, and Sprid does not collect Pin comments, so there is nothing for a Pin in the Inbox.\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",
1701
+ "revision": "3f91ada0ff9d11ad"
1647
1702
  },
1648
1703
  {
1649
1704
  "id": "distribution",
package/src/post/cli.mjs CHANGED
@@ -1296,7 +1296,7 @@ export function crosspostCheck(m, caption, slug, platforms, lane = {}) {
1296
1296
  pinterestOptions: m.post?.pinterestOptions,
1297
1297
  platforms: modeled })
1298
1298
  .flatMap((p) => p.problems.map((x) => `${p.platform}: ${x}`));
1299
- const skips = unmodeled.length ? [`local projection rules unavailable for ${unmodeled.join(", ")}; use --deep for booked posts`] : [];
1299
+ const skips = unmodeled.length ? [`local projection rules unavailable for ${unmodeled.join(", ")}; use --deep for scheduled posts`] : [];
1300
1300
  // What the options slice cannot say: whether the MEDIA can be one Pin.
1301
1301
  if (modeled?.includes("pinterest")) {
1302
1302
  const media = pinterestMediaCheck(m, lane);
@@ -1540,7 +1540,7 @@ async function cmdCheck(cfg, argv) {
1540
1540
  let deepProblems = [];
1541
1541
  if (has(argv, "deep")) {
1542
1542
  const rows = await deepCrosspost(cfg, targets);
1543
- if (!rows.length) console.log(` ${C.ok("crosspost")} every booked platform publishes what it should\n`);
1543
+ if (!rows.length) console.log(` ${C.ok("crosspost")} every scheduled platform publishes what it should\n`);
1544
1544
  else {
1545
1545
  deepProblems = rows;
1546
1546
  console.log(` ${C.bad(`crosspost: ${rows.length} problem(s) in Sprid`)}\n`);
@@ -2137,12 +2137,12 @@ async function cmdPull(cfg, argv) {
2137
2137
  console.log(
2138
2138
  ` ${C.ok("pulled")} ${m.slug.padEnd(42)}` +
2139
2139
  publishes.map((p) => `${p.platform[0]}${p.status === "published" ? C.ok("✓") : p.status === "scheduled" ? C.dim("·") : C.bad("!")}`).join(" ").padEnd(22) +
2140
- C.dim(when ? when.slice(0, 16).replace("T", " ") : "not booked") +
2140
+ C.dim(when ? when.slice(0, 16).replace("T", " ") : "not scheduled") +
2141
2141
  (failed.length ? ` ${C.bad(failed.map((f) => `${f.platform}: ${f.lastError}`).join("; ").slice(0, 60))}` : ""),
2142
2142
  );
2143
2143
  }
2144
2144
  if (orphans)
2145
- console.log(`\n ${C.warn(`${orphans} post(s) carry no publish row at all - drafted but never booked`)}`);
2145
+ console.log(`\n ${C.warn(`${orphans} post(s) carry no publish row at all - drafted but never scheduled`)}`);
2146
2146
  console.log();
2147
2147
  }
2148
2148