From 282aae5d8d5030c07bb621367a6df62fcb0fe080 Mon Sep 17 00:00:00 2001 From: hammadjay Date: Tue, 13 Oct 2026 12:37:11 +0500 Subject: [PATCH 1/2] feat(carousels): AI carousel commands (CONT-4215) carousels:templates, carousels:estimate, carousels:generate and carousels:edit. The style follows the flags (--template, --description/--reference, or neither for the AI's pick). Like images:generate, generate and edit answer when the deck is done (300s timeout, never retried) and print the slides' media IDs and caption for posts:create --post-type carousel. Skill docs (both copies), README and CHANGELOG. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 ++ README.md | 23 ++++ SKILL.md | 36 +++++- skills/contentstudio/SKILL.md | 36 +++++- src/api.ts | 60 +++++++++ src/commands/carousels.ts | 232 ++++++++++++++++++++++++++++++++++ src/index.ts | 2 + tests/api.test.ts | 60 +++++++++ tests/cli.test.ts | 86 +++++++++++++ 9 files changed, 539 insertions(+), 6 deletions(-) create mode 100644 src/commands/carousels.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 563c936..c99e9fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased — AI carousels + +- **AI carousels** (CONT-4215) — `carousels:templates`, `carousels:estimate`, + `carousels:generate` and `carousels:edit`. The style follows the flags: + `--template` is a library template, `--description` / `--reference` (max 2) a + custom look, neither lets the AI choose. Like `images:generate`, `generate` and + `edit` answer when the deck is done (a 300s timeout, never retried, since each + call is billed) and print the slide media IDs and caption, ready for + `posts:create --post-type carousel`. + ## 1.6.0 — content categories, approval workflows, planner share links, AI video (2026-09-17) The public API gained five surfaces; the CLI mirrors all of them. diff --git a/README.md b/README.md index 6ccd626..2a7d0de 100644 --- a/README.md +++ b/README.md @@ -1035,6 +1035,25 @@ A `403` with no `error_code` is a membership or API-request-credit problem and s Video tools (`image-to-video`, `motion-control`, `lip-sync`, `talking-avatar`) are not exposed on this API — they answer `TOOL_NOT_FOUND` like an unknown key. Sample workspaces are read-only: the three discovery commands work, generation returns `403`. +## AI Carousels + +An ordered set of slides plus one caption, made with the same templates and options as AI chat. +`generate` and `edit` answer when the deck is done (up to about 3 minutes), like `images:generate`. + +```bash +contentstudio carousels:templates +contentstudio carousels:estimate --prompt "5 tips for better LinkedIn headlines" --template clean-minimal --slides 6 +contentstudio carousels:generate --prompt "5 tips for better LinkedIn headlines" --template clean-minimal --slides 6 +contentstudio carousels:generate --prompt "Our autumn menu" --description "warm, hand-drawn" --reference +contentstudio carousels:edit --instruction "make the headline shorter" --slide 1 +``` + +The result lists each slide's `media_id` (already in the media library) and the caption. +Schedule it with `posts:create --post-type carousel --content "" --media-id ...`. + +A template has one price; any other style is a `{min, max}` range, and generate never charges more +than `max`. A failed request charges nothing. + ## Platform-Specific Examples The full body schema accepts platform-specific options. These examples show the most common configurations. @@ -1433,6 +1452,10 @@ The CLI wraps these endpoints from the ContentStudio v1 public API (plus the wor | GET | `/workspaces/{w}/ai/jobs` | `ai-video:jobs` | | GET | `/workspaces/{w}/ai/jobs/{job_id}` | `ai-video:job ` | | DELETE | `/workspaces/{w}/ai/jobs/{job_id}` | `ai-video:cancel-job ` | +| GET | `/workspaces/{w}/ai/carousels/templates` | `carousels:templates` | +| POST | `/workspaces/{w}/ai/carousels/estimate` | `carousels:estimate` | +| POST | `/workspaces/{w}/ai/carousels/generate` | `carousels:generate` | +| POST | `/workspaces/{w}/ai/carousels/{carousel_id}/edit` | `carousels:edit ` | Full OpenAPI 3.0 spec: Human-readable docs: diff --git a/SKILL.md b/SKILL.md index 0d04473..e739ee2 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: contentstudio -description: ContentStudio is a tool to schedule social-media posts, manage the social inbox, and pull performance analytics across Facebook, LinkedIn, Twitter/X, Instagram, YouTube, TikTok, Pinterest, Threads, Tumblr, Bluesky, and Google Business Profile. Use when the user wants to list/create/delete/approve posts, find the best time to post, generate or edit images with AI, read and reply to DMs, comments and reviews, manage media, audit workspaces, accounts, campaigns, labels, categories, or team-members, or pull analytics reports (top posts, engagement, impressions, follower growth, AI insights, etc.) on their ContentStudio account. +description: ContentStudio is a tool to schedule social-media posts, manage the social inbox, and pull performance analytics across Facebook, LinkedIn, Twitter/X, Instagram, YouTube, TikTok, Pinterest, Threads, Tumblr, Bluesky, and Google Business Profile. Use when the user wants to list/create/delete/approve posts, find the best time to post, generate or edit images and carousels with AI, read and reply to DMs, comments and reviews, manage media, audit workspaces, accounts, campaigns, labels, categories, or team-members, or pull analytics reports (top posts, engagement, impressions, follower growth, AI insights, etc.) on their ContentStudio account. version: 1.5.0 homepage: https://api.contentstudio.io/guide metadata: {"openclaw":{"emoji":"📅","requires":{"bins":["contentstudio"],"env":["CONTENTSTUDIO_API_KEY"]}}} @@ -91,10 +91,12 @@ contentstudio workspaces:use The CLI silently defaults to the active workspace (whatever was set by `workspaces:use`). That default is fine for **read-only** calls (`workspaces:list`, `accounts:list`, `posts:list`, `media:list`, etc.) — just use the active workspace. -But for any **mutating** action — `accounts:connect`, `accounts:add-bluesky`, `accounts:add-facebook-group`, `accounts:remove`, `posts:create`, `posts:update`, `posts:delete`, `posts:approve`, `posts:reject`, `comments:add`, `media:upload`, `workspaces:update`, `workspaces:delete`, `labels:create`, `labels:update`, `labels:delete`, `campaigns:create`, `campaigns:update`, `campaigns:delete`, `team:add`, `team:update`, `team:remove`, `categories:create`, `categories:update`, `categories:delete`, `categories:shuffle`, `category-slots:create`, `category-slots:update`, `category-slots:delete`, `approval-workflows:create`, `approval-workflows:update`, `approval-workflows:delete`, `approval-workflows:duplicate`, `approval-workflows:set-default`, `approval-workflows:remove-default`, `planner-share-links:create`, `planner-share-links:update`, `planner-share-links:delete`, `planner-share-links:send-invitations`, every `inbox:*` write (`inbox:send`, `inbox:comment-add`, `inbox:comment-delete`, `inbox:review-reply`, `inbox:update`, `inbox:tag-*`, …), and `ai-video:generate` / `ai-video:run-tool` / `ai-video:cancel-job` — you MUST confirm the workspace with the user first, even if a workspace is already active. Don't assume the active workspace is the one they want to mutate. +But for any **mutating** action — `accounts:connect`, `accounts:add-bluesky`, `accounts:add-facebook-group`, `accounts:remove`, `posts:create`, `posts:update`, `posts:delete`, `posts:approve`, `posts:reject`, `comments:add`, `media:upload`, `workspaces:update`, `workspaces:delete`, `labels:create`, `labels:update`, `labels:delete`, `campaigns:create`, `campaigns:update`, `campaigns:delete`, `team:add`, `team:update`, `team:remove`, `categories:create`, `categories:update`, `categories:delete`, `categories:shuffle`, `category-slots:create`, `category-slots:update`, `category-slots:delete`, `approval-workflows:create`, `approval-workflows:update`, `approval-workflows:delete`, `approval-workflows:duplicate`, `approval-workflows:set-default`, `approval-workflows:remove-default`, `planner-share-links:create`, `planner-share-links:update`, `planner-share-links:delete`, `planner-share-links:send-invitations`, every `inbox:*` write (`inbox:send`, `inbox:comment-add`, `inbox:comment-delete`, `inbox:review-reply`, `inbox:update`, `inbox:tag-*`, …), `ai-video:generate` / `ai-video:run-tool` / `ai-video:cancel-job`, and `carousels:generate` / `carousels:edit` — you MUST confirm the workspace with the user first, even if a workspace is already active. Don't assume the active workspace is the one they want to mutate. > **AI video generation costs credits.** `ai-video:generate` and `ai-video:run-tool` submit a real (billed) job the moment they're called without `--dry-run` — run `ai-video:estimate` first when the flags support it, show the estimate/cost to the user, and `--dry-run` the actual call before running it for real. `ai-video:cancel-job` may also charge for partial work already consumed — don't cancel a job on the user's behalf without confirming. +> **AI carousels cost image credits.** `carousels:generate` and `carousels:edit` make a real (billed) carousel without `--dry-run`. Run `carousels:estimate` first and show the user the cost: a template has one price, any other style is a `{min, max}` range and generate never charges more than `max`. A failed request charges nothing. + > **Inbox writes are customer-facing.** `inbox:send`, `inbox:comment-add`, and `inbox:review-reply` publish text to a real person on a real social platform, and there is no undo on the provider side. Always `--dry-run` first, show the exact message text to the user, and get explicit approval before sending. Never compose-and-send a reply to a customer in one step. (`workspaces:create` is the one write that is **not** workspace-scoped — it creates a brand-new workspace and ignores the active one.) @@ -184,7 +186,7 @@ All `*:list` commands paginate: `category-slots:list` and `planner-share-links:activity` return everything in one shot and carry no `pagination` block — a category has a handful of slots, and activity answers its own `total`. -Non-list commands (`auth:whoami`, `posts:create`, `posts:delete`, `media:upload`, `ai-video:tools`, `ai-video:models`, `ai-video:job`, etc.) never include `pagination` in their envelope. +Non-list commands (`auth:whoami`, `posts:create`, `posts:delete`, `media:upload`, `ai-video:tools`, `ai-video:models`, `ai-video:job`, `carousels:*`, etc.) never include `pagination` in their envelope. --- @@ -492,6 +494,34 @@ Errors: 422 for unknown accounts or a workspace with no connected accounts; 502 **Errors specific to AI Video** (in addition to the standard table below): 403 `INSUFFICIENT_VIDEO_CREDITS` (not enough credits — surfaces as `AuthError`), 403 `STORAGE_LIMIT_EXCEEDED` (surfaces as `AuthError`), 404 `TOOL_NOT_FOUND` / `JOB_NOT_FOUND` (`NotFoundError`), 409 `JOB_ALREADY_TERMINAL` (`ConflictError`), 502 `AI_SERVICE_UNAVAILABLE` / 504 `AI_SERVICE_TIMEOUT` (`BackendError` — safe to retry after a short backoff). +### AI Carousels + +A carousel is an ordered set of slides plus one caption, made with the same templates and options as AI chat. Like `images:generate`, `generate` and `edit` answer when the deck is done: about 25 seconds for a template, up to about 3 minutes for a deck the AI draws. Tell the user it may take a few minutes. + +| Command | Purpose | +|---------|---------| +| `carousels:templates` | Every template: `id`, `name`, `best_for`, `sizes`, `preview_url` (the cover as a PNG), `preview_html_url` (the same cover as HTML) | +| `carousels:estimate --prompt "..." [style flags]` | Credits as `{min, max}`. Nothing generated or charged | +| `carousels:generate --prompt "..." [--slides 2-10] [--size portrait\|square] [--template \| --description "..." --reference (repeatable, max 2)] [--no-brand-colours] [--use-brand\|--no-use-brand] [--language] [--dry-run]` | Generate a carousel. Prints the slides' media IDs and the caption | +| `carousels:edit --instruction "..." [--slide ] [--dry-run]` | One plain-language change to a finished carousel (within 7 days) | + +**Style:** `--template` is a library template. `--description` and/or `--reference` is a custom look (the AI picks how to make it). Neither lets the AI choose the look. `--template` with `--description`/`--reference` is a `ConfigError`. `--no-brand-colours` keeps the template's own colours (the logo stays when brand is on). Omitting `--use-brand` follows the workspace's brand setting. + +**Result:** `carousel_id`, `slides[]` in order (`position`, `media_id`, `url`, `width`, `height`, each already in the media library), `caption`, `style`, `credits` (charged), `failed_slots` (slides that could not be drawn; only delivered slides are charged). + +**Generate and schedule a carousel:** + +```bash +contentstudio carousels:estimate --prompt "5 tips for better LinkedIn headlines" --template clean-minimal --slides 6 +contentstudio --json carousels:generate --prompt "5 tips for better LinkedIn headlines" --template clean-minimal --slides 6 +# take data.slides[].media_id and data.caption from the output, then: +contentstudio posts:create --account --post-type carousel \ + --content "" --media-id --media-id --media-id \ + --publish-type scheduled --scheduled-at "2026-10-16 09:00:00" +``` + +**Errors:** 422 `UNKNOWN_TEMPLATE` / `TOO_MANY_REFERENCES` / `REFERENCE_UNREADABLE` / `SLIDE_COUNT_OUT_OF_RANGE` / `STYLE_FIELDS_CONFLICT` (`ValidationError`; the CLI picks the mode from the flags, so it never sends a conflict), 422 `PROMPT_NEEDS_DETAIL` (the prompt gave no subject: ask the user and generate again), 422 `EDIT_NOT_APPLIED` (the instruction changed nothing) / `EDIT_NEEDS_NEW_CAROUSEL` (a different template is a new `carousels:generate`), 403 `IMAGE_CREDIT_LIMIT_EXCEEDED` (`AuthError`), 404 `CAROUSEL_NOT_FOUND` (not in this workspace, or older than 7 days), 429 `CAROUSEL_REQUESTS_LIMIT` (too many being made in this workspace at once: wait for one), 502 `CAROUSEL_GENERATION_FAILED` / `AI_SERVICE_UNAVAILABLE` / `MEDIA_PERSISTENCE_FAILED` and 504 `AI_SERVICE_TIMEOUT` (safe to retry; a failed request charges nothing), 507 `MEDIA_STORAGE_LIMIT_EXCEEDED`. Never retry a generate that timed out on your side without checking the media library: the carousel may have been made and charged. + ### Lookup tables (read) | Command | Purpose | diff --git a/skills/contentstudio/SKILL.md b/skills/contentstudio/SKILL.md index 0d04473..e739ee2 100644 --- a/skills/contentstudio/SKILL.md +++ b/skills/contentstudio/SKILL.md @@ -1,6 +1,6 @@ --- name: contentstudio -description: ContentStudio is a tool to schedule social-media posts, manage the social inbox, and pull performance analytics across Facebook, LinkedIn, Twitter/X, Instagram, YouTube, TikTok, Pinterest, Threads, Tumblr, Bluesky, and Google Business Profile. Use when the user wants to list/create/delete/approve posts, find the best time to post, generate or edit images with AI, read and reply to DMs, comments and reviews, manage media, audit workspaces, accounts, campaigns, labels, categories, or team-members, or pull analytics reports (top posts, engagement, impressions, follower growth, AI insights, etc.) on their ContentStudio account. +description: ContentStudio is a tool to schedule social-media posts, manage the social inbox, and pull performance analytics across Facebook, LinkedIn, Twitter/X, Instagram, YouTube, TikTok, Pinterest, Threads, Tumblr, Bluesky, and Google Business Profile. Use when the user wants to list/create/delete/approve posts, find the best time to post, generate or edit images and carousels with AI, read and reply to DMs, comments and reviews, manage media, audit workspaces, accounts, campaigns, labels, categories, or team-members, or pull analytics reports (top posts, engagement, impressions, follower growth, AI insights, etc.) on their ContentStudio account. version: 1.5.0 homepage: https://api.contentstudio.io/guide metadata: {"openclaw":{"emoji":"📅","requires":{"bins":["contentstudio"],"env":["CONTENTSTUDIO_API_KEY"]}}} @@ -91,10 +91,12 @@ contentstudio workspaces:use The CLI silently defaults to the active workspace (whatever was set by `workspaces:use`). That default is fine for **read-only** calls (`workspaces:list`, `accounts:list`, `posts:list`, `media:list`, etc.) — just use the active workspace. -But for any **mutating** action — `accounts:connect`, `accounts:add-bluesky`, `accounts:add-facebook-group`, `accounts:remove`, `posts:create`, `posts:update`, `posts:delete`, `posts:approve`, `posts:reject`, `comments:add`, `media:upload`, `workspaces:update`, `workspaces:delete`, `labels:create`, `labels:update`, `labels:delete`, `campaigns:create`, `campaigns:update`, `campaigns:delete`, `team:add`, `team:update`, `team:remove`, `categories:create`, `categories:update`, `categories:delete`, `categories:shuffle`, `category-slots:create`, `category-slots:update`, `category-slots:delete`, `approval-workflows:create`, `approval-workflows:update`, `approval-workflows:delete`, `approval-workflows:duplicate`, `approval-workflows:set-default`, `approval-workflows:remove-default`, `planner-share-links:create`, `planner-share-links:update`, `planner-share-links:delete`, `planner-share-links:send-invitations`, every `inbox:*` write (`inbox:send`, `inbox:comment-add`, `inbox:comment-delete`, `inbox:review-reply`, `inbox:update`, `inbox:tag-*`, …), and `ai-video:generate` / `ai-video:run-tool` / `ai-video:cancel-job` — you MUST confirm the workspace with the user first, even if a workspace is already active. Don't assume the active workspace is the one they want to mutate. +But for any **mutating** action — `accounts:connect`, `accounts:add-bluesky`, `accounts:add-facebook-group`, `accounts:remove`, `posts:create`, `posts:update`, `posts:delete`, `posts:approve`, `posts:reject`, `comments:add`, `media:upload`, `workspaces:update`, `workspaces:delete`, `labels:create`, `labels:update`, `labels:delete`, `campaigns:create`, `campaigns:update`, `campaigns:delete`, `team:add`, `team:update`, `team:remove`, `categories:create`, `categories:update`, `categories:delete`, `categories:shuffle`, `category-slots:create`, `category-slots:update`, `category-slots:delete`, `approval-workflows:create`, `approval-workflows:update`, `approval-workflows:delete`, `approval-workflows:duplicate`, `approval-workflows:set-default`, `approval-workflows:remove-default`, `planner-share-links:create`, `planner-share-links:update`, `planner-share-links:delete`, `planner-share-links:send-invitations`, every `inbox:*` write (`inbox:send`, `inbox:comment-add`, `inbox:comment-delete`, `inbox:review-reply`, `inbox:update`, `inbox:tag-*`, …), `ai-video:generate` / `ai-video:run-tool` / `ai-video:cancel-job`, and `carousels:generate` / `carousels:edit` — you MUST confirm the workspace with the user first, even if a workspace is already active. Don't assume the active workspace is the one they want to mutate. > **AI video generation costs credits.** `ai-video:generate` and `ai-video:run-tool` submit a real (billed) job the moment they're called without `--dry-run` — run `ai-video:estimate` first when the flags support it, show the estimate/cost to the user, and `--dry-run` the actual call before running it for real. `ai-video:cancel-job` may also charge for partial work already consumed — don't cancel a job on the user's behalf without confirming. +> **AI carousels cost image credits.** `carousels:generate` and `carousels:edit` make a real (billed) carousel without `--dry-run`. Run `carousels:estimate` first and show the user the cost: a template has one price, any other style is a `{min, max}` range and generate never charges more than `max`. A failed request charges nothing. + > **Inbox writes are customer-facing.** `inbox:send`, `inbox:comment-add`, and `inbox:review-reply` publish text to a real person on a real social platform, and there is no undo on the provider side. Always `--dry-run` first, show the exact message text to the user, and get explicit approval before sending. Never compose-and-send a reply to a customer in one step. (`workspaces:create` is the one write that is **not** workspace-scoped — it creates a brand-new workspace and ignores the active one.) @@ -184,7 +186,7 @@ All `*:list` commands paginate: `category-slots:list` and `planner-share-links:activity` return everything in one shot and carry no `pagination` block — a category has a handful of slots, and activity answers its own `total`. -Non-list commands (`auth:whoami`, `posts:create`, `posts:delete`, `media:upload`, `ai-video:tools`, `ai-video:models`, `ai-video:job`, etc.) never include `pagination` in their envelope. +Non-list commands (`auth:whoami`, `posts:create`, `posts:delete`, `media:upload`, `ai-video:tools`, `ai-video:models`, `ai-video:job`, `carousels:*`, etc.) never include `pagination` in their envelope. --- @@ -492,6 +494,34 @@ Errors: 422 for unknown accounts or a workspace with no connected accounts; 502 **Errors specific to AI Video** (in addition to the standard table below): 403 `INSUFFICIENT_VIDEO_CREDITS` (not enough credits — surfaces as `AuthError`), 403 `STORAGE_LIMIT_EXCEEDED` (surfaces as `AuthError`), 404 `TOOL_NOT_FOUND` / `JOB_NOT_FOUND` (`NotFoundError`), 409 `JOB_ALREADY_TERMINAL` (`ConflictError`), 502 `AI_SERVICE_UNAVAILABLE` / 504 `AI_SERVICE_TIMEOUT` (`BackendError` — safe to retry after a short backoff). +### AI Carousels + +A carousel is an ordered set of slides plus one caption, made with the same templates and options as AI chat. Like `images:generate`, `generate` and `edit` answer when the deck is done: about 25 seconds for a template, up to about 3 minutes for a deck the AI draws. Tell the user it may take a few minutes. + +| Command | Purpose | +|---------|---------| +| `carousels:templates` | Every template: `id`, `name`, `best_for`, `sizes`, `preview_url` (the cover as a PNG), `preview_html_url` (the same cover as HTML) | +| `carousels:estimate --prompt "..." [style flags]` | Credits as `{min, max}`. Nothing generated or charged | +| `carousels:generate --prompt "..." [--slides 2-10] [--size portrait\|square] [--template \| --description "..." --reference (repeatable, max 2)] [--no-brand-colours] [--use-brand\|--no-use-brand] [--language] [--dry-run]` | Generate a carousel. Prints the slides' media IDs and the caption | +| `carousels:edit --instruction "..." [--slide ] [--dry-run]` | One plain-language change to a finished carousel (within 7 days) | + +**Style:** `--template` is a library template. `--description` and/or `--reference` is a custom look (the AI picks how to make it). Neither lets the AI choose the look. `--template` with `--description`/`--reference` is a `ConfigError`. `--no-brand-colours` keeps the template's own colours (the logo stays when brand is on). Omitting `--use-brand` follows the workspace's brand setting. + +**Result:** `carousel_id`, `slides[]` in order (`position`, `media_id`, `url`, `width`, `height`, each already in the media library), `caption`, `style`, `credits` (charged), `failed_slots` (slides that could not be drawn; only delivered slides are charged). + +**Generate and schedule a carousel:** + +```bash +contentstudio carousels:estimate --prompt "5 tips for better LinkedIn headlines" --template clean-minimal --slides 6 +contentstudio --json carousels:generate --prompt "5 tips for better LinkedIn headlines" --template clean-minimal --slides 6 +# take data.slides[].media_id and data.caption from the output, then: +contentstudio posts:create --account --post-type carousel \ + --content "" --media-id --media-id --media-id \ + --publish-type scheduled --scheduled-at "2026-10-16 09:00:00" +``` + +**Errors:** 422 `UNKNOWN_TEMPLATE` / `TOO_MANY_REFERENCES` / `REFERENCE_UNREADABLE` / `SLIDE_COUNT_OUT_OF_RANGE` / `STYLE_FIELDS_CONFLICT` (`ValidationError`; the CLI picks the mode from the flags, so it never sends a conflict), 422 `PROMPT_NEEDS_DETAIL` (the prompt gave no subject: ask the user and generate again), 422 `EDIT_NOT_APPLIED` (the instruction changed nothing) / `EDIT_NEEDS_NEW_CAROUSEL` (a different template is a new `carousels:generate`), 403 `IMAGE_CREDIT_LIMIT_EXCEEDED` (`AuthError`), 404 `CAROUSEL_NOT_FOUND` (not in this workspace, or older than 7 days), 429 `CAROUSEL_REQUESTS_LIMIT` (too many being made in this workspace at once: wait for one), 502 `CAROUSEL_GENERATION_FAILED` / `AI_SERVICE_UNAVAILABLE` / `MEDIA_PERSISTENCE_FAILED` and 504 `AI_SERVICE_TIMEOUT` (safe to retry; a failed request charges nothing), 507 `MEDIA_STORAGE_LIMIT_EXCEEDED`. Never retry a generate that timed out on your side without checking the media library: the carousel may have been made and charged. + ### Lookup tables (read) | Command | Purpose | diff --git a/src/api.ts b/src/api.ts index bcce9d4..ab85be6 100644 --- a/src/api.ts +++ b/src/api.ts @@ -2434,6 +2434,66 @@ export function cancelAiVideoJob(c: Client, workspaceId: string, jobId: string) return c.delete(`/workspaces/${workspaceId}/ai/jobs/${seg(jobId)}`); } +// ── AI carousels ────────────────────────────────────────── + +export interface AiCarouselStyle { + mode?: "auto" | "template" | "custom"; + template_id?: string; + description?: string; + /** Up to 2: workspace media IDs or public image URLs. */ + references?: string[]; + brand_colours?: boolean; +} + +export interface AiCarouselBody { + prompt: string; + slide_count?: number; + size?: "portrait" | "square"; + language?: string; + use_brand?: boolean; + style?: AiCarouselStyle; +} + +export interface AiCarouselEditBody { + instruction: string; + slide?: number; + language?: string; + use_brand?: boolean; +} + +/** GET /workspaces/{w}/ai/carousels/templates — every template, with a cover preview URL. */ +export function listAiCarouselTemplates(c: Client, workspaceId: string) { + return c.get(`/workspaces/${workspaceId}/ai/carousels/templates`); +} + +/** + * POST /workspaces/{w}/ai/carousels/estimate — credits as {min, max}. Nothing is generated or + * charged; a template has one price, any other style a range generate never exceeds. + */ +export function estimateAiCarousel(c: Client, workspaceId: string, body: AiCarouselBody) { + return c.post(`/workspaces/${workspaceId}/ai/carousels/estimate`, { json: body }); +} + +/** + * POST /workspaces/{w}/ai/carousels/generate — answers when the deck is done (up to ~3 minutes) + * with {carousel_id, slides[{position, media_id, url, ...}], caption, style, credits, ...}. + */ +export function generateAiCarousel(c: Client, workspaceId: string, body: AiCarouselBody) { + return c.post(`/workspaces/${workspaceId}/ai/carousels/generate`, { json: body }); +} + +/** POST /workspaces/{w}/ai/carousels/{carousel_id}/edit — one plain-language change; answers with the deck. */ +export function editAiCarousel( + c: Client, + workspaceId: string, + carouselId: string, + body: AiCarouselEditBody, +) { + return c.post(`/workspaces/${workspaceId}/ai/carousels/${seg(carouselId)}/edit`, { + json: body, + }); +} + // ── ads analytics ───────────────────────────────────────── /** diff --git a/src/commands/carousels.ts b/src/commands/carousels.ts new file mode 100644 index 0000000..c8a985f --- /dev/null +++ b/src/commands/carousels.ts @@ -0,0 +1,232 @@ +/** + * AI carousels — templates, estimate, generate and edit. Like images:generate, generate and edit + * answer when the deck is done (up to about 3 minutes) and print the slides' media IDs, ready for + * `posts:create --post-type carousel --media-id ...`. + */ + +import type { Argv } from "yargs"; + +import { + AiCarouselBody, + AiCarouselEditBody, + AiCarouselStyle, + editAiCarousel, + estimateAiCarousel, + generateAiCarousel, + listAiCarouselTemplates, +} from "../api"; +import { ConfigError } from "../errors"; +import * as out from "../output"; +import { buildClient, emitDryRun, isDryRun, resolveWorkspace, run } from "../cliCtx"; + +const MAX_PROMPT_LENGTH = 2000; +const MAX_REFERENCES = 2; +// Billed and not idempotent, so never retried; an AI-drawn deck takes up to about 3 minutes. +const BUILD = { retries: 0, timeoutMs: 300_000 }; + +function optionFlags(y: Argv) { + return y + .option("prompt", { type: "string", describe: `Required. Max ${MAX_PROMPT_LENGTH} chars.` }) + .option("slides", { + type: "number", + describe: "slide_count (2-10). Omit it and the AI chooses the length.", + }) + .option("size", { + type: "string", + choices: ["portrait", "square"] as const, + describe: "portrait is 4:5 (default), square is 1:1.", + }) + .option("template", { + type: "string", + describe: "A template id from carousels:templates (style mode template).", + }) + .option("description", { + type: "string", + describe: "Describe the look in plain words (style mode custom).", + }) + .option("reference", { + type: "string", + array: true, + describe: `An image to match: a media ID or public URL (repeatable, max ${MAX_REFERENCES}, style mode custom).`, + }) + .option("brand-colours", { + type: "boolean", + describe: "Use the brand's colours (default). --no-brand-colours keeps the template's own.", + }) + .option("use-brand", { + type: "boolean", + describe: "Brand knowledge (voice, logo). Omit it to follow the workspace setting.", + }) + .option("language", { type: "string", describe: "Language of the slides and caption." }); +} + +export function registerCarousels(yargs: Argv): Argv { + return yargs + .command( + "carousels:templates", + "List carousel templates (id, name, best for, sizes, cover preview).", + (y) => y, + run(async (_argv: any, g) => { + const { cfg, client } = buildClient(g); + const wid = resolveWorkspace(cfg, g); + const data: any = await listAiCarouselTemplates(client, wid); + const templates = (data?.templates as any[]) ?? []; + out.emitSuccess(data, g, () => + out.table( + ["ID", "NAME", "SIZES", "BEST FOR"], + templates.map((t: any) => [ + String(t?.id ?? "-"), + String(t?.name ?? "-"), + Array.isArray(t?.sizes) ? t.sizes.join(",") : "-", + trim(t?.best_for, 60), + ]), + ), + ); + }), + ) + .command( + "carousels:estimate", + "Get the credit cost as {min, max}. Nothing is generated or charged.", + (y) => optionFlags(y), + run(async (argv: any, g) => { + const { cfg, client } = buildClient(g); + const wid = resolveWorkspace(cfg, g); + const data: any = await estimateAiCarousel(client, wid, buildCarouselBody(argv)); + out.emitSuccess(data, g, (d: any) => { + const { min, max } = d?.credits ?? {}; + out.status("Credits", min === max ? String(min ?? "-") : `${min} to ${max}`); + if (!d?.route_known) out.info("The AI picks the route while planning; it never charges more than the max."); + }); + }), + ) + .command( + "carousels:generate", + "Generate a carousel (ordered slides plus one caption), waiting for it by default.", + (y) => + optionFlags(y).option("dry-run", { + type: "boolean", + default: false, + describe: "Print the body that would be sent without calling the API.", + }), + run(async (argv: any, g) => { + const { cfg, client } = buildClient(g, BUILD); + const wid = resolveWorkspace(cfg, g); + const body = buildCarouselBody(argv); + + if (isDryRun(argv)) { + return emitDryRun( + g, + `POST /workspaces/${wid}/ai/carousels/generate`, + body as unknown as Record, + "generate an AI carousel", + ); + } + + if (!g.useJson) out.info("Making the carousel; this takes up to about 3 minutes..."); + const deck: any = await generateAiCarousel(client, wid, body); + out.emitSuccess(deck, g, (d: any) => printDeck(d)); + }), + ) + .command( + "carousels:edit ", + "Apply one plain-language change to a finished carousel, waiting for it by default.", + (y) => + y + .positional("carousel_id", { type: "string", demandOption: true }) + .option("instruction", { type: "string", describe: "Required. The change, in plain words." }) + .option("slide", { type: "number", describe: "1-based slide the change is about." }) + .option("language", { type: "string" }) + .option("use-brand", { type: "boolean" }) + .option("dry-run", { type: "boolean", default: false }), + run(async (argv: any, g) => { + const { cfg, client } = buildClient(g, BUILD); + const wid = resolveWorkspace(cfg, g); + const carouselId = String(argv.carousel_id); + const body = buildEditBody(argv); + + if (isDryRun(argv)) { + return emitDryRun( + g, + `POST /workspaces/${wid}/ai/carousels/${carouselId}/edit`, + body as unknown as Record, + `edit carousel ${carouselId}`, + ); + } + + if (!g.useJson) out.info("Editing the carousel..."); + const deck: any = await editAiCarousel(client, wid, carouselId, body); + out.emitSuccess(deck, g, (d: any) => printDeck(d)); + }), + ); +} + +function printDeck(d: any): void { + out.success("Carousel ready."); + out.status("Carousel ID", String(d?.carousel_id ?? "-")); + out.status("Credits", String(d?.credits ?? "-")); + out.status("Caption", String(d?.caption ?? "")); + out.table( + ["SLIDE", "MEDIA ID", "URL"], + ((d?.slides as any[]) ?? []).map((s: any) => [String(s?.position ?? "-"), String(s?.media_id ?? "-"), String(s?.url ?? "-")]), + ); + if (Array.isArray(d?.failed_slots) && d.failed_slots.length) { + out.warning(`Slides not drawn: ${d.failed_slots.map((n: number) => n + 1).join(", ")}`); + } +} + +/** + * Build + validate the generate/estimate body from argv. Runs in --dry-run too, so the printed + * payload is what would be sent. The style mode follows the flags: --template is a template, + * --description or --reference is custom, neither lets the AI choose. + */ +export function buildCarouselBody(argv: any): AiCarouselBody { + if (!argv.prompt) throw new ConfigError("--prompt is required."); + const prompt = String(argv.prompt); + if (prompt.length > MAX_PROMPT_LENGTH) { + throw new ConfigError(`--prompt exceeds ${MAX_PROMPT_LENGTH} characters (got ${prompt.length}).`); + } + const references = ((argv.reference as string[] | undefined) ?? []).map(String); + if (references.length > MAX_REFERENCES) { + throw new ConfigError(`--reference may be repeated at most ${MAX_REFERENCES} times (got ${references.length}).`); + } + const custom = !!argv.description || references.length > 0; + if (argv.template && custom) { + throw new ConfigError("--template and --description/--reference are different styles; pass one or the other."); + } + + const body: AiCarouselBody = { prompt }; + if (argv.slides !== undefined) body.slide_count = Number(argv.slides); + if (argv.size) body.size = argv.size; + if (argv.language) body.language = String(argv.language); + const useBrand = argv["use-brand"] ?? argv.useBrand; + if (useBrand !== undefined) body.use_brand = !!useBrand; + + const style: AiCarouselStyle = {}; + if (argv.template) { + style.mode = "template"; + style.template_id = String(argv.template); + } else if (custom) { + style.mode = "custom"; + if (argv.description) style.description = String(argv.description); + if (references.length) style.references = references; + } + const colours = argv["brand-colours"] ?? argv.brandColours; + if (colours !== undefined) style.brand_colours = !!colours; + if (Object.keys(style).length) body.style = style; + return body; +} + +function buildEditBody(argv: any): AiCarouselEditBody { + if (!argv.instruction) throw new ConfigError("--instruction is required."); + const body: AiCarouselEditBody = { instruction: String(argv.instruction) }; + if (argv.slide !== undefined) body.slide = Number(argv.slide); + if (argv.language) body.language = String(argv.language); + const useBrand = argv["use-brand"] ?? argv.useBrand; + if (useBrand !== undefined) body.use_brand = !!useBrand; + return body; +} + +function trim(value: unknown, max: number): string { + const text = String(value ?? "-"); + return text.length > max ? `${text.slice(0, max - 1)}…` : text; +} diff --git a/src/index.ts b/src/index.ts index c45ddc8..b1a275a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import { hideBin } from "yargs/helpers"; import { VERSION } from "./api"; import { registerAiVideo } from "./commands/aiVideo"; +import { registerCarousels } from "./commands/carousels"; import { registerAnalytics } from "./commands/analytics"; import { registerAnalyticsReports } from "./commands/analyticsReports"; import { registerApprovalWorkflows } from "./commands/approvalWorkflows"; @@ -80,5 +81,6 @@ cli = registerInbox(cli); cli = registerAnalytics(cli); cli = registerAnalyticsReports(cli); cli = registerAiVideo(cli); +cli = registerCarousels(cli); cli.parse(); diff --git a/tests/api.test.ts b/tests/api.test.ts index e810239..f2cbb01 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -12,6 +12,10 @@ import { attachInboxTags, bulkUpdateInboxElements, cancelAiVideoJob, + editAiCarousel, + estimateAiCarousel, + generateAiCarousel, + listAiCarouselTemplates, deleteInboxComment, deleteInboxMessage, deleteInboxTags, @@ -2081,3 +2085,59 @@ function limitsPayload() { }, }; } + +describe("AI carousels", () => { + it("listAiCarouselTemplates unwraps the templates", async () => { + nock(BASE) + .get(`${PATH}/workspaces/ws-1/ai/carousels/templates`) + .reply(200, envelope({ templates: [{ id: "clean-minimal", name: "Clean Minimal" }] })); + const data: any = await listAiCarouselTemplates(mkClient(), "ws-1"); + expect(data.templates[0].id).toBe("clean-minimal"); + }); + + it("estimateAiCarousel posts the options and returns the range", async () => { + let sent: any; + nock(BASE) + .post(`${PATH}/workspaces/ws-1/ai/carousels/estimate`, (body) => { + sent = body; + return true; + }) + .reply(200, envelope({ credits: { min: 8, max: 38 }, route_known: false })); + const data: any = await estimateAiCarousel(mkClient(), "ws-1", { prompt: "5 tips", slide_count: 6 }); + expect(sent).toEqual({ prompt: "5 tips", slide_count: 6 }); + expect(data.credits).toEqual({ min: 8, max: 38 }); + }); + + it("generateAiCarousel returns the finished deck", async () => { + nock(BASE) + .post(`${PATH}/workspaces/ws-1/ai/carousels/generate`) + .reply(200, envelope({ carousel_id: "crs_1", slides: [{ position: 1, media_id: "m1" }], caption: "c" })); + const data: any = await generateAiCarousel(mkClient(), "ws-1", { + prompt: "5 tips", + style: { mode: "template", template_id: "clean-minimal" }, + }); + expect(data).toMatchObject({ carousel_id: "crs_1", caption: "c" }); + expect(data.slides[0].media_id).toBe("m1"); + }); + + it("editAiCarousel posts to the carousel", async () => { + let sent: any; + nock(BASE) + .post(`${PATH}/workspaces/ws-1/ai/carousels/crs_1/edit`, (body) => { + sent = body; + return true; + }) + .reply(200, envelope({ carousel_id: "crs_1", slides: [], caption: "c" })); + await editAiCarousel(mkClient(), "ws-1", "crs_1", { instruction: "shorter", slide: 2 }); + expect(sent).toEqual({ instruction: "shorter", slide: 2 }); + }); + + it("an unknown template is a ValidationError", async () => { + nock(BASE) + .post(`${PATH}/workspaces/ws-1/ai/carousels/generate`) + .reply(422, { status: false, message: "Unknown template_id.", error_code: "UNKNOWN_TEMPLATE" }); + await expect( + generateAiCarousel(mkClient(), "ws-1", { prompt: "x", style: { mode: "template", template_id: "nope" } }), + ).rejects.toBeInstanceOf(ValidationError); + }); +}); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 8c0c30a..42c5e2e 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1096,3 +1096,89 @@ describe("posts:create repeat is gated to --publish-type scheduled", () => { expect(scheduling.publish_type).toBe("scheduled"); }); }); + +describe("carousels commands", () => { + function withCfg() { + fs.writeFileSync( + cfgFile, + JSON.stringify({ api_key: "cs_INVALID", active_workspace_id: "ws-1" }), + ); + return { CONTENTSTUDIO_CONFIG_PATH: cfgFile }; + } + + it("lists every carousel command in --help", () => { + const r = run(["--help"], { CONTENTSTUDIO_CONFIG_PATH: cfgFile }); + for (const cmd of [ + "carousels:templates", + "carousels:estimate", + "carousels:generate", + "carousels:edit", + ]) { + expect(r.stdout, cmd).toContain(cmd); + } + }); + + it("carousels:generate --dry-run builds a template body and hits no network", () => { + const r = run( + ["--json", "carousels:generate", "--prompt", "5 tips", "--template", "clean-minimal", "--slides", "6", "--size", "square", "--dry-run"], + withCfg(), + ); + expect(r.code).toBe(0); + const d = JSON.parse(r.stdout); + expect(d.data.endpoint).toBe("POST /workspaces/ws-1/ai/carousels/generate"); + expect(d.data.body).toEqual({ + prompt: "5 tips", + slide_count: 6, + size: "square", + style: { mode: "template", template_id: "clean-minimal" }, + }); + }); + + it("a description or a reference makes the style custom", () => { + const r = run( + ["--json", "carousels:generate", "--prompt", "x", "--description", "neon", "--reference", "m1", "--no-brand-colours", "--dry-run"], + withCfg(), + ); + expect(JSON.parse(r.stdout).data.body.style).toEqual({ + mode: "custom", + description: "neon", + references: ["m1"], + brand_colours: false, + }); + }); + + it("no style flags leave the look to the AI", () => { + const r = run(["--json", "carousels:generate", "--prompt", "x", "--dry-run"], withCfg()); + expect(JSON.parse(r.stdout).data.body).toEqual({ prompt: "x" }); + }); + + it("refuses a template together with a custom description", () => { + const r = run(["--json", "carousels:generate", "--prompt", "x", "--template", "a", "--description", "b"], withCfg()); + expect(r.code).not.toBe(0); + expect(JSON.parse(r.stdout).error.type).toBe("ConfigError"); + }); + + it("refuses more than two references", () => { + const r = run( + ["--json", "carousels:generate", "--prompt", "x", "--reference", "a", "--reference", "b", "--reference", "c"], + withCfg(), + ); + expect(r.code).not.toBe(0); + }); + + it("the job commands are gone: generate answers with the deck", () => { + const r = run(["--help"], { CONTENTSTUDIO_CONFIG_PATH: cfgFile }); + expect(r.stdout).not.toContain("carousels:job"); + expect(r.stdout).not.toContain("carousels:cancel"); + }); + + it("carousels:edit --dry-run names the carousel and the slide", () => { + const r = run( + ["--json", "carousels:edit", "crs_1", "--instruction", "shorter", "--slide", "2", "--dry-run"], + withCfg(), + ); + const d = JSON.parse(r.stdout); + expect(d.data.endpoint).toBe("POST /workspaces/ws-1/ai/carousels/crs_1/edit"); + expect(d.data.body).toEqual({ instruction: "shorter", slide: 2 }); + }); +}); From 1168bbb015a18999db2ce15bbda960e4a1705fd7 Mon Sep 17 00:00:00 2001 From: hammadjay Date: Wed, 14 Oct 2026 11:55:28 +0500 Subject: [PATCH 2/2] docs(carousels): slide count is 3-10; out-of-range is VALIDATION_ERROR Co-Authored-By: Claude Opus 5.5 --- SKILL.md | 4 ++-- skills/contentstudio/SKILL.md | 4 ++-- src/commands/carousels.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/SKILL.md b/SKILL.md index ec6ab46..7a3c98a 100644 --- a/SKILL.md +++ b/SKILL.md @@ -503,7 +503,7 @@ A carousel is an ordered set of slides plus one caption, made with the same temp |---------|---------| | `carousels:templates` | Every template: `id`, `name`, `best_for`, `sizes`, `preview_url` (the cover as a PNG), `preview_html_url` (the same cover as HTML) | | `carousels:estimate --prompt "..." [style flags]` | Credits as `{min, max}`. Nothing generated or charged | -| `carousels:generate --prompt "..." [--slides 2-10] [--size portrait\|square] [--template \| --description "..." --reference (repeatable, max 2)] [--no-brand-colours] [--use-brand\|--no-use-brand] [--language] [--dry-run]` | Generate a carousel. Prints the slides' media IDs and the caption | +| `carousels:generate --prompt "..." [--slides 3-10] [--size portrait\|square] [--template \| --description "..." --reference (repeatable, max 2)] [--no-brand-colours] [--use-brand\|--no-use-brand] [--language] [--dry-run]` | Generate a carousel. Prints the slides' media IDs and the caption | | `carousels:edit --instruction "..." [--slide ] [--dry-run]` | One plain-language change to a finished carousel (within 7 days) | **Style:** `--template` is a library template. `--description` and/or `--reference` is a custom look (the AI picks how to make it). Neither lets the AI choose the look. `--template` with `--description`/`--reference` is a `ConfigError`. `--no-brand-colours` keeps the template's own colours (the logo stays when brand is on). Omitting `--use-brand` follows the workspace's brand setting. @@ -521,7 +521,7 @@ contentstudio posts:create --account --post-type carousel --publish-type scheduled --scheduled-at "2026-10-16 09:00:00" ``` -**Errors:** 422 `UNKNOWN_TEMPLATE` / `TOO_MANY_REFERENCES` / `REFERENCE_UNREADABLE` / `SLIDE_COUNT_OUT_OF_RANGE` / `STYLE_FIELDS_CONFLICT` (`ValidationError`; the CLI picks the mode from the flags, so it never sends a conflict), 422 `PROMPT_NEEDS_DETAIL` (the prompt gave no subject: ask the user and generate again), 422 `EDIT_NOT_APPLIED` (the instruction changed nothing) / `EDIT_NEEDS_NEW_CAROUSEL` (a different template is a new `carousels:generate`), 403 `IMAGE_CREDIT_LIMIT_EXCEEDED` (`AuthError`), 404 `CAROUSEL_NOT_FOUND` (not in this workspace, or older than 7 days), 429 `CAROUSEL_REQUESTS_LIMIT` (too many being made in this workspace at once: wait for one), 502 `CAROUSEL_GENERATION_FAILED` / `AI_SERVICE_UNAVAILABLE` / `MEDIA_PERSISTENCE_FAILED` and 504 `AI_SERVICE_TIMEOUT` (safe to retry; a failed request charges nothing), 507 `MEDIA_STORAGE_LIMIT_EXCEEDED`. Never retry a generate that timed out on your side without checking the media library: the carousel may have been made and charged. +**Errors:** 422 `VALIDATION_ERROR` (e.g. `--slides` outside 3–10) / `UNKNOWN_TEMPLATE` / `TOO_MANY_REFERENCES` / `REFERENCE_UNREADABLE` / `SLIDE_COUNT_OUT_OF_RANGE` / `STYLE_FIELDS_CONFLICT` (`ValidationError`; the CLI picks the mode from the flags, so it never sends a conflict), 422 `PROMPT_NEEDS_DETAIL` (the prompt gave no subject: ask the user and generate again), 422 `EDIT_NOT_APPLIED` (the instruction changed nothing) / `EDIT_NEEDS_NEW_CAROUSEL` (a different template is a new `carousels:generate`), 403 `IMAGE_CREDIT_LIMIT_EXCEEDED` (`AuthError`), 404 `CAROUSEL_NOT_FOUND` (not in this workspace, or older than 7 days), 429 `CAROUSEL_REQUESTS_LIMIT` (too many being made in this workspace at once: wait for one), 502 `CAROUSEL_GENERATION_FAILED` / `AI_SERVICE_UNAVAILABLE` / `MEDIA_PERSISTENCE_FAILED` and 504 `AI_SERVICE_TIMEOUT` (safe to retry; a failed request charges nothing), 507 `MEDIA_STORAGE_LIMIT_EXCEEDED`. Never retry a generate that timed out on your side without checking the media library: the carousel may have been made and charged. ### Lookup tables (read) diff --git a/skills/contentstudio/SKILL.md b/skills/contentstudio/SKILL.md index ec6ab46..7a3c98a 100644 --- a/skills/contentstudio/SKILL.md +++ b/skills/contentstudio/SKILL.md @@ -503,7 +503,7 @@ A carousel is an ordered set of slides plus one caption, made with the same temp |---------|---------| | `carousels:templates` | Every template: `id`, `name`, `best_for`, `sizes`, `preview_url` (the cover as a PNG), `preview_html_url` (the same cover as HTML) | | `carousels:estimate --prompt "..." [style flags]` | Credits as `{min, max}`. Nothing generated or charged | -| `carousels:generate --prompt "..." [--slides 2-10] [--size portrait\|square] [--template \| --description "..." --reference (repeatable, max 2)] [--no-brand-colours] [--use-brand\|--no-use-brand] [--language] [--dry-run]` | Generate a carousel. Prints the slides' media IDs and the caption | +| `carousels:generate --prompt "..." [--slides 3-10] [--size portrait\|square] [--template \| --description "..." --reference (repeatable, max 2)] [--no-brand-colours] [--use-brand\|--no-use-brand] [--language] [--dry-run]` | Generate a carousel. Prints the slides' media IDs and the caption | | `carousels:edit --instruction "..." [--slide ] [--dry-run]` | One plain-language change to a finished carousel (within 7 days) | **Style:** `--template` is a library template. `--description` and/or `--reference` is a custom look (the AI picks how to make it). Neither lets the AI choose the look. `--template` with `--description`/`--reference` is a `ConfigError`. `--no-brand-colours` keeps the template's own colours (the logo stays when brand is on). Omitting `--use-brand` follows the workspace's brand setting. @@ -521,7 +521,7 @@ contentstudio posts:create --account --post-type carousel --publish-type scheduled --scheduled-at "2026-10-16 09:00:00" ``` -**Errors:** 422 `UNKNOWN_TEMPLATE` / `TOO_MANY_REFERENCES` / `REFERENCE_UNREADABLE` / `SLIDE_COUNT_OUT_OF_RANGE` / `STYLE_FIELDS_CONFLICT` (`ValidationError`; the CLI picks the mode from the flags, so it never sends a conflict), 422 `PROMPT_NEEDS_DETAIL` (the prompt gave no subject: ask the user and generate again), 422 `EDIT_NOT_APPLIED` (the instruction changed nothing) / `EDIT_NEEDS_NEW_CAROUSEL` (a different template is a new `carousels:generate`), 403 `IMAGE_CREDIT_LIMIT_EXCEEDED` (`AuthError`), 404 `CAROUSEL_NOT_FOUND` (not in this workspace, or older than 7 days), 429 `CAROUSEL_REQUESTS_LIMIT` (too many being made in this workspace at once: wait for one), 502 `CAROUSEL_GENERATION_FAILED` / `AI_SERVICE_UNAVAILABLE` / `MEDIA_PERSISTENCE_FAILED` and 504 `AI_SERVICE_TIMEOUT` (safe to retry; a failed request charges nothing), 507 `MEDIA_STORAGE_LIMIT_EXCEEDED`. Never retry a generate that timed out on your side without checking the media library: the carousel may have been made and charged. +**Errors:** 422 `VALIDATION_ERROR` (e.g. `--slides` outside 3–10) / `UNKNOWN_TEMPLATE` / `TOO_MANY_REFERENCES` / `REFERENCE_UNREADABLE` / `SLIDE_COUNT_OUT_OF_RANGE` / `STYLE_FIELDS_CONFLICT` (`ValidationError`; the CLI picks the mode from the flags, so it never sends a conflict), 422 `PROMPT_NEEDS_DETAIL` (the prompt gave no subject: ask the user and generate again), 422 `EDIT_NOT_APPLIED` (the instruction changed nothing) / `EDIT_NEEDS_NEW_CAROUSEL` (a different template is a new `carousels:generate`), 403 `IMAGE_CREDIT_LIMIT_EXCEEDED` (`AuthError`), 404 `CAROUSEL_NOT_FOUND` (not in this workspace, or older than 7 days), 429 `CAROUSEL_REQUESTS_LIMIT` (too many being made in this workspace at once: wait for one), 502 `CAROUSEL_GENERATION_FAILED` / `AI_SERVICE_UNAVAILABLE` / `MEDIA_PERSISTENCE_FAILED` and 504 `AI_SERVICE_TIMEOUT` (safe to retry; a failed request charges nothing), 507 `MEDIA_STORAGE_LIMIT_EXCEEDED`. Never retry a generate that timed out on your side without checking the media library: the carousel may have been made and charged. ### Lookup tables (read) diff --git a/src/commands/carousels.ts b/src/commands/carousels.ts index c8a985f..bcb642f 100644 --- a/src/commands/carousels.ts +++ b/src/commands/carousels.ts @@ -29,7 +29,7 @@ function optionFlags(y: Argv) { .option("prompt", { type: "string", describe: `Required. Max ${MAX_PROMPT_LENGTH} chars.` }) .option("slides", { type: "number", - describe: "slide_count (2-10). Omit it and the AI chooses the length.", + describe: "slide_count (3-10). Omit it and the AI chooses the length.", }) .option("size", { type: "string",