skillZs
★ LIVE SKILL TAGS ★
>>> LIVE SKILLS INDEX <<<
* OPEN SOURCE *
NO LOGIN, NO TRACKING
※ REAL INSTALL DATA ※
← back to all skills
contentstudioio/contentstudio-agent120 installs

contentstudio

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.

How do I install this agent skill?

npx skills add https://github.com/contentstudioio/contentstudio-agent --skill contentstudio
view source ↗

Is this agent skill safe to install?

  • Gen Agent Trust Hubpass

    The skill provides a legitimate interface for managing social media via the ContentStudio API. It follows security best practices for credential storage and includes safety instructions for human verification of actions. It is classified as low risk due to the inherent surface for indirect prompt injection when processing social media comments and messages.

  • Socketpass

    No alerts

  • Snykpass

    Risk: LOW · No issues

What does this agent skill do?

Hosted MCP server (Cursor, Grok Bot, Grok Build plugins)

When this skill is installed as a plugin, it also connects the hosted ContentStudio MCP server at https://mcp.contentstudio.io/mcp. The user signs in with their ContentStudio account through OAuth on first use. No API key is needed.

If ContentStudio MCP tools are available (for example fetch_workspaces, fetch_posts, create_post), use them first. Use the CLI below only when the MCP tools are not connected, or for a task the MCP tools don't cover. Before any tool that publishes, deletes, or approves a post, show the user what will happen and wait for them to confirm.

Install ContentStudio CLI if it doesn't exist

npm install -g contentstudio-cli
# or
pnpm install -g contentstudio-cli

npm release: https://www.npmjs.com/package/contentstudio-cli contentstudio-agent github: https://github.com/contentstudioio/contentstudio-agent contentstudio API docs: https://api.contentstudio.io/api-docs official website: https://contentstudio.io


PropertyValue
namecontentstudio
descriptionSocial-media automation CLI for scheduling posts and managing media/accounts via the ContentStudio public API
allowed-toolsBash(contentstudio:*)

⚠️ Authentication Required

You MUST authenticate before running any contentstudio CLI command. All commands will fail without a valid API key.

Before doing anything else, check auth status:

contentstudio auth:status

If has_api_key is false, authenticate one of two ways. The user can generate a key from ContentStudio Dashboard → Settings → API Keys.

  1. API key (interactive) — stores the key in the CLI config file:
contentstudio auth:login --api-key cs_...
  1. Environment variable (headless / agent runtimes) — the CLI reads CONTENTSTUDIO_API_KEY from the environment and it takes precedence over the config file:
export CONTENTSTUDIO_API_KEY=cs_...

Headless deployment note (OpenClaw, CI, daemons): a shell export does not persist to a service process. Set CONTENTSTUDIO_API_KEY in the agent's actual environment — e.g. systemd Environment= (systemctl edit), an EnvironmentFile=, or Docker -e / compose environment: — then restart the service. Runtimes that gate on declared requirements (e.g. OpenClaw's requires.env) will stay blocked until this variable is present in the process environment.

Then verify a workspace is selected:

contentstudio --json workspaces:current

If active_workspace_id is null, list workspaces and ask the user to pick one:

contentstudio --json workspaces:list
contentstudio workspaces:use <workspace_id>

Invocation rules for agents

  • Always pass --json before the subcommand for stable, parseable output.
  • Envelope shape:
    • Success: {"ok": true, "data": <payload>, "pagination"?: {...}}
    • Error: {"ok": false, "error": {"type": "<ErrorType>", "message": "...", "http_status": <int>, "hint": "..."}}
  • Exit codes are non-zero on error. Check both returncode and ok.
  • Parse stdout only — human messages go to stderr.
  • Before any mutating action (posts/comments/media), run it with --dry-run first to verify the payload is correct. --dry-run never touches the API.

Confirm the target workspace before mutating actions

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, 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.

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.

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.)

Pattern:

  1. Run contentstudio --json workspaces:current to see what's active.
  2. Tell the user: "Your active workspace is <name> (<id>). Do you want to connect/post/delete in this workspace, or a different one?"
  3. If they say a different one, run workspaces:list, let them pick, then either:
    • Run workspaces:use <id> to switch the default, or
    • Pass --workspace <id> on the single mutating call (preferred when it's a one-off — does not change the active workspace).
  4. Only then run the mutating command.

This is mandatory even when the user's request seems to imply the active workspace ("connect a Facebook page", "create a draft post") — they may have just switched contexts in their head and forgotten which workspace is active in the CLI.

Pagination — be proactive, don't silently truncate

All list commands return a pagination block in JSON mode when more results exist than fit on one page:

{
  "ok": true,
  "data": [ /* current page of items */ ],
  "pagination": {
    "current_page": 1,
    "per_page": 10,
    "total": 48,
    "last_page": 5,
    "from": 1,
    "to": 10,
    "has_more": true
  }
}

Mandatory rule: Whenever pagination.has_more === true, the user has more data than what was returned. You MUST NOT silently treat the current page as "all results". Pick one of these three strategies:

  1. Ask the user (default for ambiguous requests):

    "I retrieved 10 of your 48 workspaces. Do you want me to fetch the rest, or is the first 10 enough for what you're doing?"

  2. Auto-paginate — if the user's request implies they want everything (e.g. "list ALL my accounts", "show every draft post", "delete all queued posts"):

    • Call again with --per-page <total> to get everything in one round-trip:
      contentstudio --json workspaces:list --per-page 48
      
    • Or iterate --page 2, --page 3, … --page <last_page> if total is large (>200) and you want bounded pages.
  3. Filter, don't paginate — if the user asked for something specific (e.g. "Facebook accounts only"), use the relevant filter flag (--platform facebook, --search "...", --status draft) instead of paginating. Smaller result set = no pagination needed.

Quick decision tree for the agent

Did the user say "all" / "every" / "complete list" / "every single"?
  → YES: auto-paginate using --per-page <pagination.total>
  → NO:
      Did the user give a specific count? ("show me top 5", "first 20 posts")
        → YES: respect that count; use --per-page accordingly
        → NO:
            pagination.has_more === true?
              → YES: ASK the user before assuming you have everything
              → NO: you have all the data; proceed

Examples

User: "list my workspaces" Agent should:

  1. Run contentstudio --json workspaces:list --per-page 50 (high default to often avoid pagination)
  2. If pagination.has_more is still true, say: "I see 50 of N workspaces. Want me to fetch all N?"

User: "delete all my draft posts" Agent should:

  1. Run contentstudio --json posts:list --status draft --per-page 1 to peek at total
  2. Run contentstudio --json posts:list --status draft --per-page <total> to get them all
  3. Iterate over data[] and delete each
  4. Never delete just the first page and report "done"

User: "show me my Facebook accounts" Agent should:

  1. Use --platform facebook filter — usually returns 0 or a handful, no pagination concern
  2. If has_more still true (>20 FB accounts), ask before auto-fetching

Endpoints that paginate

All *:list commands paginate: workspaces:list, accounts:list, posts:list, comments:list, media:list, campaigns:list, categories:list, labels:list, team:list, approval-workflows:list, ai-video:jobs.

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.


Command Reference

All commands are invoked as contentstudio <group>:<command>.

Authentication

CommandPurpose
auth:login --api-key cs_...Store and verify API key
auth:logoutForget stored credentials
auth:whoamiHit /me and return user info
auth:statusShow local config (key redacted)

Workspaces

CommandPurpose
workspaces:listList user's workspaces
workspaces:use <id>Set active workspace
workspaces:currentShow active workspace
workspaces:create --name <n> --logo <url> --timezone <tz> [--super-admin-id <id>] [--note <t>] [--instagram-posting-method api|mobile] [--first-day-day <Day> --first-day-key <0-6>]Create a new workspace (NOT workspace-scoped)
workspaces:update [<id>] [--name] [--logo] [--timezone] [--note] [--instagram-posting-method] [--first-day-day --first-day-key]Update a workspace (defaults to active; ≥1 field required)
workspaces:delete <id>Delete a workspace

workspaces:create / workspaces:update:

  • --name ≤35 chars, letters/spaces/digits/period only.
  • --logo must be a URL; --timezone is an IANA string (e.g. Asia/Karachi).
  • --super-admin-id (create only) — account owner to create under; required when you manage multiple super admins.
  • First day of week is expressed as two paired flags: --first-day-day <Sunday..Saturday> + --first-day-key <index> where the key is the day's index (Sunday=0 … Saturday=6). Both build first_day: {day, key}.
  • workspaces:update defaults to the active workspace if <id> is omitted and requires at least one field.
  • Errors: WORKSPACE_DELETE_FAILED (422) on delete failure; 404 when the workspace doesn't exist.

Social accounts (read + connect)

CommandPurpose
accounts:list [--platform <p>] [--search <q>]List connected social accounts
platforms:listList platforms supported for new account connections
accounts:connect <platform>Generate a one-time OAuth URL to connect a new account
accounts:connect <platform> --reconnect --account-id <id>Refresh an expired/invalid account
accounts:add-bluesky --handle <h> --app-password <p>Connect a Bluesky account (no browser — uses app password)
accounts:add-facebook-group --name <n> [--image <url>]Manually add a Facebook Group connection
accounts:remove <account_id>Remove (disconnect) a social account. account_id is the account's id from accounts:list. Requires the save_social permission (403 otherwise).

--platform values for accounts:list filter: facebook, linkedin, twitter, instagram, youtube, tiktok, pinterest, gmb.

<platform> values for accounts:connect: facebook, facebook-profile, instagram, instagram-via-facebook, twitter, linkedin, pinterest, tiktok, youtube, threads, gmb, tumblr.

Account-connection flow for AI agents:

  1. Run platforms:list to see what's supported and which method each uses (oauth / credentials / manual).
  2. For OAuth platforms (most), call accounts:connect <platform> and surface the returned URL to the user — they open it in their browser to authorize. The CLI itself never handles credentials.
  3. For Bluesky, ask the user for their handle + app-password (link them to https://bsky.app/settings/app-passwords) and call accounts:add-bluesky.
  4. For Facebook Groups, just call accounts:add-facebook-group --name "...".

Posts

CommandPurpose
posts:list [--status draft|scheduled|...] [--date-from] [--date-to]List posts
posts:create -c "text" -i <account> -t <publish_type> [-s "YYYY-MM-DD HH:MM:SS"] [-m <image_url>]Create a post (shortcut mode)
posts:create -c "text" -t content_category --content-category-id <cat_id>Create a content-category post (accounts come from the category)
posts:create -c "text" -i <fb_account> -t draft --facebook-carousel '<json>'Create a Facebook carousel post (2–10 cards)
posts:create -c "text" -i <threads_account> -t draft --threads '<json>'Create a Threads multi-thread (chained) post (max 10 items)
posts:create -c "text" -i <twitter_account> -t draft --twitter '<json>'Create a Twitter/X threaded-tweet post (max 10 tweets)
posts:create -c "text" -i <account> -t draft --first-comment "..." --first-comment-account <id>Create a post with a first comment
posts:create -c "text" -i <linkedin_account> -t draft --post-type poll --linkedin-options '<json>'Create a LinkedIn poll post (text-only)
posts:create -c "text" -i <ig_account> -t draft --post-type reel --video-url <url> --instagram-trial-reelCreate an Instagram trial reel (shown to non-followers first)
posts:create -c "common text" -i <fb_account> -i <tiktok_account> -t draft -m <img_url> --platform-overrides '<json>'Same post to multiple platforms with a per-platform content override
posts:create --body /path/to/body.jsonCreate a post with full JSON body
posts:update <post_id> [same flags as posts:create]Update an existing post (same body). Rejected (422) once the post is published/processing
posts:delete <post_id> [--delete-from-social]Delete a post
posts:approve <post_id> [--comment "..."]Approve a pending post
posts:reject <post_id> [--comment "..."]Reject a pending post

-t / --publish-type values: scheduled, draft, queued, content_category.

posts:update <post_id> takes the exact same flags and body as posts:create (both --body and shortcut mode) — it PUTs to /workspaces/{w}/posts/{post_id}. The backend allows the update only while the post's status is not published or processing (otherwise it returns 422). Use --approval-workflow-action (below) on update to change an already-attached workflow.

posts:create / posts:update shortcut-mode flags:

  • -c / --content (required) — post text.
  • -i / --account <id> (repeatable) — account ID(s) to post to. Required UNLESS --content-category-id is given.
  • --content-category-id <id> — sets top-level content_category_id. Required by the backend when --publish-type content_category. When set, accounts are derived from the category, so --account is not required (and may be omitted). Use this instead of --account for content-category posts.
  • -s / --scheduled-at "YYYY-MM-DD HH:MM:SS" — scheduling time. The CLI normalizes any parseable date to YYYY-MM-DD HH:MM:SS (the backend's required date_format) and sends it as a plain wall-clock string. The API reads it in the workspace's timezone, not UTC — so pass the local time the user wants the post to fire at, and get the zone from workspaces:current if you're unsure. scheduling:best-times already returns slots in that zone, so they can be passed straight through.
  • -m / --image-url <url> (repeatable), --video-url <url>, --media-id <id> (repeatable) — media.
  • --post-type <type> — e.g. feed, reel, carousel, story, poll. A carousel is auto-derived by the backend when post_type=carousel and 2+ images are attached. A poll requires --post-type poll and a text-only --linkedin-options poll block (no media).
  • --label <id> (repeatable, max 20) → labels.
  • --campaign-id <id> → campaign_id.
  • --linkedin-options '<json>' → linkedin_options (LinkedIn accounts). Pass a JSON object; the CLI parses it locally (invalid JSON → ConfigError) and sends it verbatim.
    • Shape: { "title"?: <string>, "poll"?: { "question": <≤140>, "options": <string[2..4], each ≤30>, "duration": "ONE_DAY" | "THREE_DAYS" | "SEVEN_DAYS" | "FOURTEEN_DAYS" } }
    • A poll must be paired with --post-type poll and text-only content (no images/video). Backend validates and 422s on violations.
  • --facebook-collaborator <user_id> (repeatable, max 10) → facebook_options.collaborators (Facebook accounts). Merges with --facebook-carousel / --facebook-background-id.
  • --instagram-collaborator <user_id> (repeatable, max 3) → instagram_options.collaborators (Instagram accounts). Rejected (422) together with --instagram-trial-reel.
  • --instagram-trial-reel (boolean, default false) → instagram_options.trial_reel.enabled. Publishes an Instagram trial reel — shown to non-followers first, so it does not appear on the profile grid or in follower feeds.
    • --instagram-trial-reel-graduation SS_PERFORMANCE|MANUAL (default SS_PERFORMANCE) → instagram_options.trial_reel.graduation_strategy. SS_PERFORMANCE lets Instagram auto-graduate it to followers if it performs well; MANUAL requires graduating it by hand in the Instagram app (Instagram has no API for that).
    • Requires --post-type reel exactly (not feed+reel) and a video — feed/carousel/story are rejected. The CLI does not pre-validate this; the backend returns 422.
    • Rejected (422) together with --instagram-collaborator. Share-to-story is silently dropped (not rejected) when combined with a trial reel.
    • Not available when the workspace posts to Instagram via the mobile app (instagram_posting_option=mobile).
  • --platform-overrides '<json>' → platform_overrides (top-level, works across any platform in the post). Pass a JSON object keyed by platform (facebook, instagram, twitter, linkedin, pinterest, youtube, tiktok, gmb, tumblr, threads, bluesky, telegram); the CLI parses it locally (invalid JSON → ConfigError) and sends it verbatim.
    • Shape per platform: { "content": { "text"?: <string>, "post_type"?: <string>, "media"?: { "images"?: <url[] ≤10>, "video"?: <url> } } }.
    • text and post_type each merge independently with the common top-level content — an override with only media still inherits the common text/post_type.
    • media is atomic: if an override's content includes a media key at all, that platform's media is defined ENTIRELY by the override (no per-field fallback to the common media for whichever of images/video it omits). Omitting media entirely inherits the common content.media wholesale. This exists because some platforms (e.g. TikTok) can never support mixed images+video.
    • Omitting --platform-overrides entirely publishes the same top-level content to every targeted platform.
    • Override images are URLs only (no media_ids) and follow the same validation as the top-level media (max 10 images, no mixing images+video in one override).
  • Approval — two mutually-exclusive systems (pass only one):
    • Legacy --approver <user_id> (repeatable) + --approve-option anyone|everyone (default anyone) + --approval-notes "..." → builds approval: {approvers, approve_option, notes} only when at least one approver is given. The post creator cannot be an approver. anyone = any single approver; everyone = all must approve.
    • Workflow --approval-workflow-id <id> + --approval-workflow-notes "..." → approval_workflow: {workflow_id, notes?} — ATTACH a workflow (works on both create and update). Get the id from approval-workflows:list (its id).
    • Workflow (update only) --approval-workflow-action restart|resume|renotify_current|keep|remove + --approval-workflow-notes "..." → approval_workflow: {workflow_action, notes?} — mutate the already-attached workflow. Only valid on posts:update.
    • Exactly one of --approval-workflow-id / --approval-workflow-action, and --approver cannot be combined with either --approval-workflow-* flag. The CLI errors locally (ConfigError) if these rules are broken.
  • --facebook-background-id <id> → facebook_options.facebook_background_id (plain-text Facebook posts only; rejected if media is attached). Get a valid id from facebook:text-backgrounds.
  • --facebook-carousel '<json>' → facebook_options.carousel (Facebook accounts only). Pass a JSON object; the CLI parses it locally (invalid JSON → ConfigError) and adds is_carousel_post: true. It merges with --facebook-background-id (neither clobbers the other). The backend validates card counts/CTA/limits and returns a 422 if they're wrong.
    • Shape: { "cards": [ { "image": <url, required>, "link": <url, required>, "title"?: <≤255>, "description"?: <≤1000> } ], "call_to_action"?, "end_card"?: <bool>, "end_card_url"?: <url>, "accounts"?: <string[]> }
    • MIN 2, MAX 10 cards. The Facebook account ID(s) still go in the top-level -i / --account (or in carousel.accounts).
    • call_to_action is one of 33 values: NO_BUTTON, ADD_TO_CART, APPLY_NOW, BET_NOW, BOOK_TRAVEL, BUY_NOW, BUY_TICKETS, CALL_NOW, CONTACT_US, DOWNLOAD, GET_DIRECTIONS, GET_OFFER, GET_QUOTE, GO_LIVE, INSTALL_MOBILE_APP, LEARN_MORE, LIKE_PAGE, LISTEN_MUSIC, OPEN_LINK, ORDER_NOW, PLAY_GAME, REGISTER_NOW, REQUEST_TIME, SAVE, MESSAGE_PAGE, WHATSAPP_MESSAGE, SHOP_NOW, SIGN_UP, SUBSCRIBE, USE_APP, WATCH_MORE, WATCH_VIDEO.
  • --threads '<json>' → threads_options (Threads accounts only). Pass a JSON array of thread items; the CLI parses it locally (invalid JSON → ConfigError), sets has_multi_threads: true and multi_threads: <array>. The Threads account ID goes in the top-level -i / --account.
    • Shape: [ { "message": <string>, "media"?: <url[] ≤10>, "media_ids"?: <string[] ≤10> } ]
    • MAX 10 items. Each item needs message OR media. Threads allows mixed media. Backend validates limits and returns a 422 if exceeded.
  • --twitter '<json>' → twitter_options (Twitter/X accounts only). Pass a JSON array of tweet items; the CLI parses it locally (invalid JSON → ConfigError), sets has_threaded_tweets: true and threaded_tweets: <array>. The Twitter account ID goes in the top-level -i / --account. This mirrors --threads but for Twitter threaded tweets.
    • Shape: [ { "message": <string>, "media"?: <url[] ≤10>, "media_ids"?: <string[] ≤10> } ]
    • MAX 10 tweets. Each item needs message OR media. Twitter does NOT allow mixed media in one tweet (no images + video together) and max 1 video per tweet. The CLI does not validate tweet contents — the backend enforces these limits and returns a 422 if violated.
  • --first-comment "<message>" → first_comment (≤2000 chars). The CLI builds first_comment: { message, accounts? }. The accounts are supplied with --first-comment-account <id> (repeatable).
    • --first-comment-account <id> (repeatable) → first_comment.accounts. The backend REQUIRES at least one account when a --first-comment message is given, and the accounts must be a subset of the post's main --account IDs. The CLI does not hard-block client-side — if you omit --first-comment-account, the backend returns a 422.

(--facebook-carousel, --facebook-collaborator, --instagram-collaborator, --instagram-trial-reel, --instagram-trial-reel-graduation, --linkedin-options, --platform-overrides, --threads, and --twitter only apply in shortcut mode. The --body JSON mode already supports facebook_options (carousel + collaborators), instagram_options (collaborators + trial_reel), linkedin_options, threads_options, twitter_options, first_comment, approval, approval_workflow, and top-level platform_overrides natively — use it for posts that mix multiple platform option blocks.)

The posts:list payload now includes linkedin_options and approval_workflow per post (in addition to the existing fields) — they surface automatically in the --json output.

Scheduling — best time to post

CommandPurpose
scheduling:best-timesRanked posting slots for the workspace, derived from the connected accounts' history
scheduling:best-times --account <platform>:<account_id>Restrict the analysis to specific accounts (repeatable)
scheduling:best-times --global-slots <n> --per-account-slots <n>How many recommendations to return (1–24 each)
scheduling:best-times --entities '<json>'Full entity array, for per-account slot counts

A slot is one recommended posting time: a weekday and an hour. Slots come back ranked best-first, so --global-slots 3 means the three best hours to post.

  • Times are always in the workspace timezone, echoed as meta.timezone. There is no timezone parameter. That is the same clock posts:create --scheduled-at writes against, so a slot can be scheduled as-is — do not convert it to UTC first.
  • Omit --account to analyse every connected account. Otherwise pass <platform>:<account_id> where both halves come from one accounts:list row (its platform and _id), e.g. --account facebook:<account_id>. Supported platforms: facebook, instagram, linkedin, twitter, tiktok, youtube, pinterest, threads, gmb, tumblr, bluesky, telegram.
  • --entities '[{"id":"<account_id>","type":"facebook","slots":3}]' is the escape hatch for a different slot count per account; it cannot be combined with --account.
  • --global-slots (API default 5) sizes the pooled global view; --per-account-slots (API default 3) sizes each account's list. Both are 1–24 and are validated by the CLI before the call. Neither changes the underlying analysis or the heatmap_matrix, which always carries every hour that had signal.

Response shape (data in the JSON envelope):

  • meta — {generated_at, timezone, warnings[], missing_entities[], ai_fallback_entities[]}.
  • global — pooled across analysed accounts: top_recommendations[] (each {rank, day, date, time, score, platform_breakdown}, where time is the hour as a bare string, e.g. "14" = 14:00), plus heatmap_matrix.data (sparse [hour, day_index, score] triples, day_index 0 = Monday) and dates_key. null when no account had usable data.
  • individual — the same breakdown keyed by account id, each with platform and source (data_driven or an AI fallback).

A thin workspace still returns HTTP 200. Accounts with too little history come back in meta.missing_entities and global may be null — that is a successful read, not an error. Tell the user which accounts were skipped rather than reporting a failure. Accounts listed in meta.ai_fallback_entities are estimates, not measurements — say so when you present them.

Errors: 422 for unknown accounts or a workspace with no connected accounts; 502 (BackendError) when the optimizer is temporarily unavailable — retry rather than reporting no data.

Reading is safe. scheduling:best-times only reads, so it needs no --dry-run and no workspace confirmation. Scheduling a post from a slot is a mutation, so the usual --dry-run + workspace-confirmation rules apply to that step.

Comments / Internal notes

CommandPurpose
comments:list <post_id>List comments on a post
comments:add <post_id> "message" [--note] [--mention <user_id>]Add public comment or internal note

Media library

CommandPurpose
media:list [--type images|videos] [--sort recent|...]List media assets
media:upload --file <local_path>Upload a local file
media:upload --url <external_url>Import from external URL

AI images

CommandPurpose
images:toolsThe image tools this API can invoke, with each tool's required inputs and control options
images:modelsModel identifiers images:generate accepts
images:brand{configured, enabled} — whether --use-brand will apply anything
images:generate -p "<prompt>"Prompt → image, saved to the media library
images:generate -p "<edit>" --image-url <url>Edit an existing image instead of generating from scratch
images:product-image --product-image-url <url>Restage a product photo
images:headshot --image-url <url>Professional headshot from a photo of a person
images:face-swap --target-image-url <url> --face-image-url <url>Put one image's face onto another's subject
images:outfit-swap --target-image-url <url> --outfit-image-url <url>Virtual try-on
images:upscale --image-url <url>Raise an image's resolution
images:remove-background --image-url <url>Cut the subject out of its background
images:tool <tool_key> --body '<json>'Any tool, with its full control set (this is how you reach image-to-image's style, aspect_ratio, image_resolution, image_quality, multiple attachments, reference_image_urls)

Every generation returns the same payload, and data.media_id is the handle you pass to posts:create --media-id. That two-step is the normal way to publish an AI image — see the generate-then-publish recipe in the Examples section.

{ "ok": true, "data": {
    "media_id": "66f1a2b3c4d5e6f708192a3b",   // → posts:create --media-id
    "url": "https://storage.googleapis.com/.../generated.png",
    "width": 1024, "height": 1024, "mime_type": "image/png",
    "model_used": "nano-banana-pro",            // may differ from --model
    "brand_applied": false,
    "credits": { "consumed": 1, "available": 412 },
    "persist_error": null } }
  • media_id is the durable handle; url is not. Use url for a preview or as the input to the next tool. Do not store it — a url returned alongside a persist_error is a temporary provider link.
  • Check persist_error (or media_id !== null) before calling a 200 done. The image was generated and charged but could not be saved: media_storage_full means the workspace is out of media storage (retrying costs another credit and fails again), anything else is worth one retry. Tell the user to download the url now.
  • Tools chain. A media-library url from one call is valid input to the next (generate → upscale → remove-background). Each call is charged separately.
  • Every image URL you pass in must be publicly fetchable over http(s) by the image service — no auth, no expired signed URL, no private bucket, no local path. Upload a local file with media:upload --file first and pass the returned URL. A URL the service cannot download is ValidationError (IMAGE_INPUT_REJECTED), not a service outage.
  • Generation is slow and billable. The server's deadline is 120s; the CLI waits 150s (--timeout <seconds> to change it). These calls are not retried — the built-in 429/5xx retry is off for them, because re-running a generation can consume a second image credit. Retry deliberately, not in a loop.
  • --model is optional. Omit it for the service default. Costs differ (most models 1 image credit, gpt-image-2 5), so read credits.consumed rather than assuming.
  • model_used is not one of the images:models values — it comes back provider-prefixed (fal-ai/nano-banana-pro, pixelcut/background-removal) and names the model that actually ran after any fallback. Report it; never compare it for equality with --model.
  • images:tools controls describe the underlying tool, not the public payload. Take --resolution / --aspect-ratio values from there, but a control with no matching flag cannot be sent at all — upscale lists model and upscale_factor, and neither is in the API's tool payload. Likewise accepts_instructions: true on headshot and face-swap is not reachable: only images:product-image has --instructions. Sending an unsupported field is dropped in silence, so it will look like it worked.
  • --dimensions is square, square_hd, portrait_4_5 or landscape_16_9, text→image only. Exact pixels are the model's choice — read width/height back. Anything else is rejected by the CLI before the call.
  • Brand knowledge is a boolean, read-only. --use-brand on images:generate only; it is resolved server-side and no brand ID or brand content is ever accepted or returned. --use-brand with no brand profile is brand_applied: false, not an error — images:brand tells you in advance. The tool commands and images:generate --image-url always report brand_applied: false — edits and tools do not apply brand knowledge.
  • --dry-run on every generating command prints the endpoint and body and calls nothing. Use it to show the user the prompt before spending a credit. The three discovery commands are reads and need no --dry-run.
  • Rate limit: 30 requests/minute, shared with the ContentStudio app's own AI usage on the same account. A RateLimitError here needs the full minute.
  • Video tools (image-to-video, motion-control, lip-sync, talking-avatar) are not on this API; asking for one is NotFoundError (TOOL_NOT_FOUND), same as an unknown key.
  • images:tools answering with an empty list means the catalogue is temporarily unreachable, not that the workspace has no tools. Retry rather than telling the user there are none.
  • Sample workspaces are read-only: the three discovery commands work, both generating paths return 403.

AI Video

CommandPurpose
ai-video:toolsList enabled AI video tools (key, label, description?, inputs[], controls[])
ai-video:modelsList every model ai-video:generate / ai-video:estimate can select (key, provider, modes[], supported_resolutions?, supported_durations?, supported_ratios?, supports_audio?, defaults)
ai-video:estimate [--duration] [--model] [--resolution] [--mode text-to-video|image-to-video|reference-to-video] [--audio] [--aspect-ratio] [--enhance-prompt]Real credit/time estimate. Nothing submitted or charged
ai-video:generate --prompt "..." [--image-url | --reference-image-url <url> (repeatable)] [--model] [--duration] [--resolution] [--aspect-ratio] [--audio] [--enhance-prompt] [--style] [--use-brand]Submit an async video generation job
ai-video:run-tool motion-control --image-url <url> --video-url <url>Apply motion from a driving video to a source image
ai-video:run-tool lip-sync --video-url <url> --audio-url <url>Sync a video's mouth movement to an audio track
ai-video:run-tool talking-avatar --image-url <url> --audio-url <url>Animate a still image into a talking avatar from audio
ai-video:jobs [--status queued|processing|completed|failed|cancelled] [--page] [--per-page]Paginated list of video jobs submitted through this API (never chat/internal-tool jobs)
ai-video:job <job_id>Single job status — local snapshot if terminal, live-polled otherwise
ai-video:cancel-job <job_id>Cancel a still-active job. May charge for partial work already consumed

ai-video:generate notes:

  • --prompt is required, max 1000 characters.
  • Omitting both --image-url and --reference-image-url is text-to-video. --image-url switches to image-to-video. --reference-image-url (repeatable, max 8) switches to reference-to-video. --image-url and --reference-image-url are mutually exclusive — the CLI raises a ConfigError locally if both are set.
  • --use-brand (default false) resolves brand assets server-side — there is no --brand-id flag; the backend does not accept one.
  • Response data: { job_id, status_url (relative path to ai-video:job), status, estimated_credits, estimated_seconds? }. Poll ai-video:job <job_id> (or fetch status_url directly) until status is terminal.

ai-video:run-tool <tool_key> notes:

  • tool_key is one of the keys from ai-video:tools — currently motion-control, lip-sync, talking-avatar. The CLI validates the required input pair locally before sending (motion-control → image+video, lip-sync → video+audio, talking-avatar → image+audio) and raises ConfigError if a required flag is missing.
  • No upfront estimate — the response's estimated_credits / estimated_seconds are always null. Run ai-video:tools to see each tool's declared inputs[] / controls[] if the flags above don't cover a newer tool.

ai-video:jobs / ai-video:job / ai-video:cancel-job notes:

  • ai-video:jobs only ever returns jobs submitted through this API — chat/internal-tool-generated jobs never appear.
  • Job status is one of queued, processing, completed, failed, cancelled. stage / message / result / credits / brand_applied are present depending on status.
  • ai-video:cancel-job 409s (ConflictError) with JOB_ALREADY_TERMINAL if the job already finished, failed, or was cancelled — check ai-video:job <job_id> first if unsure.

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).

Lookup tables (read)

CommandPurpose
campaigns:listList campaigns (folders)
categories:listList content categories
labels:listList labels
team:listList workspace team members
approval-workflows:listList approval workflows (use an item's id as --approval-workflow-id)

Each approval-workflows:list item is { id, name, is_default, levels: [{ level_number, title, rule, members: [{ user_id }] }] }. Use id as posts:create / posts:update's --approval-workflow-id.

Labels (write)

CommandPurpose
labels:create --name <n> --color <color_N>Create a label
labels:update <label_id> [--name] [--color]Update a label
labels:delete <label_id>Delete a label

Campaigns (write)

CommandPurpose
campaigns:create --name <n> --color <color_N>Create a campaign
campaigns:update <campaign_id> [--name] [--color]Update a campaign
campaigns:delete <campaign_id>Delete a campaign

For labels and campaigns: --name ≤100 chars; --color is one of the enum values color_1 … color_20. On update, pass --name and/or --color (each is required-if-present).

Team members (write)

CommandPurpose
team:add --email <e> --role <r> [--membership team|client] [--permissions '<json>']Invite a member
team:update <member_id> --role <r> --permissions '<json>' [--membership]Update a member's role/permissions
team:remove <member_id> [--confirmed]Remove a member
  • member_id is the membership id — the member_id field from team:list (not the user's id, a distinct field).
  • --role (required): admin, approver, or collaborator.
  • --email (required for team:add): a single email address.
  • --membership (optional): team (internal) or client (external; hidden from internal notes). Default team.
  • --permissions (optional for team:add, required for team:update): a role-aware JSON object passed as a string (e.g. --permissions '{"addSocial":true}'). Invalid JSON → local ConfigError; invalid role/key combinations → backend 422. team:update is a partial merge — only the keys you send change; a role change drops boolean keys not valid for the new role.
    • Shared booleans (any role): accessSharedFolder, allow_workflow_management.
    • admin: full access — only the hasBillingAccess boolean applies.
    • collaborator booleans: addBlog, addSocial, addSource, addTopic, viewTeam, rescheduleQueue, postsReview, changeFBGroupPublishAs, hasListeningAccess.
    • approver booleans: approverCanEditPost, approverCanAddNotes, approverCanCreatePost (approvers can only approve/reject otherwise).
    • Account-access arrays (any role; must be real connected account IDs in the workspace, else 422): facebook, instagram, threads, twitter, linkedin, pinterest, telegram, youtube, tiktok, tumblr, tumblr_blogs, tumblr_profiles, bluesky, gmb.
    • Blog arrays (any role; not existence-validated): wordpress, medium, shopify, webflow.
    • content_categories (any role; must be real category IDs in the workspace, else 422): array of content-category IDs.
  • team:remove: if the member is in approval workflows / in-flight posts, the backend returns error_code REQUIRES_REMOVAL_CONFIRMATION (422) — re-run with --confirmed (sends ?confirmed=true) to proceed. 404 = TEAM_MEMBER_NOT_FOUND.

Social accounts (write)

CommandPurpose
accounts:remove <account_id> [--dry-run]Remove (disconnect) a social account (DELETE /workspaces/{w}/accounts/{account_id})
  • account_id is the account's id from accounts:list.
  • Requires the save_social permission — callers without it get 403.
  • Errors: 401 (bad/missing API key), 403 (missing save_social), 404 (account not found in the workspace), 422 (removal failed). Success is 200 with an empty data array.
  • Mutating command — preview with --dry-run and confirm the workspace first.

Facebook helpers

CommandPurpose
facebook:text-backgroundsList Facebook colored-background presets (use id as facebook_options.facebook_background_id on plain-text posts)

Social Inbox

The inbox unifies three kinds of item into elements: conversation (DMs), post (a post with comments), and review. inbox:list is the entry point — everything else takes an id it returned.

Which id to pass

Inbox commands take their id from the element_details object on each inbox:list row. Use element_details.element_id — it is accepted by every element-scoped command.

CommandId to pass
inbox:update (--element)element_details.element_id
inbox:tag-attach / inbox:tag-detachelement_details.element_id
inbox:mark-readelement_details.element_id
inbox:contact / inbox:contact-updateelement_details.element_id
inbox:messages / send / notes / note-add / bookmarkselement_details.element_id (t_… form)
inbox:comments / inbox:comment-addelement_details.post_id

Values look like:

  • element_details.element_id — t_10000000000000001 (conversation) or 100000000000000001_200000000000000002 (post)
  • element_details.post_id — 900000000000001_100000000000000001

The row's top-level element_ref is an internal reference, not a command argument — always take the id from element_details.

If a command returns an empty list or reports the item as not found, confirm the id against this table before describing the result to the user.

Also needed for most writes:

  • platform_id — the connected social account the item belongs to. The backend replies through that account's token. It is on every inbox:list row as platform_id, or from accounts:list.
  • The platform field on a list row is platform (not platform_type), but the write commands take --platform-type.

Reading

CommandPurpose
inbox:listSearch the inbox. --type conversation|post|review (repeatable), --action all|marked_done|archived|assigned, --search, --tag, --channels '{"facebook":["<acct>"]}', --page, --limit
inbox:summaryCounts per bucket — cheap way to answer "anything unread?"
inbox:messages <conversation_id>Messages in a DM thread. Id = element_details.element_id. --sort-order asc|desc
inbox:comments <post_id>A post's comments (threaded). Id = element_details.post_id
inbox:notes <conversation_id>Internal notes (team-only). Id = element_details.element_id. Paginated
inbox:bookmarks <conversation_id>Starred messages. Id = element_details.element_id. Paginated
inbox:contact <element_ref>Contact profile behind an element
inbox:tagsThe workspace's inbox tag catalogue

Replying — customer-facing, confirm before sending

CommandPurpose
inbox:send <conversation_id>Send a DM (id = element_details.element_id). Needs --platform-type facebook|instagram, --platform-id, and --message and/or --file. --idempotency-key de-dupes a retry
inbox:comment-add <post_id>Comment on a post. --comment-id makes it a threaded reply; --private-reply sends a Facebook DM instead; --attachment <path> attaches a file
inbox:review-reply <review_id>Add or replace a review reply (upsert). --platform-id, --reply
inbox:note-add <conversation_id>Add an internal note. --mention <user_id> (repeatable). Not customer-visible

Triage and moderation

CommandPurpose
inbox:mark-read <element_ref>Mark read (idempotent)
inbox:updateBulk state change. --element (repeatable, max 100) plus exactly one of --status done|pending, --archived, --assigned (pair with --assigned-to '{"id":"<user>"}')
inbox:comment-hide / inbox:comment-unhide <comment_id>Hide/unhide. Unhide needs --platform-type + --platform-id
inbox:comment-like / inbox:comment-unlike <comment_id>Facebook only
inbox:comment-delete <comment_id>Delete. Needs --platform-type + --platform-id; LinkedIn also needs --comment-urn
inbox:star / inbox:unstar <message_id>Star a message
inbox:message-delete <message_id>Soft-delete a message. --platform-id
inbox:review-reply-delete <review_id>Remove a review reply. --platform-id
inbox:contact-update <element_ref>--platform-id plus any of --name, --email, --phone, --company

Tags

CommandPurpose
inbox:tag-create--name (≤50), --color — a hex value like #33aa55. (Older tags may display color_1, but the API now rejects that format.)
inbox:tag-update <tag_id>--name and/or --color
inbox:tag-delete--tag <id> (repeatable, bulk)
inbox:tag-mergeFold tags into a new one: --name, --color, --tag (repeatable)
inbox:tag-attach <element_ref>--tag (repeatable), --platform-id, --inbox-type
inbox:tag-detach <element_ref> <tag_id>--platform-id, --inbox-type

Inbox pagination note. Inbox list commands use --limit rather than --per-page (--per-page is accepted as an alias). The pagination rules in the section above apply unchanged: if pagination.has_more is true, do not report the first page as the whole inbox.

Inbox page size is 200. For inboxes larger than that, page through with --page 1, --page 2, … up to pagination.last_page rather than raising --limit past 200.

Inbox limits. The CLI validates these locally, so they surface as a ConfigError before any request is sent:

LimitWhere
--limit ≤ 200inbox:list, inbox:messages, inbox:comments
≤ 100 --element refs per callinbox:update
Exactly one operation per callinbox:update — --status, --archived, and --assigned are mutually exclusive; run separate commands
Tag name ≤ 50 charsinbox:tag-create

Partial success on bulk updates. inbox:update returns HTTP 207 when some elements were updated and others were not, listing the remainder in missing_ids. The CLI reports this as a warning. When missing_ids is non-empty, tell the user which elements did not change rather than reporting the batch as fully applied.

inbox:contact-update updates the whole contact. A contact is a person, not a per-element attribute, so the change applies to every element for that contact on that account in the workspace. The response's updated_count says how many were updated. Mention this scope to the user before running it.

inbox:contact returns personal data. Email and phone of an end customer. Return only the fields the user actually asked for; don't dump the whole record into a summary or paste it somewhere persistent without being asked.

inbox:messages includes activity events. A thread contains both messages and a record of team activity. Activity entries have message: null and an action block (MARKED_AS_DONE, PENDING, ARCHIVED, …) naming the teammate who performed it, and they count toward total_messages and pagination. Filter on action == null when you mean customer messages — don't count activity entries as messages, quote them as customer text, or treat one as the latest reply. The CLI renders them as — marked as done — rows in human mode.

Replies are nested, not paginated. In inbox:comments, replies live under each thread's children — they are not separate top-level rows. Paging counts threads (total_threads), not individual comments, so "12 comments" from the pagination block means 12 threads and there may be many more replies inside.

Handling a 409 on a send. For inbox:send and inbox:comment-add, a 409 means the delivery outcome is undetermined — the message may or may not have reached the customer. The CLI surfaces it as ConflictError. Do not retry automatically: read the conversation back with inbox:messages to check whether it landed, and tell the user what you found before sending again.

Confirming a send. inbox:send returns sent_message.id_status. When it is unavailable, the platform accepted the message without returning an id, so there is no id to reconcile against later — report it as sent, with delivery unconfirmed.

Inbox-specific responses. A 502 from an inbox:* command indicates the inbox service is temporarily unreachable rather than a missing item — retry after a short backoff. An empty inbox:list result is a successful empty read: report it as "no matching conversations", not "not found".

Analytics

Read-only performance reports across Facebook, Instagram, YouTube, Pinterest, LinkedIn, Google Business Profile, TikTok, Twitter/X, Meta Ads and Google Ads, plus cross-network Campaigns & Labels reports (133 commands total, one per backend endpoint — no generic passthrough).

Most commands need --platform-id (the connected account, from accounts:list) plus either a date range or a native post id. The ads and campaign/label families are the exceptions — see below:

  • Date-range reports — --start-date / --end-date (YYYY-MM-DD, both required). Optional on most: --timezone (IANA name, default UTC), --date (alternative 'YYYY-MM-DD - YYYY-MM-DD' form that overrides the range), --limit / --offset, --order-by (choices vary per command — check --help), and array filters like --media-type, --hashtags, --entity-type (repeat the flag for multiple values).
  • Single-item lookups (*-single-post, *-single-pin, *-single-tweet, *-single-video) — --platform-id + --post-id (the platform-native id, not a ContentStudio internal id). No date range.
  • AI insights commands (*-ai-insights) additionally take --type (aiInsightsSummary for the compact card, aiInsightsDetailed for the full report) and --language (ISO 639-1, default en). Both ads platforms have one too.
  • Ads reports (analytics:meta-ads-*, analytics:google-ads-*) take --account-id — an ad account (act_… on Meta, a customer id on Google, from analytics:meta-ads-accounts / analytics:google-ads-accounts) — not --platform-id. Table commands add --limit/--offset, --search, --order-by/--order-dir and id filters (--campaign-id, --ad-set-id, --ad-group-id); chart commands add --metric and --level. analytics:*-ads-accounts needs no account at all — it is how you find one.
  • Campaigns & Labels (analytics:campaigns-labels-*) are the only POST reports: the filters are lists, so repeat the flag — --campaigns <id> --campaigns <id>, --labels <id>, and one account list per network (--facebook-accounts, --instagram-accounts, …). Only --start-date/--end-date are required.

Run contentstudio analytics:<command> --help to see the exact options for any one command — required vs. optional and enum choices differ per endpoint.

Every analytics command is read-only — the campaign/label ones are POSTs only because their filters are arrays — so none of them take --dry-run (that flag only exists on mutating commands elsewhere in this CLI).

If a command returns ANALYTICS_UPSTREAM_ERROR (HTTP 200 with status: false, often upstream_status: 401), that is the ContentStudio backend's own analytics pipeline failing upstream — not a bad request. Report it as "the analytics service is temporarily unavailable," don't retry the exact same call in a loop, and don't treat it as evidence the account/workspace is wrong.

Facebook (12)

CommandPurposeRequired
analytics:facebook-ai-insightsFacebook AI-generated insights--platform-id, --start-date, --end-date
analytics:facebook-audience-growthFacebook fan / follower growth over time--platform-id, --start-date, --end-date
analytics:facebook-audience-locationFacebook audience location (country/city breakdown); --country narrows the city list--platform-id, --start-date, --end-date
analytics:facebook-engagementFacebook page engagements over time--platform-id, --start-date, --end-date
analytics:facebook-get-top-postsFacebook top posts with media_type filter--platform-id, --start-date, --end-date
analytics:facebook-impressionsFacebook page impressions over time--platform-id, --start-date, --end-date
analytics:facebook-overview-top-postsFacebook top posts (overview widget)--platform-id, --start-date, --end-date
analytics:facebook-publishing-behaviourFacebook engagement by impression type over time--platform-id, --start-date, --end-date
analytics:facebook-reelsFacebook Reels performance over time--platform-id, --start-date, --end-date
analytics:facebook-single-postGet a single Facebook post by ID--platform-id, --post-id
analytics:facebook-summaryFacebook summary KPIs — current vs previous period--platform-id, --start-date, --end-date
analytics:facebook-video-insightsFacebook video view time and plays over time--platform-id, --start-date, --end-date

Instagram (15)

CommandPurposeRequired
analytics:instagram-active-usersInstagram active users by hour and day of week--platform-id, --start-date, --end-date
analytics:instagram-ai-insightsInstagram AI-generated insights--platform-id, --start-date, --end-date
analytics:instagram-audience-growthInstagram follower growth over time--platform-id, --start-date, --end-date
analytics:instagram-country-cityInstagram audience country / city breakdown; --country narrows the city list--platform-id, --start-date, --end-date
analytics:instagram-demographics-ageInstagram audience age / gender breakdown--platform-id, --start-date, --end-date
analytics:instagram-engagementInstagram post engagement over time--platform-id, --start-date, --end-date
analytics:instagram-get-top-postsInstagram top posts with hashtag filter--platform-id, --start-date, --end-date
analytics:instagram-hashtagsInstagram top hashtags by engagement--platform-id, --start-date, --end-date
analytics:instagram-impressionsInstagram post impressions over time--platform-id, --start-date, --end-date
analytics:instagram-publishing-behaviourInstagram post engagement by media type over time--platform-id, --start-date, --end-date
analytics:instagram-reels-performanceInstagram Reels engagement and watch time over time--platform-id, --start-date, --end-date
analytics:instagram-single-postGet a single Instagram post by ID--platform-id, --post-id
analytics:instagram-stories-performanceInstagram stories impressions, reach, and interactions over time--platform-id, --start-date, --end-date
analytics:instagram-summaryInstagram summary KPIs — current vs previous period--platform-id, --start-date, --end-date
analytics:instagram-top-postsInstagram top-performing posts--platform-id, --start-date, --end-date

YouTube (20)

CommandPurposeRequired
analytics:youtube-ai-insightsYouTube AI-generated insights--platform-id, --start-date, --end-date
analytics:youtube-demographicsYouTube audience demographics — age & gender, device type, subscriber change--platform-id, --start-date, --end-date
analytics:youtube-engagement-trendYouTube cumulative engagement trend over time--platform-id, --start-date, --end-date
analytics:youtube-engagement-trend-dailyYouTube daily-delta engagement trend--platform-id, --start-date, --end-date
analytics:youtube-find-videoYouTube traffic source breakdown (how viewers found videos)--platform-id, --start-date, --end-date
analytics:youtube-least-postsYouTube least-performing videos ordered by views and engagement--platform-id, --start-date, --end-date
analytics:youtube-performance-scheduleYouTube video performance metrics grouped by publish date--platform-id, --start-date, --end-date
analytics:youtube-publishing-behaviourYouTube posts published over time and content-type breakdown--platform-id, --start-date, --end-date
analytics:youtube-single-videoGet a single YouTube video by ID--platform-id, --post-id
analytics:youtube-sorted-top-postsYouTube videos sorted by a configurable metric--platform-id, --start-date, --end-date
analytics:youtube-subscriber-trendYouTube cumulative subscriber trend over time--platform-id, --start-date, --end-date
analytics:youtube-subscriber-trend-dailyYouTube daily-delta subscriber trend--platform-id, --start-date, --end-date
analytics:youtube-summaryYouTube summary KPIs — current vs previous period--platform-id, --start-date, --end-date
analytics:youtube-top-geographiesYouTube top geographies — countries pre-sorted by views, watch time, view duration and view percentage--platform-id, --start-date, --end-date
analytics:youtube-top-postsYouTube top videos ordered by views and engagement--platform-id, --start-date, --end-date
analytics:youtube-video-sharingYouTube sharing platform breakdown--platform-id, --start-date, --end-date
analytics:youtube-views-trendYouTube cumulative views split by subscriber / non-subscriber--platform-id, --start-date, --end-date
analytics:youtube-views-trend-dailyYouTube daily-delta views trend--platform-id, --start-date, --end-date
analytics:youtube-watch-time-trendYouTube cumulative watch time split by subscriber / non-subscriber--platform-id, --start-date, --end-date
analytics:youtube-watch-time-trend-dailyYouTube daily-delta watch time trend--platform-id, --start-date, --end-date

Pinterest (14)

CommandPurposeRequired
analytics:pinterest-ai-insightsPinterest AI-generated insights--platform-id, --start-date, --end-date
analytics:pinterest-engagement-trendPinterest cumulative engagement trend over time--platform-id, --start-date, --end-date
analytics:pinterest-engagement-trend-dailyPinterest daily-delta engagement trend--platform-id, --start-date, --end-date
analytics:pinterest-follower-trendPinterest cumulative follower trend over time--platform-id, --start-date, --end-date
analytics:pinterest-follower-trend-dailyPinterest daily-delta follower trend--platform-id, --start-date, --end-date
analytics:pinterest-impressions-trendPinterest cumulative impressions trend over time--platform-id, --start-date, --end-date
analytics:pinterest-impressions-trend-dailyPinterest daily-delta impressions trend--platform-id, --start-date, --end-date
analytics:pinterest-pin-performancePinterest pin performance metrics over time--platform-id, --start-date, --end-date
analytics:pinterest-pin-postingPinterest cumulative pin posting activity over time--platform-id, --start-date, --end-date
analytics:pinterest-pin-posting-dailyPinterest daily-delta pin posting activity--platform-id, --start-date, --end-date
analytics:pinterest-pin-rollupPinterest pin performance rollup — current vs previous period--platform-id, --start-date, --end-date
analytics:pinterest-single-pinGet a single Pinterest pin by ID--platform-id, --post-id
analytics:pinterest-summaryPinterest summary KPIs — current vs previous period--platform-id, --start-date, --end-date
analytics:pinterest-top-pinsPinterest top-performing and least-performing pins--platform-id, --start-date, --end-date

LinkedIn (11)

CommandPurposeRequired
analytics:linkedin-ai-insightsLinkedIn AI-generated insights--platform-id, --start-date, --end-date
analytics:linkedin-audience-growthLinkedIn follower growth over time--platform-id, --start-date, --end-date
analytics:linkedin-followers-demographicsLinkedIn follower demographics by industry, country, and other dimensions--platform-id, --start-date, --end-date
analytics:linkedin-get-top-postsLinkedIn top posts with hashtag and media type filter--platform-id, --start-date, --end-date
analytics:linkedin-hashtagsLinkedIn top hashtags by engagement--platform-id, --start-date, --end-date
analytics:linkedin-page-viewsLinkedIn page views over time (desktop vs mobile)--platform-id, --start-date, --end-date
analytics:linkedin-posts-per-daysLinkedIn post count distribution by day of week--platform-id, --start-date, --end-date
analytics:linkedin-publishing-behaviourLinkedIn post engagement by media type over time--platform-id, --start-date, --end-date
analytics:linkedin-single-postGet a single LinkedIn post by ID--platform-id, --post-id
analytics:linkedin-summaryLinkedIn summary KPIs — current vs previous period--platform-id, --start-date, --end-date
analytics:linkedin-top-postsLinkedIn top-performing posts--platform-id, --start-date, --end-date

Google Business Profile (GMB) (10)

CommandPurposeRequired
analytics:gmb-actionsGMB customer actions (clicks, calls, directions) over time--platform-id, --start-date, --end-date
analytics:gmb-ai-insightsGMB AI-generated insights--platform-id, --start-date, --end-date
analytics:gmb-impressionsGMB impressions breakdown by channel and device over time--platform-id, --start-date, --end-date
analytics:gmb-media-activityGMB media (photo/video) activity over time--platform-id, --start-date, --end-date
analytics:gmb-publishing-behaviorGMB posts published over time and topic-type breakdown--platform-id, --start-date, --end-date
analytics:gmb-reviewsGMB reviews — ratings, distribution, and daily activity--platform-id, --start-date, --end-date
analytics:gmb-search-keywordsGMB top search keywords that surfaced the listing--platform-id, --start-date, --end-date
analytics:gmb-single-postGet a single GMB post by ID--platform-id, --post-id
analytics:gmb-summaryGMB summary KPIs — current vs previous period--platform-id, --start-date, --end-date
analytics:gmb-top-postsGMB top-performing posts--platform-id, --start-date, --end-date

TikTok (8)

CommandPurposeRequired
analytics:tiktok-ai-insightsTikTok AI-generated insights--platform-id, --start-date, --end-date
analytics:tiktok-engagement-trendTikTok daily engagement trend over time--platform-id, --start-date, --end-date
analytics:tiktok-follower-trendTikTok follower and views trend over time--platform-id, --start-date, --end-date
analytics:tiktok-publishing-behaviourTikTok daily post volume and engagement breakdown over time--platform-id, --start-date, --end-date
analytics:tiktok-single-postGet a single TikTok post by ID--platform-id, --post-id
analytics:tiktok-sorted-top-postsTikTok posts sorted by a configurable metric--platform-id, --start-date, --end-date
analytics:tiktok-summaryTikTok summary KPIs — current vs previous period--platform-id, --start-date, --end-date
analytics:tiktok-top-postsTikTok top and least performing posts--platform-id, --start-date, --end-date

Twitter/X (7)

CommandPurposeRequired
analytics:twitter-credits-usedTwitter API credits usage for the workspace--platform-id, --start-date, --end-date
analytics:twitter-engagement-impressionTwitter engagement and impression trend over time--platform-id, --start-date, --end-date
analytics:twitter-followers-trendTwitter follower trend over time--platform-id, --start-date, --end-date
analytics:twitter-least-tweetsTwitter least-performing tweets--platform-id, --start-date, --end-date
analytics:twitter-single-tweetGet a single Twitter/X tweet by ID--platform-id, --post-id
analytics:twitter-summaryTwitter summary KPIs — current vs previous period--platform-id, --start-date, --end-date
analytics:twitter-top-tweetsTwitter top-performing tweets--platform-id, --start-date, --end-date

Meta Ads (11)

CommandPurposeRequired
analytics:meta-ads-accountsList connected Meta ad accounts—
analytics:meta-ads-ad-setsAd sets with per-ad-set metrics--account-id, --start-date, --end-date
analytics:meta-ads-adsAds with per-ad metrics and creative details--account-id, --start-date, --end-date
analytics:meta-ads-ai-insightsAI-generated insights for an ad account--account-id, --start-date, --end-date, --type
analytics:meta-ads-campaignsCampaigns with per-campaign metrics--account-id, --start-date, --end-date
analytics:meta-ads-demographicsAudience breakdown by age and gender, region or country--account-id, --start-date, --end-date
analytics:meta-ads-performance-by-levelOne metric broken down by campaign, ad set or ad--account-id, --start-date, --end-date
analytics:meta-ads-performance-by-placementOne metric broken down by publisher platform and placement--account-id, --start-date, --end-date
analytics:meta-ads-performance-over-timeDaily time series for one or more metrics--account-id, --start-date, --end-date
analytics:meta-ads-results-by-objectiveResults and spend grouped by campaign objective--account-id, --start-date, --end-date
analytics:meta-ads-summaryMeta Ads headline KPIs — current vs previous period--account-id, --start-date, --end-date

Google Ads (17)

CommandPurposeRequired
analytics:google-ads-accountsList connected Google Ads accounts—
analytics:google-ads-ad-groupsAd groups with per-ad-group metrics--account-id, --start-date, --end-date
analytics:google-ads-adsAds with per-ad metrics--account-id, --start-date, --end-date
analytics:google-ads-ai-insightsAI-generated insights for an ad account--account-id, --start-date, --end-date, --type
analytics:google-ads-campaignsCampaigns with per-campaign metrics--account-id, --start-date, --end-date
analytics:google-ads-conversion-actionsConversion actions configured on the account--account-id, --start-date, --end-date
analytics:google-ads-conversion-funnelConversion funnel — impressions through to conversions--account-id, --start-date, --end-date
analytics:google-ads-conversions-by-actionConversions grouped by conversion action--account-id, --start-date, --end-date
analytics:google-ads-conversions-over-timeConversions over time--account-id, --start-date, --end-date
analytics:google-ads-demographicsAudience breakdown by age, gender and location--account-id, --start-date, --end-date
analytics:google-ads-keywordsKeywords with per-keyword metrics--account-id, --start-date, --end-date
analytics:google-ads-performance-by-levelOne metric broken down by campaign, ad group or ad--account-id, --start-date, --end-date
analytics:google-ads-performance-by-typeOne metric broken down by campaign type--account-id, --start-date, --end-date
analytics:google-ads-performance-over-timeDaily time series for one or more metrics--account-id, --start-date, --end-date
analytics:google-ads-search-termsSearch terms with per-term metrics--account-id, --start-date, --end-date
analytics:google-ads-shoppingShopping campaign product performance--account-id, --start-date, --end-date
analytics:google-ads-summaryGoogle Ads headline KPIs — current vs previous period--account-id, --start-date, --end-date

Campaigns & Labels (5)

CommandPurposeRequired
analytics:campaigns-labels-breakdownPer-campaign and per-label totals, current vs previous period--start-date, --end-date
analytics:campaigns-labels-insights-breakdownDaily time series per campaign and per label--start-date, --end-date
analytics:campaigns-labels-postsPer-post table for the selected campaigns & labels--start-date, --end-date
analytics:campaigns-labels-summaryCampaign & label summary KPIs — current vs previous period--start-date, --end-date
analytics:campaigns-labels-top-postsTop 5 posts per network for the selected campaigns & labels--start-date, --end-date

Analytics: reports, schedules, share links

Reporting is asynchronous. reports:generate returns an id straight away and the work happens elsewhere, so never treat the create response as a finished report — poll reports:get <id> --wait, or pass --callback-url to be told instead of asking. A report is done when status is completed and export_url is populated; failed is terminal too, and reports:retry re-runs it from the stored definition without rebuilding the request.

Start from reports:options rather than guessing: it returns the report types this workspace can build and the sections each one accepts, and it is the same catalogue the product's own section selector reads.

The two competitor types take a competitor set, not accounts. facebook_competitor and instagram_competitor are built from a saved set, so they need --competitor-report-id (from competitor-reports:list) and ignore --accounts. Putting the set id in --accounts is the natural mistake and is refused before the call goes out — it used to be accepted, dropped, and surface minutes later as "Combined report generation failed".

Share links are how a client sees a report without an account. Create one with share-links:create; --password protects it, --date-range pins the period so the numbers stop moving, and omitting the range leaves it rolling. There is no expiry — a link lives until you disable or delete it, so prefer share-links:disable (reversible) over share-links:delete when a client engagement pauses. A share link is independent of any generated report: it shows the live dashboard, not a PDF.

report-schedules:run asks for an immediate send, but the API acknowledges the request without returning a report id. Confirm with report-schedules:get and check last_run_at moved before telling the user the report went out.

Analytics: competitors

Two different things share the word "report". A competitor report is a saved set of competitors to benchmark against — it has no status and produces no file. The comparison numbers are read separately, with competitors:compare.

Provisioning order matters: competitors:search first, because a competitor is an object (competitor_id plus name), not a bare id. competitor-reports:create accepts the shorthand --competitors 'id:Name,id:Name' or a JSON array, and expands it for you.

A page that cannot be tracked comes back as an empty result with a reason — that is a successful read, not an error. Tell the user which page could not be tracked and why, rather than reporting a failure.

competitor-reports:update replaces the set, so send every competitor you want to keep, not just the new one.

When reading comparisons, respect each row's state. Only Processed means a complete measurement for the period — a competitor in any other state has zeros that mean not measured, not zero engagement. Never present those as a result.


Examples

Verify the stored key is valid

contentstudio --json auth:whoami
# → {"ok": true, "data": {"id": "...", "email": "...", "full_name": "..."}}

Find a Facebook account to post to

contentstudio --json accounts:list --platform facebook --per-page 10
# Pick an _id, e.g. <account_id>

Post-creation examples

Always preview a mutating post with --dry-run first — it returns {"ok": true, "data": {"dry_run": true, "endpoint": "...", "body": {...}}} and never touches the API. Drop --dry-run to actually create.

1. Plain text draft

contentstudio --json posts:create \
  -c "Our new blog is live!" \
  -i <account_id> \
  -t draft

2. Text + single image, scheduled with a date

contentstudio --json posts:create \
  -c "Our new blog is live! https://example.com/post" \
  -i <account_id> \
  -t scheduled \
  -s "2026-05-01 10:00:00" \
  -m https://example.com/hero.jpg

3. Text + multiple images (repeat -m)

contentstudio --json posts:create \
  -c "Gallery drop 📸" \
  -i <account_id> \
  -t scheduled \
  -s "2026-05-02 09:00:00" \
  -m https://example.com/1.jpg \
  -m https://example.com/2.jpg \
  -m https://example.com/3.jpg

4. Text + video

contentstudio --json posts:create \
  -c "Watch our launch reel 🎬" \
  -i <account_id> \
  -t scheduled \
  -s "2026-05-03 12:00:00" \
  --video-url https://example.com/launch.mp4

5. Queued post (goes into the publishing queue; no explicit time)

contentstudio --json posts:create \
  -c "Filler post for the queue" \
  -i <account_id> \
  -t queued

6. Content-category post (accounts come from the category — NO --account)

# Find a category id first:
contentstudio --json categories:list
# --content-category-id is required for -t content_category:
contentstudio --json posts:create \
  -c "Evergreen tip of the day" \
  -t content_category \
  --content-category-id <category_id>

7. Post with an approval workflow (two approvers, all must approve)

contentstudio --json posts:create \
  -c "Quarterly results announcement" \
  -i <account_id> \
  -t scheduled \
  -s "2026-05-05 08:00:00" \
  --approver <user_id_1> \
  --approver <user_id_2> \
  --approve-option everyone \
  --approval-notes "Legal + comms must both sign off"

8. Post with labels and a campaign (repeat --label)

contentstudio --json posts:create \
  -c "Spring sale kickoff" \
  -i <account_id> \
  -t scheduled \
  -s "2026-05-06 10:00:00" \
  --label <label_id_1> \
  --label <label_id_2> \
  --campaign-id <campaign_id>

9. Facebook colored-background text post (plain text, no media)

# Get a valid background id first:
contentstudio --json facebook:text-backgrounds
contentstudio --json posts:create \
  -c "Big news coming soon!" \
  -i <facebook_account_id> \
  -t draft \
  --facebook-background-id <background_id>

10. Facebook CAROUSEL (Facebook only; 2–10 cards) — preview then create

# Preview:
contentstudio --json posts:create --dry-run \
  -c "Shop the new collection" \
  -i <facebook_account_id> \
  -t scheduled \
  -s "2026-07-01 10:00:00" \
  --facebook-carousel '{"cards":[{"image":"https://e.com/1.jpg","link":"https://e.com/p1","title":"Tee","description":"100% cotton"},{"image":"https://e.com/2.jpg","link":"https://e.com/p2","title":"Hoodie"},{"image":"https://e.com/3.jpg","link":"https://e.com/p3","title":"Cap"}],"call_to_action":"SHOP_NOW","end_card":true,"end_card_url":"https://e.com/shop"}'
# Drop --dry-run to create. The CLI adds "is_carousel_post": true.

A carousel and a colored-background text post (--facebook-background-id, example 9) are two different Facebook formats — use one or the other, not both in the same post. The FB account ID goes in -i / --account.

11. Threads multi-thread (Threads only; max 10 chained items) — preview then create

contentstudio --json posts:create --dry-run \
  -c "🧵 A thread on shipping CLIs" \
  -i <threads_account_id> \
  -t draft \
  --threads '[{"message":"1/ Start small."},{"message":"2/ Ship a demo.","media":["https://e.com/demo.mp4"]},{"message":"3/ Iterate in public."}]'
# Drop --dry-run to create. The CLI adds "has_multi_threads": true.

The top-level -c / --content is the lead post; each --threads item is a chained reply, in order. Don't repeat the lead text in the items (number them 1/, 2/, … as the continuation). Each item needs message or media.

12. Post with a first comment (auto-posted comment after publish; e.g. "link in bio") — preview then create

contentstudio --json posts:create --dry-run \
  -c "New drop is live 🎉" \
  -i <account_id> \
  -t draft \
  --first-comment "🔗 link in bio" \
  --first-comment-account <account_id>
# Drop --dry-run to create. --first-comment-account is REQUIRED by the backend
# and must be a subset of the -i / --account IDs, else the API returns a 422.

13. Twitter/X threaded tweets (Twitter only; max 10 tweets) — preview then create

contentstudio --json posts:create --dry-run \
  -c "Why we built a CLI 🧵" \
  -i <twitter_account_id> \
  -t draft \
  --twitter '[{"message":"1/ Start with the contract."},{"message":"2/ Show, don'\''t tell.","media":["https://e.com/x.jpg"]},{"message":"3/ Ship it."}]'
# Drop --dry-run to create. The CLI adds "has_threaded_tweets": true.
# Twitter rule: no mixed media in one tweet (no images+video together), max 1 video per tweet.

The top-level -c / --content is the lead tweet; each --twitter item is a follow-up tweet in the chain, in order. Don't repeat the lead text in the items (number the items 1/, 2/, … as the continuation). Each item needs message or media. The Twitter account ID goes in -i / --account.

14. Full-control body via --body <file.json> (any field the shortcut flags don't cover)

Use --body when you need fields beyond the shortcut flags (per-platform platform_overrides, twitter_options/threads_options, timezone, hide_client, etc.). The JSON is sent as written apart from the id normalization below, so build it for the platform(s) your accounts belong to — a Facebook-carousel body, a Threads body, and a Twitter body are separate posts, not one combined payload.

Reference labels and campaigns by id, not by the object labels:list / campaigns:list returned. The API takes "labels": ["<label_id>"] and "campaign_id": "<campaign_id>", and the schedule time is scheduling.scheduled_at, not execute_time. The CLI rewrites the shapes it recognises ({ "id": … } / { "_id": … }, a bare campaign key, execute_time) and refuses a label or campaign that carries no id at all, so a body written against the list output still goes through. Anything it cannot rewrite now comes back as a 400 validation error naming the field — errors: { "labels.0": [...] } — rather than the 500 {"message": "Server Error"} this used to return. Write the documented shape anyway: normalization is a safety net, not the contract.

// /tmp/post.json — a Facebook carousel via the full body schema
{
  "content": { "text": "Shop the collection" },
  "accounts": ["<facebook_account_id>"],
  "scheduling": { "publish_type": "scheduled", "scheduled_at": "2026-07-01 10:00:00" },
  "facebook_options": {
    "carousel": {
      "is_carousel_post": true,
      "cards": [
        { "image": "https://e.com/1.jpg", "link": "https://e.com/p1", "title": "Tee" },
        { "image": "https://e.com/2.jpg", "link": "https://e.com/p2", "title": "Hoodie" }
      ],
      "call_to_action": "SHOP_NOW",
      "end_card": true,
      "end_card_url": "https://e.com/shop"
    }
  },
  "labels": ["<label_id>"],
  "campaign_id": "<campaign_id>",
  "approval": { "approvers": ["<user_id>"], "approve_option": "anyone", "notes": "please review" }
}
contentstudio --json posts:create --body /tmp/post.json

For a Threads or Twitter/X thread, use a body with that account and the matching block instead — e.g. { "content": {...}, "accounts": ["<threads_account_id>"], "scheduling": {...}, "threads_options": { "has_multi_threads": true, "multi_threads": [...] } } (or twitter_options.threaded_tweets for Twitter/X).

Schedule a post at the best time

# 1. Ask for the best slots. Omit --account for every connected account.
contentstudio --json scheduling:best-times --global-slots 3
# → data.meta.timezone            e.g. "Asia/Karachi"
#   data.global.top_recommendations[0]  {rank: 1, day: "Wednesday",
#                                        date: "2026-08-19", time: "14", score: 100}
#   data.meta.missing_entities     accounts with too little history (skipped)

# 2. Narrow it to the account you're actually posting to.
#    <platform>:<account_id> — both from one accounts:list row.
contentstudio --json scheduling:best-times \
  --account facebook:<account_id> --per-account-slots 3

# 3. Show the user the ranked slots and let them pick. Then schedule at that
#    slot's date + hour AS-IS — the times are already workspace-local, so
#    converting to UTC would move the post.
contentstudio --json posts:create \
  -c "Launch day is here." \
  -i <account_id> \
  -t scheduled \
  -s "2026-08-19 14:00:00" \
  --dry-run

# 4. Drop --dry-run once the user approves the time and the text.

If data.global is null, the workspace has too little history — don't report an error. Say which accounts were skipped (meta.missing_entities) and offer to schedule at a time the user chooses instead.

Generate an image and publish it (two steps)

# 0. Optional: see what is available. Both are configuration, so cache them.
contentstudio --json images:models
contentstudio --json images:tools

# 1. Show the user the prompt first — generating costs an image credit.
contentstudio --json images:generate \
  -p "Flat-lay of autumn coffee beans on linen, warm daylight" \
  --dimensions square_hd --dry-run

# 2. Generate. Takes seconds; --json gives you data.media_id.
MEDIA_ID=$(contentstudio --json images:generate \
  -p "Flat-lay of autumn coffee beans on linen, warm daylight" \
  --dimensions square_hd | jq -r '.data.media_id')

# 3. Attach it. media_id goes in as --media-id, unchanged.
contentstudio --json posts:create \
  -c "Autumn blend is back." -i <account_id> -t draft \
  --media-id "$MEDIA_ID" --dry-run

# 4. Drop --dry-run once the user approves the image and the text. That creates a
#    draft; to send it instead, swap `-t draft` for
#    `-t scheduled -s "YYYY-MM-DD HH:MM:SS"`. There is no publish-now type —
#    --publish-type takes scheduled|draft|queued|content_category.

If media_id comes back null, read persist_error: the image exists at data.url but is not in the media library, so posts:create --media-id has nothing to take. Either fix the cause (media_storage_full → free up storage) or use the URL now, before the provider link expires.

Editing and chaining work the same way — the url of one result is the input to the next:

# Edit an existing image (the prompt describes the change, not the whole picture)
contentstudio --json images:generate \
  -p "Make the background a snowy street at dusk" \
  --image-url https://example.com/base.png

# Clean up a product shot, then restage it
URL=$(contentstudio --json images:remove-background \
        --image-url https://example.com/mug.png | jq -r '.data.url')
contentstudio --json images:product-image --product-image-url "$URL" \
  --instructions "on a marble kitchen counter, morning light"

# A tool's own controls — the escape hatch reaches every field the API declares
contentstudio --json images:tool image-to-image --body '{
  "prompt": "same mug, editorial magazine styling",
  "attachments": ["https://example.com/mug.png"],
  "aspect_ratio": "4:5"
}' --dry-run

Only ever pass URLs the image service can download. To use a local file, upload it first:

URL=$(contentstudio --json media:upload --file ./mug.png | jq -r '.data.url')
contentstudio --json images:upscale --image-url "$URL"

List recent draft posts

contentstudio --json posts:list --status draft --per-page 5

Delete a post (and from social)

contentstudio --json posts:delete <post_id> --delete-from-social

Add an internal note on a post (private)

contentstudio --json comments:add <post_id> "Double-check the link" --note

Override workspace for a single call

contentstudio --json --workspace <other_ws_id> posts:list --per-page 3

Triage the inbox: find unanswered DMs and reply to one

# 1. Cheap check first — is there anything to do?
contentstudio --json inbox:summary

# 2. List open conversations
contentstudio --json inbox:list --type conversation --action all --limit 20
# → per row: element_ref, platform, platform_id,
#            element_details.element_id  ← THIS is the conversation id

# 3. Read the thread. Use element_details.element_id (looks like t_1234...),
#    NOT element_ref — element_ref here returns an empty list.
contentstudio --json inbox:messages t_10000000000000001 --sort-order desc --limit 10

# 4. Find the account that owns the thread (gives you --platform-id)
contentstudio --json accounts:list --platform facebook

# 5. Preview the reply — ALWAYS do this, and show the text to the user
contentstudio --json inbox:send <conversation_id> \
  --platform-type facebook \
  --platform-id <account_id> \
  --message "Hi! Your order shipped this morning — tracking is on the way." \
  --dry-run

# 6. Only after the user approves, drop --dry-run

Reply to a comment, then hide a spam one

# Threaded reply. <post_id> is element_details.post_id, not element_ref.
contentstudio --json inbox:comment-add <post_id> \
  --platform-type facebook --platform-id <account_id> \
  --comment-id <comment_id> \
  --message "Thanks for the kind words!" --dry-run

# Hide spam rather than deleting it (reversible)
contentstudio --json inbox:comment-hide <comment_id> --dry-run

Clear a batch of conversations

contentstudio --json inbox:update \
  --element <element_ref_1> --element <element_ref_2> \
  --status done --dry-run

Tag a conversation for follow-up

contentstudio --json inbox:tags                       # find or create a tag id
contentstudio --json inbox:tag-attach <element_ref> \
  --tag <tag_id> --platform-id <account_id> --inbox-type conversation --dry-run

Error handling

error.typehttp_statusTypical hint
AuthError401, 403Run auth:login with a valid key.
NotFoundError404The resource doesn't exist or isn't in this workspace.
ValidationError422Flattened Laravel-style field errors from the API.
ConflictError409Resource already exists, or a send's delivery outcome is undetermined. Verify before retrying a send.
RateLimitError429Wait a moment and retry. On the AI image commands the bucket is 30/min and needs the full minute.
CreditLimitError403Out of AI image credits (images:*). Top up or wait for the cycle; nothing was charged. Re-running auth:login cannot fix it.
BackendError5xx or networkRetry after a short backoff.
ConfigError— (local)Missing API key / workspace; run auth:login or pass flags.

When NOT to use this skill

  • The user is asking about running their own ContentStudio backend (Laravel source); this CLI only talks to the deployed API.
  • Tasks not exposed by the v1 API (e.g., billing changes, first-time social account connection — those happen in the ContentStudio web UI).

The authoritative version for this skill is the version: field in the frontmatter at the top of this file.

Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.

<a href="https://skillzs.dev/skills/contentstudioio/contentstudio-agent/contentstudio">View contentstudio on skillZs</a>