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
| Property |
Value |
| name |
contentstudio |
| description |
Social-media automation CLI for scheduling posts and managing media/accounts via the ContentStudio public API |
| allowed-tools |
Bash(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.
- API key (interactive) — stores the key in the CLI config file:
contentstudio auth:login --api-key cs_...
- 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, and every inbox:* write (inbox:send, inbox:comment-add, inbox:comment-delete, inbox:review-reply, inbox:update, inbox:tag-*, …) — 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.
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:
- Run
contentstudio --json workspaces:current to see what's active.
- Tell the user: "Your active workspace is
<name> (<id>). Do you want to connect/post/delete in this workspace, or a different one?"
- 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).
- 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:
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?"
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"):
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:
- Run
contentstudio --json workspaces:list --per-page 50 (high default to often avoid pagination)
- 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:
- Run
contentstudio --json posts:list --status draft --per-page 1 to peek at total
- Run
contentstudio --json posts:list --status draft --per-page <total> to get them all
- Iterate over
data[] and delete each
- Never delete just the first page and report "done"
User: "show me my Facebook accounts"
Agent should:
- Use
--platform facebook filter — usually returns 0 or a handful, no pagination concern
- 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.
Non-list commands (auth:whoami, posts:create, posts:delete, media:upload, etc.) never include pagination in their envelope.
Command Reference
All commands are invoked as contentstudio <group>:<command>.
Authentication
| Command |
Purpose |
auth:login --api-key cs_... |
Store and verify API key |
auth:logout |
Forget stored credentials |
auth:whoami |
Hit /me and return user info |
auth:status |
Show local config (key redacted) |
Workspaces
| Command |
Purpose |
workspaces:list |
List user's workspaces |
workspaces:use <id> |
Set active workspace |
workspaces:current |
Show 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)
| Command |
Purpose |
accounts:list [--platform <p>] [--search <q>] |
List connected social accounts |
platforms:list |
List 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:
- Run
platforms:list to see what's supported and which method each uses (oauth / credentials / manual).
- 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.
- For Bluesky, ask the user for their handle + app-password (link them to https://bsky.app/settings/app-passwords) and call
accounts:add-bluesky.
- For Facebook Groups, just call
accounts:add-facebook-group --name "...".
Posts
| Command |
Purpose |
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-reel |
Create 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.json |
Create 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
| Command |
Purpose |
scheduling:best-times |
Ranked 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
| Command |
Purpose |
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
| Command |
Purpose |
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 |
Lookup tables (read)
| Command |
Purpose |
campaigns:list |
List campaigns (folders) |
categories:list |
List content categories |
labels:list |
List labels |
team:list |
List workspace team members |
approval-workflows:list |
List 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)
| Command |
Purpose |
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)
| Command |
Purpose |
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)
| Command |
Purpose |
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)
| Command |
Purpose |
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
| Command |
Purpose |
facebook:text-backgrounds |
List 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.
| Command |
Id to pass |
inbox:update (--element) |
element_details.element_id |
inbox:tag-attach / inbox:tag-detach |
element_details.element_id |
inbox:mark-read |
element_details.element_id |
inbox:contact / inbox:contact-update |
element_details.element_id |
inbox:messages / send / notes / note-add / bookmarks |
element_details.element_id (t_… form) |
inbox:comments / inbox:comment-add |
element_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
| Command |
Purpose |
inbox:list |
Search the inbox. --type conversation|post|review (repeatable), --action all|marked_done|archived|assigned, --search, --tag, --channels '{"facebook":["<acct>"]}', --page, --limit |
inbox:summary |
Counts 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:tags |
The workspace's inbox tag catalogue |
Replying — customer-facing, confirm before sending
| Command |
Purpose |
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
| Command |
Purpose |
inbox:mark-read <element_ref> |
Mark read (idempotent) |
inbox:update |
Bulk 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
| Command |
Purpose |
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-merge |
Fold 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:
| Limit |
Where |
--limit ≤ 200 |
inbox:list, inbox:messages, inbox:comments |
≤ 100 --element refs per call |
inbox:update |
| Exactly one operation per call |
inbox:update — --status, --archived, and --assigned are mutually exclusive; run separate commands |
| Tag name ≤ 50 chars |
inbox: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
…(truncated)
1---2name: contentstudio3description: 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, 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.4---56## Install ContentStudio CLI if it doesn't exist78```bash9npm install -g contentstudio-cli10# or11pnpm install -g contentstudio-cli12```1314npm release: https://www.npmjs.com/package/contentstudio-cli15contentstudio-agent github: https://github.com/contentstudioio/contentstudio-agent16contentstudio API docs: https://api.contentstudio.io/api-docs17official website: https://contentstudio.io1819---2021| Property | Value |22|----------|-------|23| **name** | contentstudio |24| **description** | Social-media automation CLI for scheduling posts and managing media/accounts via the ContentStudio public API |25| **allowed-tools** | Bash(contentstudio:*) |2627---2829## ⚠️ Authentication Required3031**You MUST authenticate before running any contentstudio CLI command.** All commands will fail without a valid API key.3233Before doing anything else, check auth status:3435```bash36contentstudio auth:status37```3839If `has_api_key` is `false`, authenticate one of two ways. The user can generate a key from **ContentStudio Dashboard → Settings → API Keys**.40411. **API key (interactive)** — stores the key in the CLI config file:4243```bash44contentstudio auth:login --api-key cs_...45```46472. **Environment variable (headless / agent runtimes)** — the CLI reads `CONTENTSTUDIO_API_KEY` from the environment and it takes precedence over the config file:4849```bash50export CONTENTSTUDIO_API_KEY=cs_...51```5253> **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.5455Then verify a workspace is selected:5657```bash58contentstudio --json workspaces:current59```6061If `active_workspace_id` is `null`, list workspaces and ask the user to pick one:6263```bash64contentstudio --json workspaces:list65contentstudio workspaces:use <workspace_id>66```6768---6970## Invocation rules for agents7172- **Always pass `--json` before the subcommand** for stable, parseable output.73- **Envelope shape**:74 - Success: `{"ok": true, "data": <payload>, "pagination"?: {...}}`75 - Error: `{"ok": false, "error": {"type": "<ErrorType>", "message": "...", "http_status": <int>, "hint": "..."}}`76- **Exit codes** are non-zero on error. Check both `returncode` and `ok`.77- **Parse stdout only** — human messages go to stderr.78- **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.7980### Confirm the target workspace before mutating actions8182The 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.8384But 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`, and every `inbox:*` write (`inbox:send`, `inbox:comment-add`, `inbox:comment-delete`, `inbox:review-reply`, `inbox:update`, `inbox:tag-*`, …) — 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.8586> **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.8788(`workspaces:create` is the one write that is **not** workspace-scoped — it creates a brand-new workspace and ignores the active one.)8990Pattern:91921. Run `contentstudio --json workspaces:current` to see what's active.932. Tell the user: "Your active workspace is **`<name>`** (`<id>`). Do you want to connect/post/delete in this workspace, or a different one?"943. If they say a different one, run `workspaces:list`, let them pick, then either:95 - Run `workspaces:use <id>` to switch the default, or96 - Pass `--workspace <id>` on the single mutating call (preferred when it's a one-off — does not change the active workspace).974. Only then run the mutating command.9899This 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.100101## Pagination — be proactive, don't silently truncate102103**All list commands return a `pagination` block** in JSON mode when more results exist than fit on one page:104105```json106{107 "ok": true,108 "data": [ /* current page of items */ ],109 "pagination": {110 "current_page": 1,111 "per_page": 10,112 "total": 48,113 "last_page": 5,114 "from": 1,115 "to": 10,116 "has_more": true117 }118}119```120121**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:1221231. **Ask the user** (default for ambiguous requests):124 > "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?"1251262. **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"):127 - Call again with `--per-page <total>` to get everything in one round-trip:128 ```bash129 contentstudio --json workspaces:list --per-page 48130 ```131 - Or iterate `--page 2`, `--page 3`, … `--page <last_page>` if `total` is large (>200) and you want bounded pages.1321333. **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.134135### Quick decision tree for the agent136137```138Did the user say "all" / "every" / "complete list" / "every single"?139 → YES: auto-paginate using --per-page <pagination.total>140 → NO:141 Did the user give a specific count? ("show me top 5", "first 20 posts")142 → YES: respect that count; use --per-page accordingly143 → NO:144 pagination.has_more === true?145 → YES: ASK the user before assuming you have everything146 → NO: you have all the data; proceed147```148149### Examples150151**User**: "list my workspaces"152**Agent should**:1531. Run `contentstudio --json workspaces:list --per-page 50` (high default to often avoid pagination)1542. If `pagination.has_more` is still true, say: "I see 50 of N workspaces. Want me to fetch all N?"155156**User**: "delete all my draft posts"157**Agent should**:1581. Run `contentstudio --json posts:list --status draft --per-page 1` to peek at `total`1592. Run `contentstudio --json posts:list --status draft --per-page <total>` to get them all1603. Iterate over `data[]` and delete each1614. Never delete just the first page and report "done"162163**User**: "show me my Facebook accounts"164**Agent should**:1651. Use `--platform facebook` filter — usually returns 0 or a handful, no pagination concern1662. If `has_more` still true (>20 FB accounts), ask before auto-fetching167168### Endpoints that paginate169170All `*:list` commands paginate:171`workspaces:list`, `accounts:list`, `posts:list`, `comments:list`, `media:list`, `campaigns:list`, `categories:list`, `labels:list`, `team:list`, `approval-workflows:list`.172173Non-list commands (`auth:whoami`, `posts:create`, `posts:delete`, `media:upload`, etc.) never include `pagination` in their envelope.174175---176177## Command Reference178179All commands are invoked as `contentstudio <group>:<command>`.180181### Authentication182183| Command | Purpose |184|---------|---------|185| `auth:login --api-key cs_...` | Store and verify API key |186| `auth:logout` | Forget stored credentials |187| `auth:whoami` | Hit `/me` and return user info |188| `auth:status` | Show local config (key redacted) |189190### Workspaces191192| Command | Purpose |193|---------|---------|194| `workspaces:list` | List user's workspaces |195| `workspaces:use <id>` | Set active workspace |196| `workspaces:current` | Show active workspace |197| `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) |198| `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) |199| `workspaces:delete <id>` | Delete a workspace |200201`workspaces:create` / `workspaces:update`:202- `--name` ≤35 chars, letters/spaces/digits/period only.203- `--logo` must be a URL; `--timezone` is an IANA string (e.g. `Asia/Karachi`).204- `--super-admin-id` (create only) — account owner to create under; required when you manage multiple super admins.205- 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}`.206- `workspaces:update` defaults to the active workspace if `<id>` is omitted and requires at least one field.207- Errors: `WORKSPACE_DELETE_FAILED` (422) on delete failure; 404 when the workspace doesn't exist.208209### Social accounts (read + connect)210211| Command | Purpose |212|---------|---------|213| `accounts:list [--platform <p>] [--search <q>]` | List connected social accounts |214| `platforms:list` | List platforms supported for new account connections |215| `accounts:connect <platform>` | Generate a one-time OAuth URL to connect a new account |216| `accounts:connect <platform> --reconnect --account-id <id>` | Refresh an expired/invalid account |217| `accounts:add-bluesky --handle <h> --app-password <p>` | Connect a Bluesky account (no browser — uses app password) |218| `accounts:add-facebook-group --name <n> [--image <url>]` | Manually add a Facebook Group connection |219| `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). |220221`--platform` values for `accounts:list` filter: `facebook`, `linkedin`, `twitter`, `instagram`, `youtube`, `tiktok`, `pinterest`, `gmb`.222223`<platform>` values for `accounts:connect`: `facebook`, `facebook-profile`, `instagram`, `instagram-via-facebook`, `twitter`, `linkedin`, `pinterest`, `tiktok`, `youtube`, `threads`, `gmb`, `tumblr`.224225**Account-connection flow for AI agents:**2261. Run `platforms:list` to see what's supported and which method each uses (`oauth` / `credentials` / `manual`).2272. 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.2283. For Bluesky, ask the user for their handle + app-password (link them to <https://bsky.app/settings/app-passwords>) and call `accounts:add-bluesky`.2294. For Facebook Groups, just call `accounts:add-facebook-group --name "..."`.230231### Posts232233| Command | Purpose |234|---------|---------|235| `posts:list [--status draft\|scheduled\|...] [--date-from] [--date-to]` | List posts |236| `posts:create -c "text" -i <account> -t <publish_type> [-s "YYYY-MM-DD HH:MM:SS"] [-m <image_url>]` | Create a post (shortcut mode) |237| `posts:create -c "text" -t content_category --content-category-id <cat_id>` | Create a content-category post (accounts come from the category) |238| `posts:create -c "text" -i <fb_account> -t draft --facebook-carousel '<json>'` | Create a Facebook carousel post (2–10 cards) |239| `posts:create -c "text" -i <threads_account> -t draft --threads '<json>'` | Create a Threads multi-thread (chained) post (max 10 items) |240| `posts:create -c "text" -i <twitter_account> -t draft --twitter '<json>'` | Create a Twitter/X threaded-tweet post (max 10 tweets) |241| `posts:create -c "text" -i <account> -t draft --first-comment "..." --first-comment-account <id>` | Create a post with a first comment |242| `posts:create -c "text" -i <linkedin_account> -t draft --post-type poll --linkedin-options '<json>'` | Create a LinkedIn poll post (text-only) |243| `posts:create -c "text" -i <ig_account> -t draft --post-type reel --video-url <url> --instagram-trial-reel` | Create an Instagram trial reel (shown to non-followers first) |244| `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 |245| `posts:create --body /path/to/body.json` | Create a post with full JSON body |246| `posts:update <post_id> [same flags as posts:create]` | Update an existing post (same body). Rejected (422) once the post is published/processing |247| `posts:delete <post_id> [--delete-from-social]` | Delete a post |248| `posts:approve <post_id> [--comment "..."]` | Approve a pending post |249| `posts:reject <post_id> [--comment "..."]` | Reject a pending post |250251`-t / --publish-type` values: `scheduled`, `draft`, `queued`, `content_category`.252253`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.254255**`posts:create` / `posts:update` shortcut-mode flags:**256- `-c / --content` (required) — post text.257- `-i / --account <id>` (repeatable) — account ID(s) to post to. **Required UNLESS `--content-category-id` is given.**258- `--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.259- `-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.260- `-m / --image-url <url>` (repeatable), `--video-url <url>`, `--media-id <id>` (repeatable) — media.261- `--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).262- `--label <id>` (repeatable, max 20) → `labels`.263- `--campaign-id <id>` → `campaign_id`.264- `--linkedin-options '<json>'` → `linkedin_options` (**LinkedIn accounts**). Pass a JSON **object**; the CLI parses it locally (invalid JSON → `ConfigError`) and sends it verbatim.265 - Shape: `{ "title"?: <string>, "poll"?: { "question": <≤140>, "options": <string[2..4], each ≤30>, "duration": "ONE_DAY" | "THREE_DAYS" | "SEVEN_DAYS" | "FOURTEEN_DAYS" } }`266 - A **poll** must be paired with `--post-type poll` and text-only content (no images/video). Backend validates and 422s on violations.267- `--facebook-collaborator <user_id>` (repeatable, **max 10**) → `facebook_options.collaborators` (Facebook accounts). Merges with `--facebook-carousel` / `--facebook-background-id`.268- `--instagram-collaborator <user_id>` (repeatable, **max 3**) → `instagram_options.collaborators` (Instagram accounts). Rejected (422) together with `--instagram-trial-reel`.269- `--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.270 - `--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).271 - 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.272 - **Rejected (422) together with `--instagram-collaborator`.** Share-to-story is silently dropped (not rejected) when combined with a trial reel.273 - Not available when the workspace posts to Instagram via the mobile app (`instagram_posting_option=mobile`).274- `--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.275 - Shape per platform: `{ "content": { "text"?: <string>, "post_type"?: <string>, "media"?: { "images"?: <url[] ≤10>, "video"?: <url> } } }`.276 - `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`.277 - `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.278 - Omitting `--platform-overrides` entirely publishes the same top-level `content` to every targeted platform.279 - 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).280- **Approval — two mutually-exclusive systems (pass only one):**281 - **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.282 - **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`).283 - **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`.284 - **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.285- `--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`.286- `--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.287 - Shape: `{ "cards": [ { "image": <url, required>, "link": <url, required>, "title"?: <≤255>, "description"?: <≤1000> } ], "call_to_action"?, "end_card"?: <bool>, "end_card_url"?: <url>, "accounts"?: <string[]> }`288 - **MIN 2, MAX 10 cards.** The Facebook account ID(s) still go in the top-level `-i / --account` (or in `carousel.accounts`).289 - `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`.290- `--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`.291 - Shape: `[ { "message": <string>, "media"?: <url[] ≤10>, "media_ids"?: <string[] ≤10> } ]`292 - **MAX 10 items.** Each item needs `message` OR `media`. Threads allows mixed media. Backend validates limits and returns a 422 if exceeded.293- `--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.294 - Shape: `[ { "message": <string>, "media"?: <url[] ≤10>, "media_ids"?: <string[] ≤10> } ]`295 - **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.296- `--first-comment "<message>"` → `first_comment` (≤2000 chars). The CLI builds `first_comment: { message, accounts? }`. The accounts are supplied with `--first-comment-account <id>` (repeatable).297 - `--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.298299(`--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.)300301The `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.302303### Scheduling — best time to post304305| Command | Purpose |306|---------|---------|307| `scheduling:best-times` | Ranked posting slots for the workspace, derived from the connected accounts' history |308| `scheduling:best-times --account <platform>:<account_id>` | Restrict the analysis to specific accounts (repeatable) |309| `scheduling:best-times --global-slots <n> --per-account-slots <n>` | How many recommendations to return (1–24 each) |310| `scheduling:best-times --entities '<json>'` | Full entity array, for per-account slot counts |311312A **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*.313314- **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.315- **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`.316- `--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`.317- `--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.318319**Response shape** (`data` in the JSON envelope):320321- `meta` — `{generated_at, timezone, warnings[], missing_entities[], ai_fallback_entities[]}`.322- `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.**323- `individual` — the same breakdown keyed by account id, each with `platform` and `source` (`data_driven` or an AI fallback).324325**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.326327Errors: 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.328329**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.330331### Comments / Internal notes332333| Command | Purpose |334|---------|---------|335| `comments:list <post_id>` | List comments on a post |336| `comments:add <post_id> "message" [--note] [--mention <user_id>]` | Add public comment or internal note |337338### Media library339340| Command | Purpose |341|---------|---------|342| `media:list [--type images\|videos] [--sort recent\|...]` | List media assets |343| `media:upload --file <local_path>` | Upload a local file |344| `media:upload --url <external_url>` | Import from external URL |345346### Lookup tables (read)347348| Command | Purpose |349|---------|---------|350| `campaigns:list` | List campaigns (folders) |351| `categories:list` | List content categories |352| `labels:list` | List labels |353| `team:list` | List workspace team members |354| `approval-workflows:list` | List approval workflows (use an item's `id` as `--approval-workflow-id`) |355356Each `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`.357358### Labels (write)359360| Command | Purpose |361|---------|---------|362| `labels:create --name <n> --color <color_N>` | Create a label |363| `labels:update <label_id> [--name] [--color]` | Update a label |364| `labels:delete <label_id>` | Delete a label |365366### Campaigns (write)367368| Command | Purpose |369|---------|---------|370| `campaigns:create --name <n> --color <color_N>` | Create a campaign |371| `campaigns:update <campaign_id> [--name] [--color]` | Update a campaign |372| `campaigns:delete <campaign_id>` | Delete a campaign |373374For 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).375376### Team members (write)377378| Command | Purpose |379|---------|---------|380| `team:add --email <e> --role <r> [--membership team\|client] [--permissions '<json>']` | Invite a member |381| `team:update <member_id> --role <r> --permissions '<json>' [--membership]` | Update a member's role/permissions |382| `team:remove <member_id> [--confirmed]` | Remove a member |383384- `member_id` is the **membership id** — the `member_id` field from `team:list` (not the user's `id`, a distinct field).385- `--role` (required): `admin`, `approver`, or `collaborator`.386- `--email` (required for `team:add`): a single email address.387- `--membership` (optional): `team` (internal) or `client` (external; hidden from internal notes). Default `team`.388- `--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.389 - **Shared booleans** (any role): `accessSharedFolder`, `allow_workflow_management`.390 - **admin**: full access — only the `hasBillingAccess` boolean applies.391 - **collaborator** booleans: `addBlog`, `addSocial`, `addSource`, `addTopic`, `viewTeam`, `rescheduleQueue`, `postsReview`, `changeFBGroupPublishAs`, `hasListeningAccess`.392 - **approver** booleans: `approverCanEditPost`, `approverCanAddNotes`, `approverCanCreatePost` (approvers can only approve/reject otherwise).393 - **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`.394 - **Blog arrays** (any role; not existence-validated): `wordpress`, `medium`, `shopify`, `webflow`.395 - **content_categories** (any role; must be real category IDs in the workspace, else 422): array of content-category IDs.396- `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`.397398### Social accounts (write)399400| Command | Purpose |401|---------|---------|402| `accounts:remove <account_id> [--dry-run]` | Remove (disconnect) a social account (`DELETE /workspaces/{w}/accounts/{account_id}`) |403404- `account_id` is the account's `id` from `accounts:list`.405- Requires the `save_social` permission — callers without it get 403.406- 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.407- Mutating command — preview with `--dry-run` and confirm the workspace first.408409### Facebook helpers410411| Command | Purpose |412|---------|---------|413| `facebook:text-backgrounds` | List Facebook colored-background presets (use `id` as `facebook_options.facebook_background_id` on plain-text posts) |414415### Social Inbox416417The inbox unifies three kinds of item into **elements**: `conversation` (DMs),418`post` (a post with comments), and `review`. `inbox:list` is the entry point —419everything else takes an id it returned.420421### Which id to pass422423Inbox commands take their id from the `element_details` object on each424`inbox:list` row. Use **`element_details.element_id`** — it is accepted by425every element-scoped command.426427| Command | Id to pass |428|---------|------------|429| `inbox:update` (`--element`) | `element_details.element_id` |430| `inbox:tag-attach` / `inbox:tag-detach` | `element_details.element_id` |431| `inbox:mark-read` | `element_details.element_id` |432| `inbox:contact` / `inbox:contact-update` | `element_details.element_id` |433| `inbox:messages` / `send` / `notes` / `note-add` / `bookmarks` | `element_details.element_id` (`t_…` form) |434| `inbox:comments` / `inbox:comment-add` | `element_details.post_id` |435436Values look like:437438- `element_details.element_id` — `t_10000000000000001` (conversation) or439 `100000000000000001_200000000000000002` (post)440- `element_details.post_id` — `900000000000001_100000000000000001`441442The row's top-level `element_ref` is an internal reference, not a command443argument — always take the id from `element_details`.444445If a command returns an empty list or reports the item as not found, confirm446the id against this table before describing the result to the user.447448Also needed for most writes:449450- **`platform_id`** — the connected social account the item belongs to. The451 backend replies through that account's token. It is on every `inbox:list`452 row as `platform_id`, or from `accounts:list`.453- The platform field on a list row is **`platform`** (not `platform_type`),454 but the write commands take `--platform-type`.455456**Reading**457458| Command | Purpose |459|---------|---------|460| `inbox:list` | Search the inbox. `--type conversation\|post\|review` (repeatable), `--action all\|marked_done\|archived\|assigned`, `--search`, `--tag`, `--channels '{"facebook":["<acct>"]}'`, `--page`, `--limit` |461| `inbox:summary` | Counts per bucket — cheap way to answer "anything unread?" |462| `inbox:messages <conversation_id>` | Messages in a DM thread. Id = `element_details.element_id`. `--sort-order asc\|desc` |463| `inbox:comments <post_id>` | A post's comments (threaded). Id = `element_details.post_id` |464| `inbox:notes <conversation_id>` | Internal notes (team-only). Id = `element_details.element_id`. Paginated |465| `inbox:bookmarks <conversation_id>` | Starred messages. Id = `element_details.element_id`. Paginated |466| `inbox:contact <element_ref>` | Contact profile behind an element |467| `inbox:tags` | The workspace's inbox tag catalogue |468469**Replying — customer-facing, confirm before sending**470471| Command | Purpose |472|---------|---------|473| `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 |474| `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 |475| `inbox:review-reply <review_id>` | Add or replace a review reply (upsert). `--platform-id`, `--reply` |476| `inbox:note-add <conversation_id>` | Add an internal note. `--mention <user_id>` (repeatable). Not customer-visible |477478**Triage and moderation**479480| Command | Purpose |481|---------|---------|482| `inbox:mark-read <element_ref>` | Mark read (idempotent) |483| `inbox:update` | Bulk state change. `--element` (repeatable, **max 100**) plus **exactly one** of `--status done\|pending`, `--archived`, `--assigned` (pair with `--assigned-to '{"id":"<user>"}'`) |484| `inbox:comment-hide` / `inbox:comment-unhide <comment_id>` | Hide/unhide. Unhide needs `--platform-type` + `--platform-id` |485| `inbox:comment-like` / `inbox:comment-unlike <comment_id>` | Facebook only |486| `inbox:comment-delete <comment_id>` | Delete. Needs `--platform-type` + `--platform-id`; LinkedIn also needs `--comment-urn` |487| `inbox:star` / `inbox:unstar <message_id>` | Star a message |488| `inbox:message-delete <message_id>` | Soft-delete a message. `--platform-id` |489| `inbox:review-reply-delete <review_id>` | Remove a review reply. `--platform-id` |490| `inbox:contact-update <element_ref>` | `--platform-id` plus any of `--name`, `--email`, `--phone`, `--company` |491492**Tags**493494| Command | Purpose |495|---------|---------|496| `inbox:tag-create` | `--name` (≤50), `--color` — a **hex** value like `#33aa55`. (Older tags may display `color_1`, but the API now rejects that format.) |497| `inbox:tag-update <tag_id>` | `--name` and/or `--color` |498| `inbox:tag-delete` | `--tag <id>` (repeatable, bulk) |499| `inbox:tag-merge` | Fold tags into a new one: `--name`, `--color`, `--tag` (repeatable) |500| `inbox:tag-attach <element_ref>` | `--tag` (repeatable), `--platform-id`, `--inbox-type` |501| `inbox:tag-detach <element_ref> <tag_id>` | `--platform-id`, `--inbox-type` |502503**Inbox pagination note.** Inbox list commands use `--limit` rather than504`--per-page` (`--per-page` is accepted as an alias). The pagination rules in505the section above apply unchanged: if `pagination.has_more` is true, do not506report the first page as the whole inbox.507508> **Inbox page size is 200.** For inboxes larger than that, page through with509> `--page 1`, `--page 2`, … up to `pagination.last_page` rather than raising510> `--limit` past 200.511512**Inbox limits.** The CLI validates these locally, so they surface as a513`ConfigError` before any request is sent:514515| Limit | Where |516|-------|-------|517| `--limit` ≤ 200 | `inbox:list`, `inbox:messages`, `inbox:comments` |518| ≤ 100 `--element` refs per call | `inbox:update` |519| Exactly **one** operation per call | `inbox:update` — `--status`, `--archived`, and `--assigned` are mutually exclusive; run separate commands |520| Tag name ≤ 50 chars | `inbox:tag-create` |521522**Partial success on bulk updates.** `inbox:update` returns HTTP `207` when523some elements were updated and others were not, listing the remainder in524`missing_ids`. The CLI reports this as a warning. When `missing_ids` is525non-empty, tell the user which elements did not change rather than reporting526the batch as fully applied.527528**`inbox:contact-update` updates the whole contact.** A contact is a person,529not a per-element attribute, so the change applies to every element for that530contact on that account in the workspace. The response's `updated_count` says531how many were updated. Mention this scope to the user before running it.532533**`inbox:contact` returns personal data.** Email and phone of an end customer.534Return only the fields the user actually asked for; don't dump the whole record535into a summary or paste it somewhere persistent without being asked.536537**`inbox:messages` includes activity events.** A thread contains both messages538and a record of team activity. Activity entries have `message: null` and an539`action` block (`MARKED_AS_DONE`, `PENDING`, `ARCHIVED`, …) naming the teammate540who performed it, and they count toward `total_messages` and pagination. Filter541on `action == null` when you mean customer messages — don't count activity542entries as messages, quote them as customer text, or treat one as the latest543reply. The CLI renders them as `— marked as done —` rows in human mode.544545**Replies are nested, not paginated.** In `inbox:comments`, replies live under546each thread's `children` — they are not separate top-level rows. Paging counts547threads (`total_threads`), not individual comments, so "12 comments" from the548pagination block means 12 *threads* and there may be many more replies inside.549550**Handling a `409` on a send.** For `inbox:send` and `inbox:comment-add`, a551`409` means the delivery outcome is undetermined — the message may or may not552have reached the customer. The CLI surfaces it as `ConflictError`. Do not retry553automatically: read the conversation back with `inbox:messages` to check554whether it landed, and tell the user what 555556…(truncated)