# MCP tools

> Every tool the Orbitwrite MCP server offers, with the permission each one needs and its inputs.

Server: `https://mcp.orbitwrite.com` (Streamable HTTP). Auth: `Authorization: Bearer orbit_sk_…`.

118 tools. A credential only sees the tools its scopes allow, so a missing tool means a missing scope, not a missing feature. Every tool's inputs are the service's own validation schema; the server re-checks permission on each call.

| Tool                               | Requires                             | Purpose                                                  |
| ---------------------------------- | ------------------------------------ | -------------------------------------------------------- |
| `whoami`                           | any key                              | Identify this credential                                 |
| `list_channels`                    | `account:read`                       | List connected channels                                  |
| `list_pinterest_boards`            | `account:read`                       | List a Pinterest channel's boards                        |
| `create_pinterest_board`           | `account:update`                     | Create a Pinterest board                                 |
| `get_platform_rules`               | any key                              | Get a network's posting rules                            |
| `list_post_templates`              | any key                              | List post templates                                      |
| `get_link_preview`                 | any key                              | Preview a link                                           |
| `list_tags`                        | `tag:read`                           | List workspace tags                                      |
| `list_campaigns`                   | `campaign:read`                      | List or search campaigns                                 |
| `get_campaign`                     | `campaign:read`                      | Read one campaign                                        |
| `list_posts`                       | `post:read`                          | List posts                                               |
| `search_posts`                     | `post:read`                          | Search posts                                             |
| `read_brand_voice`                 | `brandVoice:read`                    | Read the brand voice                                     |
| `update_brand_voice`               | `brandVoice:update`                  | Update the brand voice                                   |
| `read_posting_schedule`            | `postingSchedule:read`               | Read the posting schedule                                |
| `suggest_post_times`               | `postingSchedule:read`               | Suggest when to post                                     |
| `get_week_overview`                | `post:read`                          | Get the week overview                                    |
| `get_weekly_digest`                | `post:read`                          | Get the weekly digest                                    |
| `get_results`                      | `analytics:read`                     | See how posts did                                        |
| `get_analytics_overview`           | `analytics:read`                     | Get analytics totals                                     |
| `get_analytics_series`             | `analytics:read`                     | Chart a metric over time                                 |
| `get_follower_growth`              | `analytics:read`                     | Get follower growth                                      |
| `get_best_times`                   | `analytics:read`                     | Get the best times to post                               |
| `list_top_posts`                   | `analytics:read`                     | List top posts                                           |
| `get_analytics_breakdown`          | `analytics:read`                     | Compare platforms or post formats                        |
| `get_analytics_insights`           | `analytics:read`                     | Find what's working                                      |
| `get_campaign_report`              | `analytics:read` and `campaign:read` | Get a campaign's report                                  |
| `get_post_metrics`                 | `analytics:read`                     | Get a post's numbers over time                           |
| `get_post_benchmark`               | `analytics:read`                     | Compare a post with the others                           |
| `get_analytics_sync_status`        | `analytics:read`                     | Check analytics collection                               |
| `suggest_campaign_times`           | `postingSchedule:read`               | Plan times for a run of posts                            |
| `create_tag`                       | `tag:create`                         | Create a tag                                             |
| `create_campaign`                  | `campaign:create`                    | Create a campaign                                        |
| `update_campaign`                  | `campaign:update`                    | Rename or edit a campaign                                |
| `delete_campaign`                  | `campaign:delete`                    | Delete a campaign and its posts                          |
| `duplicate_campaign`               | `campaign:create`                    | Duplicate a campaign                                     |
| `decide_campaign`                  | `approval:decide`                    | Approve or reject a campaign                             |
| `upload_media`                     | `media:create`                       | Upload an image or video                                 |
| `upload_media_from_url`            | `media:create`                       | Upload media from a URL                                  |
| `suggest_alt_text`                 | `media:read`                         | Describe an image                                        |
| `save_draft`                       | `post:create`                        | Save a draft                                             |
| `create_post`                      | `post:create`                        | Schedule or publish a post                               |
| `get_post`                         | `post:read`                          | Read one post in full                                    |
| `score_post`                       | `post:read`                          | Score a saved post                                       |
| `get_post_cover`                   | `post:read`                          | Read a post's video cover                                |
| `set_post_cover`                   | `post:update`                        | Set a post's video cover                                 |
| `score_content`                    | `post:read`                          | Score post content before saving                         |
| `get_publish_status`               | `post:read`                          | Check publish progress                                   |
| `schedule_post`                    | `post:update`                        | Schedule a post, or move its time                        |
| `publish_post`                     | `post:update`                        | Publish a post now                                       |
| `retry_publish`                    | `post:update`                        | Retry a failed publish                                   |
| `unschedule_post`                  | `post:update`                        | Move a post back to draft                                |
| `plan_post`                        | `post:update`                        | Plan a post for a time                                   |
| `delete_post`                      | `post:delete`                        | Delete a draft or scheduled post                         |
| `update_post_info`                 | `post:update`                        | Update a post's title, tags or campaign                  |
| `duplicate_post`                   | `post:create`                        | Duplicate a post as a new draft                          |
| `get_post_activity`                | `post:read`                          | Read a post's history                                    |
| `list_activity`                    | `analytics:read`                     | Read the workspace activity feed                         |
| `list_members`                     | `member:read`                        | List workspace members                                   |
| `get_approval`                     | `approval:read`                      | Read a post's approval request                           |
| `request_approval`                 | `approval:request`                   | Request review of a post                                 |
| `decide_approval`                  | `approval:decide`                    | Approve or reject a post                                 |
| `remove_approval`                  | `approval:request`                   | Withdraw a post's approval request                       |
| `list_comments`                    | `comment:read`                       | Read a post's comments                                   |
| `add_comment`                      | `comment:create`                     | Comment on a post                                        |
| `resolve_comment`                  | `comment:update`                     | Resolve or reopen a comment thread                       |
| `list_conversations`               | `inbox:read`                         | List inbox conversations                                 |
| `get_conversation`                 | `inbox:read`                         | Read a conversation thread                               |
| `count_conversations`              | `inbox:read`                         | Count the conversations in a view                        |
| `get_inbox_sync_status`            | `inbox:read`                         | Where each channel's inbox polling stands                |
| `set_conversation_status`          | `inbox:update`                       | Resolve, reopen or snooze a conversation                 |
| `mark_conversation`                | `inbox:update`                       | Mark a conversation read or unread                       |
| `set_all_conversations_status`     | `inbox:update`                       | Resolve, reopen or snooze every conversation in a status |
| `reply_to_conversation`            | `inbox:reply` or `approval:request`  | Reply in a conversation                                  |
| `assign_conversation`              | `inbox:update`                       | Assign a conversation                                    |
| `sync_inbox`                       | `inbox:update`                       | Pull new replies from the platforms                      |
| `list_held_replies`                | `approval:read`                      | List replies waiting for approval                        |
| `decide_held_reply`                | `approval:decide`                    | Approve or reject a held reply                           |
| `get_inbox_assignment`             | `teamInbox:read`                     | Read how conversations are assigned                      |
| `update_inbox_assignment`          | `teamInbox:update`                   | Change how conversations are assigned                    |
| `distribute_waiting_conversations` | `teamInbox:update`                   | Assign the waiting backlog                               |
| `list_team_inboxes`                | `teamInbox:read`                     | List team inboxes                                        |
| `create_team_inbox`                | `teamInbox:create`                   | Create a team inbox                                      |
| `update_team_inbox`                | `teamInbox:update`                   | Change a team inbox                                      |
| `delete_team_inbox`                | `teamInbox:delete`                   | Delete a team inbox                                      |
| `reorder_team_inboxes`             | `teamInbox:update`                   | Set the routing order                                    |
| `list_team_availability`           | `inboxStatus:read`                   | Who's available in the inbox                             |
| `set_teammate_status`              | `inboxStatus:manageAny`              | Set a teammate's inbox status                            |
| `get_inbox_settings`               | `teamInbox:read`                     | Read the inbox settings                                  |
| `update_inbox_settings`            | `teamInbox:update`                   | Change the inbox settings                                |
| `list_channel_posting_schedules`   | `postingSchedule:read`               | Posting slots per channel                                |
| `set_posting_schedule`             | `postingSchedule:update`             | Replace the posting schedule                             |
| `update_tag`                       | `tag:update`                         | Rename or recolour a tag                                 |
| `delete_tag`                       | `tag:delete`                         | Delete a tag                                             |
| `list_hashtag_sets`                | `hashtagSet:read`                    | List saved hashtag sets                                  |
| `get_text_with_hashtag_set`        | `hashtagSet:read`                    | Add a hashtag set to post text                           |
| `create_hashtag_set`               | `hashtagSet:create`                  | Save a hashtag set                                       |
| `update_hashtag_set`               | `hashtagSet:update`                  | Rename a hashtag set or replace its tags                 |
| `delete_hashtag_set`               | `hashtagSet:delete`                  | Delete a hashtag set                                     |
| `list_media`                       | `media:read`                         | List the media library                                   |
| `get_media`                        | `media:read`                         | Read one media record                                    |
| `delete_media`                     | `media:delete`                       | Delete a media record and its file                       |
| `get_media_usage`                  | `media:read`                         | Media uploads this month                                 |
| `read_link_tracking`               | `linkTracking:read`                  | Read the link tracking settings                          |
| `update_link_tracking`             | `linkTracking:update`                | Replace the link tracking settings                       |
| `set_channel_link_tracking`        | `linkTracking:update`                | Set one channel's link tracking override                 |
| `list_webhooks`                    | `webhook:read`                       | List webhook endpoints                                   |
| `create_webhook`                   | `webhook:create`                     | Register a webhook endpoint                              |
| `update_webhook`                   | `webhook:update`                     | Edit a webhook endpoint                                  |
| `toggle_webhook`                   | `webhook:update`                     | Pause or resume a webhook endpoint                       |
| `delete_webhook`                   | `webhook:delete`                     | Delete a webhook endpoint                                |
| `rotate_webhook_secret`            | `webhook:update`                     | Rotate a webhook endpoint's signing secret               |
| `list_auto_dm_channels`            | `autoDm:read` and `account:read`     | Which channels can send Auto-DMs                         |
| `list_auto_dms`                    | `autoDm:read`                        | List Auto-DM campaigns                                   |
| `list_auto_dm_recipients`          | `autoDm:read`                        | Who an Auto-DM campaign messaged                         |
| `start_auto_dm`                    | `autoDm:create` and `post:publish`   | Start an Auto-DM on a published post                     |
| `update_auto_dm`                   | `autoDm:update`                      | Pause, resume or change an Auto-DM                       |
| `stop_auto_dm`                     | `autoDm:delete`                      | Stop an Auto-DM for good                                 |

### `whoami`

**Identify this credential.** Requires any valid key.

Call this first: the workspace this connection acts in (name, slug, `timezone`, primary channel, app URL), the current time as `now` and `localNow` (the same instant in the workspace's zone, with the weekday: read the person's wall-clock times against it), who you are acting as (`actor.userId` is what `assignee` filters, set\_teammate\_status and "my approvals" take; null for an API key, which acts as the workspace), the permissions every tool is checked against, `publishRequiresApproval` (true means you cannot publish without a reviewer), and, when the credential may read billing, `plan` with its limits, usage and features, so you can stay inside them before a write comes back `plan_limit:`. `workspace.timezoneIsDefault` means the workspace has no zone of its own and UTC is assumed; say so when the person's times matter.

**Input:** none

### `list_channels`

**List connected channels.** Requires `account:read`.

Every social account connected to the workspace, with its platform, handle and status. Returns a projection — access tokens are never part of it. Post-creating tools address channels by the `id` returned here.

**Input:** none

### `list_pinterest_boards`

**List a Pinterest channel's boards.** Requires `account:read`.
Rate limited: 30 calls per 60s, shared with the HTTP route for the same operation.

The boards a Pinterest channel can save a Pin to. A post to Pinterest needs one of these ids as `platformOptions.pinterest.boardId` before it can be scheduled or published.

**Input**

- `accountId`: string, required — A Pinterest channel's id from list\_channels.

### `create_pinterest_board`

**Create a Pinterest board.** Requires `account:update`.
Rate limited: 10 calls per 60s, shared with the HTTP route for the same operation.

Create a public board on a Pinterest channel when none of list\_pinterest\_boards fits. It appears on the account's Pinterest profile at once, so confirm the name with the person first. Returns the board; use its id as `platformOptions.pinterest.boardId`.

**Input**

- `accountId`: string, required — A Pinterest channel's id from list\_channels.
- `name`: string (max length 50), required
- `description`: string (max length 500)

### `get_platform_rules`

**Get a network's posting rules.** Requires any valid key.

What each network accepts, so a post can be fitted before it is saved rather than refused: text limits (`premiumMaxChars` is X Premium's), whether threads are allowed, media limits (count, MIME types, bytes, video length and frame rate, aspect ratio), the `postFormat` values it offers and what each narrows (an Instagram story publishes no text), polls, quotes, `replySettings`, the JSON Schema of its `platformOptions`, `aiLabel` (the `platformOptions` field that labels a post as AI-generated, where the network has one), and `hashtags`: the ceiling it enforces (`limit.beyond: rejected` means a post over it is refused, `ignored` that the surplus does nothing), whether a #tag does anything there (`linksHashtags`) and its conventions. Omit `platforms` for every network. A field that doesn't apply is left out.

**Input**

- `platforms`: array of one of `"twitter"` | `"bluesky"` | `"linkedin_personal"` | `"linkedin_page"` | `"mastodon"` | `"discord"` | `"instagram"` | `"tiktok"` | `"threads"` | `"facebook_page"` | `"slack"` | `"telegram"` | `"youtube"` | `"pinterest"` (max 14 items) — Platform ids, as list\_channels reports them. Omit for all.

### `list_post_templates`

**List post templates.** Requires any valid key.

Ideas to write about when the person has no topic yet, each a prompt with a draft to start from (the app's Ideas panel). Without `id`: every template's id, emoji, title, description and category, narrowed by `category` and `q`. With `id`: that template's `body`, where `{{label: hint, hint}}` marks what to fill in (also parsed into `fields`) and a bracketed line opening with Attach or Add is a note to act on and delete. Fill every field from what the person tells you before saving: a field left in is published as written (the app flags it as a `template_field` issue).

**Input**

- `id`: string (max length 100) — A template id from this list, for its body.
- `category`: one of `"behind-the-scenes"` | `"story"` | `"lesson"` | `"how-to"` | `"opinion"` | `"question"` | `"list"` | `"promote"`
- `q`: string (max length 200) — Matched against title and description.

### `get_link_preview`

**Preview a link.** Requires any valid key.
Rate limited: 30 calls per 60s, shared with the HTTP route for the same operation.

A web page's preview card: its title, description, site name and preview image (Open Graph / Twitter card), the same card the composer shows under a pasted link. The image comes back too, so you can see it. To put that image on a post, pass its URL to upload\_media\_from\_url and use the returned id in `mediaIds`; don't on a post whose text keeps the link, where the network already draws the card from it. Private and internal addresses are refused.

**Input**

- `url`: string (uri) (max length 2048), required — The page's address, http(s).

### `list_tags`

**List workspace tags.** Requires `tag:read`.

Every tag in the workspace, with post counts, sorted by name.

**Input:** none

### `list_campaigns`

**List or search campaigns.** Requires `campaign:read`.
Rate limited: 60 matching calls per 60s, shared with the HTTP route for the same operation.

Every campaign in the workspace with its status and counts. With `q`, only the campaigns whose title contains it (case-insensitive, newest first), each as its id, title, colour and icon: the way to find a campaign's id from its name.

**Input**

- `q`: string — Part of a campaign's title.

### `get_campaign`

**Read one campaign.** Requires `campaign:read`.

One campaign by id: its title, description, colour, icon and date range, plus the rollups list\_campaigns carries (how many posts it holds, the next one due, the span its posts cover). The posts themselves are read with list\_posts and its `campaignId` filter.

**Input**

- `campaignId`: string, required — A campaign id from list\_campaigns or create\_campaign.

### `list_posts`

**List posts.** Requires `post:read`.

What this workspace has (drafts, posts awaiting approval, scheduled and published), newest first, one entry per post however many channels it goes out on. Filter by `status`, board `bucket` (approval state outranks the lifecycle status), `platform`, `accountId`, `campaignId`, `scoreGrade` (`fair` and `needs_work` are the posts worth improving) or a `scheduledAfter`/`scheduledBefore` window. Pages hold 20 entries unless `limit` says otherwise; pass `nextCursor` back for the next, and null means that page was everything. Use it for "what's going out this week?" or "what did we publish last month?"; search\_posts finds a post by its words, and get\_post reads one in full.

Each entry's `id` IS the post's `groupId`, what every post tool takes; `postId` is one channel's row, which only the inbox's `postId` filter wants.

**Input**

- `limit`: integer (min 1, max 100, default 20) — Page size, 1–100. Default 20.
- `cursor`: string — Only to fetch the next page: the `nextCursor` a previous call returned, unchanged. Omit on the first call — never make one up. A null `nextCursor` means that page was the last: there is no next page to ask for.
- `sort`: one of `"createdAt"` | `"scheduledAt"` (default "createdAt") — Order by when the post was created (default) or when it goes out.
- `direction`: one of `"asc"` | `"desc"` (default "desc") — Default `desc`, newest first.
- `status`: array of one of `"draft"` | `"planned"` | `"pending_approval"` | `"scheduled"` | `"publishing"` | `"published"` (min 1 items) — Lifecycle status, any of (OR).
- `bucket`: array of one of `"drafts"` | `"needs_approval"` | `"needs_revision"` | `"approved"` | `"planned"` | `"scheduled"` | `"posted"` (min 1 items) — The board column the post sits in, any of (OR). Approval state outranks status here — posts waiting for approval are `needs_approval`.
- `platform`: array of string (min 1 items) — Platform id, e.g. "twitter" or "linkedin\_personal", any of (OR).
- `accountId`: array of string (min 1 items) — Channel ids from list\_channels, any of (OR).
- `campaignId`: string — Only posts in this campaign.
- `groupId`: array of string (min 1 items, max 100 items) — Only these posts, by groupId (the `id` each entry carries), any of (OR). At most 100.
- `scheduledAfter`: string (date-time) — ISO 8601 instant: only posts going out at or after it. Drafts have no time and drop out.
- `scheduledBefore`: string (date-time) — ISO 8601 instant: only posts going out at or before it. Drafts have no time and drop out.
- `previewAccountId`: string — Show this channel's copy (`text`, `media`, `platformUrl`) for posts that go out on it. Not a filter: other posts keep their own.
- `scoreGrade`: array of one of `"excellent"` | `"good"` | `"fair"` | `"needs_work"` (min 1 items) — Post score grade, any of (OR): the same health check score\_post runs, stored per post and refreshed when it changes.

### `search_posts`

**Search posts.** Requires `post:read`.

Posts whose text (any thread position) or title matches a query, across drafts, scheduled and published — at most 20, newest first, one per post. Each result's `id` is the post's `groupId` (what get\_post and the other post tools take), and it carries the derived board bucket (drafts / needs approval / scheduled / published / failed). Narrow the query or use list\_posts with filters when 20 isn't enough.

**Input**

- `q`: string, required — Text to search for.

### `read_brand_voice`

**Read the brand voice.** Requires `brandVoice:read`.

The workspace's brand voice profile — tone, audience, rules and the distilled memo learned from published posts: how this workspace writes, for drafting copy that sounds like it. Null when nothing has been configured yet.

**Input:** none

### `update_brand_voice`

**Update the brand voice.** Requires `brandVoice:update`.

Save the workspace's brand voice profile — the place to record a rule the person states in conversation ("we never use em dashes", "the audience is indie developers") so every later draft follows it. This is a full save, not a patch: `brandDescription`, `audience`, `productContext`, `defaultTone`, `styleRules` and `exemplars` are CLEARED when omitted. So call read\_brand\_voice first and send the whole profile back with your change applied; never send only the field that changed. Two fields are different: `learnedMemo` falls back to what is stored when omitted (the memo the distillation job learned from published posts — leave it out unless the person wants it changed; `null` clears it), and `aiLearningEnabled` / `useVoiceSamples` keep their stored value when omitted. `learnedMemoVersion` belongs to the job and cannot be set. Returns the saved profile, the same shape read\_brand\_voice returns.

**Input**

- `brandDescription`: string (max length 2000, default "")
- `audience`: string (max length 2000, default "")
- `productContext`: string (max length 2000, default "")
- `defaultTone`: one of `"professional"` | `"casual"` | `"bold"` | `"friendly"` | `"thought_leader"` | `"playful"` | `"hype"` | `"punchy"` | null
- `styleRules`: object (default {})
  - `dos`: array of string (max 20 items, default \[])
  - `donts`: array of string (max 20 items, default \[])
  - `bannedPhrases`: array of string (max 20 items, default \[])
  - `emojiPolicy`: one of `"never"` | `"sparing"` | `"freely"`
  - `hashtagPolicy`: one of `"never"` | `"sparing"` | `"freely"`
  - `ctaPolicy`: one of `"never"` | `"when_relevant"` | `"always"`
  - `language`: string (max length 100, default "")
- `exemplars`: array of object (max 8 items, default \[])
  - `id`: string, required
  - `text`: string (max length 2000), required
  - `note`: string (max length 200)
- `learnedMemo`: string | null
- `aiLearningEnabled`: boolean
- `useVoiceSamples`: boolean

### `read_posting_schedule`

**Read the posting schedule.** Requires `postingSchedule:read`.

The weekly wall-clock times the workspace publishes at, and its timezone. With `accountId`, one channel's own schedule instead — where an empty slot list means that channel has nothing of its own and inherits the workspace's.

**Input**

- `accountId`: string

### `suggest_post_times`

**Suggest when to post.** Requires `postingSchedule:read`.

Candidate publish times, one entry per channel. The slot strategies return the workspace's configured posting slots that nothing is scheduled on yet, so a time from here never books a slot that already holds a post — read\_posting\_schedule shows the times but not which are taken.

`first-available` (the default) takes the soonest free slot; `queue` appends after everything already booked, so a batch keeps its order; `best-time` ignores the schedule and ranks a per-platform engagement profile instead. That profile is a static heuristic, not measured analytics for this account — `researched` says whether the platform has a profile of its own or the generic fallback, and neither should be described to a person as data about their audience.

An empty `times` means the channel has no free slot in the next two weeks.

**Input**

- `accountIds`: array of string (min 1 items, max 50 items)
- `strategy`: one of `"first-available"` | `"queue"` | `"best-time"` (default "first-available")
- `count`: integer (min 1, max 10, default 1)
- `after`: string (date-time)

### `get_week_overview`

**Get the week overview.** Requires `post:read`.

How the workspace's week looks, in one read: the posting streak and what keeps it alive, each of the next `days` days (default 7) with its scheduled posts and posting slots (`openSlots` = future slots with nothing in them), and what's stuck — posts awaiting approval and for how long, changes requested, failed publishes from the last two weeks, and drafts never given a time (oldest first, `ready` when they could be scheduled as is). Use it for "what should I post this week?", "how's my week looking?" or a weekly digest. Sections the caller can't read are null. Times are ISO instants; `timezone` is the workspace's, so say days and times in it. Nothing here is engagement data.

**Input**

- `days`: integer (min 1, max 14, default 7)
- `weekStartsOn`: one of (literal `0` | literal `1` | literal `6`)
  - variant 1: literal `0`
  - variant 2: literal `1`
  - variant 3: literal `6`

### `get_weekly_digest`

**Get the weekly digest.** Requires `post:read`.
Rate limited: 20 calls per 60s, shared with the HTTP route for the same operation.

Last week in one read, against the week before, plus the week ahead: posts published, impressions, engagements and link clicks received during the week (by posts of any age), follower change per channel, the week's best post and the strongest pattern of the last four weeks, then everything get\_week\_overview returns for the seven days from today. Weeks run Monday to Sunday in the workspace timezone unless `weekStartsOn` says otherwise; `week` names any day of an earlier week (at most twelve back). Use it for "how did last week go?" or a Monday summary. A metric that moved in neither week is left out rather than reported as zero; `previous` is null when the plan's history doesn't reach the week before. `analytics` is null without analytics access.

**Input**

- `week`: string — Any day of the week to report, YYYY-MM-DD in the workspace timezone. Defaults to the last complete week.
- `weekStartsOn`: one of (literal `0` | literal `1` | literal `6`) — First day of the week as a getDay() index (0=Sun, 1=Mon, 6=Sat). Defaults to Monday.
  - variant 1: literal `0`
  - variant 2: literal `1`
  - variant 3: literal `6`

### `get_results`

**See how posts did.** Requires `analytics:read`.
Rate limited: 10 calls per 300s, shared with the HTTP route for the same operation.

How the workspace's published posts did over a window (default the last 7 days): each post (newest first, at most `limit`, a thread counted once) with its numbers, the totals, the best performer and each channel's follower change. Use it for "how did last week go?"; list\_top\_posts ranks a longer window by one metric. The best performer is named by both ids: to read or repost it, use `topGroupId` (`topPostId` is one channel's row). A post's `status` says why it has no numbers: `unsupported` (the network reports none), `no_data_yet`, or `unavailable` (the platform didn't answer); say so rather than calling it zero. `historyDays` is set when the plan's analytics history shortened the window. Numbers are cached for up to an hour.

**Input**

- `days`: integer (min 1, max 90) — How far back to look, ending now (or at `to`). Defaults to 7.
- `from`: string (date-time) — Start of the window, an ISO instant. Overrides `days`.
- `to`: string (date-time) — End of the window, an ISO instant. Defaults to now.
- `limit`: integer (min 1, max 25) — Most posts to read, newest first. Defaults to 15.
- `accountIds`: array of string (max 20 items) — Only these channels (ids from list\_channels).

### `get_analytics_overview`

**Get analytics totals.** Requires `analytics:read`.
Rate limited: 60 calls per 60s, shared with the HTTP route for the same operation.

Totals per metric (engagements, impressions, reactions, comments, reposts, quotes, saves, clicks) over the posts published in a window, default the last 28 days, each against the same stretch just before it, or the `compareFrom`/`compareTo` window when given (`changePercent`). Narrow with `accountIds`, `campaignId` or `tagIds`. A null `value` means nothing in the window reported that number (Bluesky has no impressions), not zero; `previous` is null when the plan's history doesn't reach back that far. Use it for "how are we doing this month?". `historyDays` is set when the plan's analytics history shortened the window.

**Input**

- `period`: one of `"this_month"` | `"last_month"` — A calendar period in the window's timezone: `this_month` (the 1st to now) or `last_month`. Overrides `days`; `from`/`to` override it.
- `days`: integer (min 1, max 366) — How far back to look, ending now (or at `to`). Defaults to 28.
- `from`: string — Start of the window, an ISO instant or a YYYY-MM-DD day. Overrides `days`.
- `to`: string — End of the window, an ISO instant or a YYYY-MM-DD day (included whole). Defaults to now.
- `accountIds`: array of string (max 50 items) — Only these channels (ids from list\_channels). Omitted means every channel.
- `timezone`: string — IANA zone that days and hours are counted in. Defaults to the workspace's, then UTC.
- `campaignId`: string (max length 200) — Only this campaign's posts (an id from list\_campaigns).
- `tagIds`: array of string (max 20 items) — Only posts carrying any of these tags (ids from list\_tags). With `campaignId`, posts must match both.
- `compareFrom`: string — Start of the window to compare against, an ISO instant or a YYYY-MM-DD day. Defaults to the same length of time just before the window.
- `compareTo`: string — End of the window to compare against, an ISO instant or a YYYY-MM-DD day (included whole). Required with `compareFrom`.
- `compare`: one of `"previous"` | `"year"` — What to compare against when `compareFrom`/`compareTo` aren't given: `previous` (the default, the same length of time just before) or `year` (the same window a year earlier).

### `get_analytics_series`

**Chart a metric over time.** Requires `analytics:read`.
Rate limited: 60 calls per 60s, shared with the HTTP route for the same operation.

How much of one metric (default `engagements`) the workspace's posts received per day or per week (Monday start) across a window, posts of any age, days counted in `timezone` (default the workspace's). Everything a post earned before its first read is dated on the day it went out. Use it for trends: "are reactions growing?"

**Input**

- `period`: one of `"this_month"` | `"last_month"` — A calendar period in the window's timezone: `this_month` (the 1st to now) or `last_month`. Overrides `days`; `from`/`to` override it.
- `days`: integer (min 1, max 366) — How far back to look, ending now (or at `to`). Defaults to 28.
- `from`: string — Start of the window, an ISO instant or a YYYY-MM-DD day. Overrides `days`.
- `to`: string — End of the window, an ISO instant or a YYYY-MM-DD day (included whole). Defaults to now.
- `accountIds`: array of string (max 50 items) — Only these channels (ids from list\_channels). Omitted means every channel.
- `timezone`: string — IANA zone that days and hours are counted in. Defaults to the workspace's, then UTC.
- `campaignId`: string (max length 200) — Only this campaign's posts (an id from list\_campaigns).
- `tagIds`: array of string (max 20 items) — Only posts carrying any of these tags (ids from list\_tags). With `campaignId`, posts must match both.
- `metric`: one of `"engagements"` | `"impressions"` | `"reactions"` | `"comments"` | `"reposts"` | `"quotes"` | `"saves"` | `"clicks"` | `"profileClicks"` | `"videoViews"` — What to chart. Defaults to `engagements`.
- `interval`: one of `"day"` | `"week"` — One point per day (the default) or per week starting Monday.

### `get_follower_growth`

**Get follower growth.** Requires `analytics:read`.
Rate limited: 60 calls per 60s, shared with the HTTP route for the same operation.

Each channel's follower count per day across a window (the day's last reading), with the first, last and change. A channel with no reading in the window is absent. Use it for "how many followers did we gain?"

**Input**

- `period`: one of `"this_month"` | `"last_month"` — A calendar period in the window's timezone: `this_month` (the 1st to now) or `last_month`. Overrides `days`; `from`/`to` override it.
- `days`: integer (min 1, max 366) — How far back to look, ending now (or at `to`). Defaults to 28.
- `from`: string — Start of the window, an ISO instant or a YYYY-MM-DD day. Overrides `days`.
- `to`: string — End of the window, an ISO instant or a YYYY-MM-DD day (included whole). Defaults to now.
- `accountIds`: array of string (max 50 items) — Only these channels (ids from list\_channels). Omitted means every channel.
- `timezone`: string — IANA zone that days and hours are counted in. Defaults to the workspace's, then UTC.

### `get_best_times`

**Get the best times to post.** Requires `analytics:read`.
Rate limited: 60 calls per 60s, shared with the HTTP route for the same operation.

An hour-of-week engagement heatmap for the named channels (or all of them): `matrix[day][row]`, Monday first, rows labelled in `rows`, 0..1 with the strongest cell 1. With `source: "measured"` it comes from their own posts in the window (90 days by default) and `counts` says how many posts sit behind each cell; below `threshold` posts it is `heuristic`, a researched profile for the platform (`researched` false means the generic fallback) — never describe that as data about this audience. To actually place a post, use suggest\_post\_times.

**Input**

- `period`: one of `"this_month"` | `"last_month"` — A calendar period in the window's timezone: `this_month` (the 1st to now) or `last_month`. Overrides `days`; `from`/`to` override it.
- `days`: integer (min 1, max 366) — How far back to look. Defaults to 90.
- `from`: string — Start of the window, an ISO instant or a YYYY-MM-DD day. Overrides `days`.
- `to`: string — End of the window, an ISO instant or a YYYY-MM-DD day (included whole). Defaults to now.
- `accountIds`: array of string (max 50 items) — Only these channels (ids from list\_channels). Omitted means every channel.
- `timezone`: string — IANA zone that days and hours are counted in. Defaults to the workspace's, then UTC.
- `campaignId`: string (max length 200) — Only this campaign's posts (an id from list\_campaigns).
- `tagIds`: array of string (max 20 items) — Only posts carrying any of these tags (ids from list\_tags). With `campaignId`, posts must match both.

### `list_top_posts`

**List top posts.** Requires `analytics:read`.
Rate limited: 60 calls per 60s, shared with the HTTP route for the same operation.

Posts published in a window (default 28 days), best first by `metric` (default `engagements`), each post's channels summed from their latest numbers, with every metric alongside. Cursor-paged: pass `nextCursor` back as `cursor` with the same metric. A post where no channel reported the metric is left out, not ranked as zero. Use it for "what worked best?" or "what should we repost?"

**Input**

- `period`: one of `"this_month"` | `"last_month"` — A calendar period in the window's timezone: `this_month` (the 1st to now) or `last_month`. Overrides `days`; `from`/`to` override it.
- `days`: integer (min 1, max 366) — How far back to look, ending now (or at `to`). Defaults to 28.
- `from`: string — Start of the window, an ISO instant or a YYYY-MM-DD day. Overrides `days`.
- `to`: string — End of the window, an ISO instant or a YYYY-MM-DD day (included whole). Defaults to now.
- `accountIds`: array of string (max 50 items) — Only these channels (ids from list\_channels). Omitted means every channel.
- `timezone`: string — IANA zone that days and hours are counted in. Defaults to the workspace's, then UTC.
- `campaignId`: string (max length 200) — Only this campaign's posts (an id from list\_campaigns).
- `tagIds`: array of string (max 20 items) — Only posts carrying any of these tags (ids from list\_tags). With `campaignId`, posts must match both.
- `metric`: one of `"engagements"` | `"impressions"` | `"reactions"` | `"comments"` | `"reposts"` | `"quotes"` | `"saves"` | `"clicks"` | `"profileClicks"` | `"videoViews"` — What to rank by. Defaults to `engagements`.
- `limit`: integer (min 1, max 50) — Posts per page. Defaults to 20.
- `cursor`: string (max length 500) — `nextCursor` from the previous page, with the same `metric`. Omit for the first page.

### `get_analytics_breakdown`

**Compare platforms or post formats.** Requires `analytics:read`.
Rate limited: 60 calls per 60s, shared with the HTTP route for the same operation.

Which platform, or which kind of post, does best: posts published in a window (default 28 days) split `by: "platform"`, `by: "format"` (video, thread, carousel, single image, poll, link, text only…) or `by: "topic"` (announcements, tutorials, events, opinion, hiring…; posts not yet labelled are left out), each with its post count, total and average engagements per post, impressions and engagement rate. Ranked by the average per post, so a busy channel doesn't win on volume. A group marked `few` has fewer than `minPosts` posts: say it's too few to judge rather than calling it a finding. Use it for "what's my best platform?" or "do videos or threads do better?"

**Input**

- `period`: one of `"this_month"` | `"last_month"` — A calendar period in the window's timezone: `this_month` (the 1st to now) or `last_month`. Overrides `days`; `from`/`to` override it.
- `days`: integer (min 1, max 366) — How far back to look, ending now (or at `to`). Defaults to 28.
- `from`: string — Start of the window, an ISO instant or a YYYY-MM-DD day. Overrides `days`.
- `to`: string — End of the window, an ISO instant or a YYYY-MM-DD day (included whole). Defaults to now.
- `accountIds`: array of string (max 50 items) — Only these channels (ids from list\_channels). Omitted means every channel.
- `timezone`: string — IANA zone that days and hours are counted in. Defaults to the workspace's, then UTC.
- `campaignId`: string (max length 200) — Only this campaign's posts (an id from list\_campaigns).
- `tagIds`: array of string (max 20 items) — Only posts carrying any of these tags (ids from list\_tags). With `campaignId`, posts must match both.
- `by`: one of `"platform"` | `"format"` | `"topic"`, required — `platform` (X, LinkedIn, …), `format` (video, thread, image, …) or `topic` (what the post is about: announcement, tutorial, event, case study, …; posts not yet labelled are left out).

### `get_analytics_insights`

**Find what's working.** Requires `analytics:read`.
Rate limited: 60 calls per 60s, shared with the HTTP route for the same operation.

The strongest patterns in the posts published in a window (default the last 30 days), strongest first (`limit`, default 3): which format, content type, tone, opening-line length, post length, weekday, time of day, links, hashtags, questions and tags did better or worse than the rest, each post judged against its own channel's median so a pattern isn't just the bigger channel winning. `timing` adds which days and times work, which don't and which are untried, with up to three `experiments` to suggest. Each insight has a `summary` with its numbers, a `lift` (2 = twice as well), the post counts, a `confidence` and `exampleGroupIds` for get\_post. Use it for "what's working?" or "what should we post more of?". These are correlations in a small sample, not causes: say so for `low` confidence, and never invent an insight the result doesn't hold. An empty `insights` means too few posts or nothing stands out: say that, and suggest a longer window.

**Input**

- `period`: one of `"this_month"` | `"last_month"` — A calendar period in the window's timezone: `this_month` (the 1st to now) or `last_month`. Overrides `days`; `from`/`to` override it.
- `days`: integer (min 1, max 366) — How far back to look. Defaults to 30: patterns need posts, and a week rarely holds enough.
- `from`: string — Start of the window, an ISO instant or a YYYY-MM-DD day. Overrides `days`.
- `to`: string — End of the window, an ISO instant or a YYYY-MM-DD day (included whole). Defaults to now.
- `accountIds`: array of string (max 50 items) — Only these channels (ids from list\_channels). Omitted means every channel.
- `timezone`: string — IANA zone that days and hours are counted in. Defaults to the workspace's, then UTC.
- `campaignId`: string (max length 200) — Only this campaign's posts (an id from list\_campaigns).
- `tagIds`: array of string (max 20 items) — Only posts carrying any of these tags (ids from list\_tags). With `campaignId`, posts must match both.
- `limit`: integer (min 1, max 10) — How many insights to return, strongest first. Defaults to 3.

### `get_campaign_report`

**Get a campaign's report.** Requires `analytics:read` and `campaign:read`.
Rate limited: 60 calls per 60s, shared with the HTTP route for the same operation.

How one campaign did, as the facts to tell its story with: how many posts it published (`published.posts`, one per post however many channels; `channelPosts` per channel), over how many days, to which channels; totals per metric and engagements per post; its best platform and best format by average engagements per post (if `few` is true, say there are too few posts to call it); its top posts; its longest thread (`longestThread`, null when there was none); what stood out (`insights`); and what hasn't gone out (`pipeline`). Works while a campaign is running and after it ends: `campaign.status` says which. Use it for "how did this campaign do?"

**Input**

- `campaignId`: string (max length 200), required — A campaign id from list\_campaigns.

### `get_post_metrics`

**Get a post's numbers over time.** Requires `analytics:read`.
Rate limited: 60 calls per 60s, shared with the HTTP route for the same operation.

Every read of one post's numbers, per channel, oldest first — how it grew. Reads are taken on a decaying schedule (often on day one, then daily, then weekly), so the points are uneven in time.

**Input**

- `groupId`: string, required — The post's `groupId`: the `id` list\_posts and search\_posts return, `topGroupId` in get\_results. Not a `postId`.

### `get_post_benchmark`

**Compare a post with the others.** Requires `analytics:read`.
Rate limited: 60 calls per 60s, shared with the HTTP route for the same operation.

How one published post did against the workspace's other posts, per channel: its engagements, impressions and engagement rate beside the median, the average and its percentile (the share of peers it beat) among the channel's posts from the 90 days before it, every channel on the same network (when there is more than one), and posts of the same format. Peers are read as they stood at this post's age, so a two-day-old post is measured against others at two days. Use it for "how did this post do?" or "is this above our usual?". `few` means too few peers to say much: say so rather than overstating a percentile.

**Input**

- `groupId`: string, required — The post's `groupId`: the `id` list\_posts and search\_posts return, `topGroupId` in get\_results. Not a `postId`.

### `get_analytics_sync_status`

**Check analytics collection.** Requires `analytics:read`.
Rate limited: 60 calls per 60s, shared with the HTTP route for the same operation.

Per channel: whether its posts' numbers are collected, when they were last and will next be read, the last follower reading, and any pause (a platform rate limit, an auth failure needing a reconnect, or X's daily cap). `x` says whether X is being read this month at all. Use it before reporting a flat chart, to tell a quiet channel from a stalled one.

**Input:** none

### `suggest_campaign_times`

**Plan times for a run of posts.** Requires `postingSchedule:read`.

Free posting slots for a run of posts — a campaign — at a cadence: `count` posts (or `perWeek` × the range, or `perMonth` for a slower run, placed month by month) between `startDate` and `endDate` (up to a year), each one instant on all the named channels, spread across the days. Use it instead of calling suggest\_post\_times once per post: it places the whole run at once, never on a slot that already holds a post, and prefers slots every channel keeps (`partial` marks one only some of them do). Dates are YYYY-MM-DD in the workspace's zone. Fewer `slots` than `requested` means the range ran out of free slots.

**Input**

- `accountIds`: array of string (min 1 items, max 20 items), required
- `count`: integer (min 1, max 40)
- `perWeek`: integer (min 1, max 21)
- `perMonth`: integer (min 1, max 31)
- `startDate`: string
- `endDate`: string

### `create_tag`

**Create a tag.** Requires `tag:create`.

Create a workspace tag. Names are unique per workspace, case-insensitively — creating one that already exists is a conflict, not a silent no-op.

**Input**

- `name`: string (max length 60), required
- `color`: one of `"grey"` | `"dark-grey"` | `"purple"` | `"blue"` | `"teal"` | `"green"` | `"yellow"` | `"orange"` | `"pink"` | `"red"` (default "grey")

### `create_campaign`

**Create a campaign.** Requires `campaign:create`.

Create an empty campaign (a title, and optionally a colour, icon, dates and description). Posts join it afterwards by passing its id as `info.campaignId` on create\_post or save\_draft.

**Input**

- `title`: string (max length 200), required
- `color`: one of `"grey"` | `"dark-grey"` | `"purple"` | `"blue"` | `"teal"` | `"green"` | `"yellow"` | `"orange"` | `"pink"` | `"red"`
- `icon`: string | null
- `startDate`: string (date-time) | null
- `endDate`: string (date-time) | null
- `description`: string (max length 4000, default "")

### `update_campaign`

**Rename or edit a campaign.** Requires `campaign:update`.

Change a campaign's title, description, colour, icon or date range. `title` is required — pass the current one (get\_campaign) when only something else changes. A field you omit is left as it is; an explicit `null` clears `icon`, `startDate` or `endDate`, and `""` clears `description`. Its posts are untouched.

**Input**

- `campaignId`: string, required — A campaign id from list\_campaigns or create\_campaign.
- `title`: string (max length 200), required
- `color`: one of `"grey"` | `"dark-grey"` | `"purple"` | `"blue"` | `"teal"` | `"green"` | `"yellow"` | `"orange"` | `"pink"` | `"red"`
- `icon`: string | null
- `startDate`: string (date-time) | null
- `endDate`: string (date-time) | null
- `description`: string (max length 4000)

### `delete_campaign`

**Delete a campaign and its posts.** Requires `campaign:delete`.

Remove a campaign together with every post in it — drafts, scheduled, pending and published alike are deleted from Orbitwrite. Posts already live on the networks stay up there; this never reaches a platform. To keep a post, move it out first with update\_post\_info (`campaignId: null`). Not reversible — confirm with the person before calling.

**Input**

- `campaignId`: string, required — A campaign id from list\_campaigns or create\_campaign.

### `duplicate_campaign`

**Duplicate a campaign.** Requires `campaign:create`.

Copy a campaign's own fields — title (with " (copy)" appended), description, colour, icon and date range — into a new campaign and return it. Its posts are not copied: the new campaign starts empty, and posts join it by `info.campaignId` on create\_post or save\_draft, or with update\_post\_info.

**Input**

- `campaignId`: string, required — A campaign id from list\_campaigns or create\_campaign.

### `decide_campaign`

**Approve or reject a campaign.** Requires `approval:decide`.

Record the review decision on a whole campaign. A generated campaign's posts wait behind it: approving releases every one of them to publish at its scheduled time, rejecting sends them back to drafts. `comment` goes back to whoever generated it. Needs approval:decide — the same authority as approving a single post.

**Input**

- `campaignId`: string, required — The campaign's id, from list\_campaigns.
- `decision`: one of `"approved"` | `"rejected"`, required
- `comment`: string | null

### `upload_media`

**Upload an image or video.** Requires `media:create`.

Store bytes you already hold. Returns a record whose `id` is what you put in an item's `mediaIds` on create\_post or save\_draft. Use this for an image you generated or were given — anything you have as data rather than as a public URL. `dataBase64` is standard base64 of the file itself (a data: prefix is tolerated). Images, video and PDF only, up to 10MB decoded.

**Input**

- `name`: string (max length 255), required
- `mimeType`: string (max length 255), required
- `dataBase64`: string, required
- `altText`: string (max length 2000)

### `upload_media_from_url`

**Upload media from a URL.** Requires `media:create`.

Fetch a publicly reachable URL and store it. Returns a record whose `id` goes in an item's `mediaIds`. Cheaper than upload\_media when the file is already on the web — the bytes never pass through the conversation. The URL must be publicly resolvable; private and internal addresses are refused.

**Input**

- `url`: string (uri), required
- `name`: string (max length 255)
- `altText`: string (max length 2000)

### `suggest_alt_text`

**Describe an image.** Requires `media:read`.
Rate limited: 60 calls per 3600s, shared with the HTTP route for the same operation.

Suggested alt text for one of the workspace's uploaded images, for screen readers: a plain description of what's visible, with any text in the image transcribed, at most 1,000 characters. Pass `mediaId` (from upload\_media, list\_media or a post's attachment) or, for an attachment with no id, its `url`; add the post's text as `context` so the description emphasises what the post is about. Nothing is saved — put the result in that attachment's `altText` when you save the post. Spends one AI assist; if you can see the image yourself, writing the alt text directly costs nothing.

**Input**

- `mediaId`: string — The image's media id — from an upload, list\_media, or a post's attachment.
- `url`: string (uri) — The image's URL, for an attachment that carries no media id. Must be one of this workspace's own uploads.
- `context`: string (max length 2000) — The post the image goes with. Used to decide what in the image matters, never as a source of facts the image doesn't show.

### `save_draft`

**Save a draft.** Requires `post:create`.

Create or replace a draft. With `groupId` the existing draft is replaced wholesale (that is the composer's autosave semantics — every editable row is deleted and re-inserted, preserving status, per-account fire times and the approval gate); without one a new draft is created. Each channel's `items` is its thread, one item per post; items need no `id`. Omit `isSynced`: channels whose copy matches the first channel's are grouped as one post; a channel with its own copy gets its own group, which is what keeps the app from overwriting it. To attach an image or video, first call upload\_media or upload\_media\_from\_url, then put the returned id in that item's `mediaIds`. To edit an existing post, get\_post it first and send the whole `accounts` array back with its `groupId`.

**Input**

- `groupId`: string
- `accounts`: array of object (min 1 items), required
  - `accountId`: string, required
  - `items`: array of object, required
    - `id`: string
    - `content`: string, required
    - `poll`: `PollInput`
      - `options`: array of string (min 2 items, max 4 items), required
      - `durationMinutes`: integer (min 5, max 10080), required
    - `media`: array of `MediaAttachment`
      - `url`: string (uri), required
      - `mimeType`: string, required
      - `mediaId`: string
      - `altText`: string
      - `width`: integer (min 0, max 9007199254740991)
      - `height`: integer (min 0, max 9007199254740991)
      - `bytes`: integer (min 0, max 9007199254740991)
      - `durationSec`: number (min 0)
      - `sourceUrl`: string (uri)
      - `videoUrl`: string (uri)
      - `title`: string (max length 300)
      - `pageCount`: integer (max 9007199254740991)
    - `mediaIds`: array of string (max 10 items)
    - `quote`: string (uri)
    - `replyTo`: string (uri)
    - `linkPreview`: object
      - `url`: string (uri), required
      - `matchUrl`: string, required
      - `title`: string | null, required
      - `description`: string | null, required
      - `image`: string | null, required
      - `siteName`: string | null, required
  - `isSynced`: boolean — Omit it: derived from whether the copy matches the first channel's.
  - `autoEngagement`: object
    - `comment`: object, required
      - `enabled`: boolean, required
      - `entries`: array of object (default \[])
        - `fromAccountId`: string, required
        - `delayMinutes`: integer (min 0, max 10080), required
        - `items`: array of `CommentItem` (default \[])
          - `id`: string
          - `content`: string, required
          - `media`: array of `MediaAttachment`
          - `mediaIds`: array of string (max 10 items)
          - `poll`: `PollInput`
        - `replyTarget`: one of `"thread_tail"` | `"root"`
    - `like`: object, required
      - `enabled`: boolean, required
      - `entries`: array of object (default \[])
        - `fromAccountId`: string, required
        - `delayMinutes`: integer (min 0, max 10080), required
        - `reaction`: one of `"LIKE"` | `"LOVE"` | `"CELEBRATE"` | `"INSIGHTFUL"` | `"CURIOUS"` | `"SUPPORT"`
    - `repost`: object, required
      - `enabled`: boolean, required
      - `entries`: array of object (default \[])
        - `fromAccountId`: string, required
        - `delayMinutes`: integer (min 0, max 10080), required
    - `slack`: object
      - `enabled`: boolean, required
      - `entries`: array of `ChannelMessageEntry` (default \[])
        - `fromAccountId`: string, required
        - `delayMinutes`: integer (min 0, max 10080), required
        - `items`: array of `CommentItem` (default \[])
          - `id`: string
          - `content`: string, required
          - `media`: array of `MediaAttachment`
          - `mediaIds`: array of string (max 10 items)
          - `poll`: `PollInput`
    - `discord`: object
      - `enabled`: boolean, required
      - `entries`: array of `ChannelMessageEntry` (default \[])
        - `fromAccountId`: string, required
        - `delayMinutes`: integer (min 0, max 10080), required
        - `items`: array of `CommentItem` (default \[])
          - `id`: string
          - `content`: string, required
          - `media`: array of `MediaAttachment`
          - `mediaIds`: array of string (max 10 items)
          - `poll`: `PollInput`
  - `replySettings`: one of `"everyone"` | `"following"` | `"mentionedUsers"` | `"subscribers"` | `"verified"` | `"accounts_you_follow"` | `"mentioned_only"` | `"followers_only"` | `"parent_post_author_only"`
  - `postFormat`: one of `"post"` | `"story"` | `"reel"` | `"short"`
  - `platformOptions`: object
    - `tiktok`: object
      - `title`: string (max length 2200)
      - `privacyLevel`: one of `"PUBLIC_TO_EVERYONE"` | `"MUTUAL_FOLLOW_FRIENDS"` | `"FOLLOWER_OF_CREATOR"` | `"SELF_ONLY"`
      - `disableComment`: boolean
      - `disableDuet`: boolean
      - `disableStitch`: boolean
      - `brandOrganicToggle`: boolean
      - `brandContentToggle`: boolean
      - `isAigc`: boolean — Label as AI-generated ("Creator labeled as AI-generated").
      - `videoCoverTimestampMs`: integer (min 0, max 9007199254740991)
      - `photoCoverIndex`: integer (min 0, max 9007199254740991)
      - `autoAddMusic`: boolean
    - `threads`: object
      - `shareToInstagram`: boolean
      - `topicTag`: string (max length 51) — The post's topic (Threads' `topic_tag`): 1 to 50 characters, no periods or ampersands. A leading # is ignored. Set on the first post of a thread only.
    - `youtube`: object
      - `title`: string (max length 100)
      - `privacyStatus`: one of `"public"` | `"unlisted"` | `"private"`
      - `categoryId`: string
      - `tags`: array of string (max 100 items)
      - `madeForKids`: boolean
      - `containsSyntheticMedia`: boolean — Altered or synthetic content: realistic video made or changed with AI (a real person, place or event).
    - `instagram`: object
      - `videoCoverTimestampMs`: integer (min 0, max 9007199254740991)
      - `coverImageUrl`: string (uri)
      - `isAiGenerated`: boolean — Label as AI-generated ("AI info"). Not applied to stories.
      - `userTags`: array of object (max 200 items) — People tagged in the media, not caption @mentions. x/y: 0-1 from the photo's top left (default centre). mediaUrl: which attachment (default the first). 20 per item max, public accounts only.
        - `username`: string, required
        - `x`: number (min 0, max 1)
        - `y`: number (min 0, max 1)
        - `mediaUrl`: string (uri)
      - `collaborators`: array of string (max 3 items) — Up to 3 usernames invited as collaborators. Not applied to stories.
    - `twitter`: object
      - `madeWithAi`: boolean — Label "Made with AI". Sent only when the first post has media.
    - `pinterest`: object
      - `boardId`: string (max length 64) — The board to save the Pin to. Required to publish.
      - `boardSectionId`: string (max length 64) — A section of that board.
      - `title`: string (max length 100) — The Pin's title. The text is its description.
      - `link`: string (uri) (max length 2048) — Where the Pin opens when tapped.
      - `videoCoverTimestampMs`: integer (min 0, max 9007199254740991)
      - `coverImageUrl`: string (uri)
      - `isAiModified`: boolean — Label the Pin "AI modified" (Pinterest's ai\_disclosures).
  - `aiGenerated`: boolean — Label as AI-generated where the network has a label (TikTok, Instagram, X, YouTube, Pinterest).
- `info`: object
  - `title`: string (max length 200)
  - `tags`: array of string (max 50 items)
  - `tagColors`: object
  - `campaignId`: string | null
  - `templateId`: string | null

### `create_post`

**Schedule or publish a post.** Requires `post:create`.

Create posts on one or more channels. Pass `scheduledAt` (suggest\_post\_times finds a free slot) or `publishNow: true`, which needs no time. Each channel's `items` is its thread, one item per post; items need no `id`. Omit `isSynced`: channels whose copy matches the first channel's are grouped as one post; a channel with its own copy gets its own group, which is what keeps the app from overwriting it. To attach media, call upload\_media or upload\_media\_from\_url first and put the returned id in that item's `mediaIds`. If the media was made with AI, set `aiGenerated: true` on each channel.

Publishing is handed to the background worker, so `publishNow` means 'within about a minute', not 'by the time this returns'. Call get\_publish\_status with the returned `groupIds` for the outcome, and get\_post to read back what was created.

To route the post through review, pass `approval` with `reviewerIds` from list\_members and a `blocking` choice (if the person hasn't said, ask whether the post should wait for approval or publish on schedule regardless) — the request is created in the same transaction and reviewers are told in-app and on the workspace's Slack/Discord routes (email only when asked from the app). An author without publish authority must do this to schedule at all.

**Input**

- `scheduledAt`: string — When to publish; required unless `publishNow` is true. An ISO 8601 instant, Z or an offset.
- `accounts`: array of object (min 1 items), required
  - `accountId`: string, required
  - `items`: array of object, required
    - `id`: string
    - `content`: string, required
    - `poll`: `PollInput`
      - `options`: array of string (min 2 items, max 4 items), required
      - `durationMinutes`: integer (min 5, max 10080), required
    - `media`: array of `MediaAttachment`
      - `url`: string (uri), required
      - `mimeType`: string, required
      - `mediaId`: string
      - `altText`: string
      - `width`: integer (min 0, max 9007199254740991)
      - `height`: integer (min 0, max 9007199254740991)
      - `bytes`: integer (min 0, max 9007199254740991)
      - `durationSec`: number (min 0)
      - `sourceUrl`: string (uri)
      - `videoUrl`: string (uri)
      - `title`: string (max length 300)
      - `pageCount`: integer (max 9007199254740991)
    - `mediaIds`: array of string (max 10 items)
    - `quote`: string (uri)
    - `replyTo`: string (uri)
    - `linkPreview`: object
      - `url`: string (uri), required
      - `matchUrl`: string, required
      - `title`: string | null, required
      - `description`: string | null, required
      - `image`: string | null, required
      - `siteName`: string | null, required
  - `isSynced`: boolean — Omit it: derived from whether the copy matches the first channel's.
  - `scheduledAt`: string — This channel's own time, if different. An ISO 8601 instant, Z or an offset.
  - `autoEngagement`: object
    - `comment`: object, required
      - `enabled`: boolean, required
      - `entries`: array of object (default \[])
        - `fromAccountId`: string, required
        - `delayMinutes`: integer (min 0, max 10080), required
        - `items`: array of `CommentItem` (default \[])
          - `id`: string
          - `content`: string, required
          - `media`: array of `MediaAttachment`
          - `mediaIds`: array of string (max 10 items)
          - `poll`: `PollInput`
        - `replyTarget`: one of `"thread_tail"` | `"root"`
    - `like`: object, required
      - `enabled`: boolean, required
      - `entries`: array of object (default \[])
        - `fromAccountId`: string, required
        - `delayMinutes`: integer (min 0, max 10080), required
        - `reaction`: one of `"LIKE"` | `"LOVE"` | `"CELEBRATE"` | `"INSIGHTFUL"` | `"CURIOUS"` | `"SUPPORT"`
    - `repost`: object, required
      - `enabled`: boolean, required
      - `entries`: array of object (default \[])
        - `fromAccountId`: string, required
        - `delayMinutes`: integer (min 0, max 10080), required
    - `slack`: object
      - `enabled`: boolean, required
      - `entries`: array of `ChannelMessageEntry` (default \[])
        - `fromAccountId`: string, required
        - `delayMinutes`: integer (min 0, max 10080), required
        - `items`: array of `CommentItem` (default \[])
          - `id`: string
          - `content`: string, required
          - `media`: array of `MediaAttachment`
          - `mediaIds`: array of string (max 10 items)
          - `poll`: `PollInput`
    - `discord`: object
      - `enabled`: boolean, required
      - `entries`: array of `ChannelMessageEntry` (default \[])
        - `fromAccountId`: string, required
        - `delayMinutes`: integer (min 0, max 10080), required
        - `items`: array of `CommentItem` (default \[])
          - `id`: string
          - `content`: string, required
          - `media`: array of `MediaAttachment`
          - `mediaIds`: array of string (max 10 items)
          - `poll`: `PollInput`
  - `replySettings`: one of `"everyone"` | `"following"` | `"mentionedUsers"` | `"subscribers"` | `"verified"` | `"accounts_you_follow"` | `"mentioned_only"` | `"followers_only"` | `"parent_post_author_only"`
  - `postFormat`: one of `"post"` | `"story"` | `"reel"` | `"short"`
  - `platformOptions`: object
    - `tiktok`: object
      - `title`: string (max length 2200)
      - `privacyLevel`: one of `"PUBLIC_TO_EVERYONE"` | `"MUTUAL_FOLLOW_FRIENDS"` | `"FOLLOWER_OF_CREATOR"` | `"SELF_ONLY"`
      - `disableComment`: boolean
      - `disableDuet`: boolean
      - `disableStitch`: boolean
      - `brandOrganicToggle`: boolean
      - `brandContentToggle`: boolean
      - `isAigc`: boolean — Label as AI-generated ("Creator labeled as AI-generated").
      - `videoCoverTimestampMs`: integer (min 0, max 9007199254740991)
      - `photoCoverIndex`: integer (min 0, max 9007199254740991)
      - `autoAddMusic`: boolean
    - `threads`: object
      - `shareToInstagram`: boolean
      - `topicTag`: string (max length 51) — The post's topic (Threads' `topic_tag`): 1 to 50 characters, no periods or ampersands. A leading # is ignored. Set on the first post of a thread only.
    - `youtube`: object
      - `title`: string (max length 100)
      - `privacyStatus`: one of `"public"` | `"unlisted"` | `"private"`
      - `categoryId`: string
      - `tags`: array of string (max 100 items)
      - `madeForKids`: boolean
      - `containsSyntheticMedia`: boolean — Altered or synthetic content: realistic video made or changed with AI (a real person, place or event).
    - `instagram`: object
      - `videoCoverTimestampMs`: integer (min 0, max 9007199254740991)
      - `coverImageUrl`: string (uri)
      - `isAiGenerated`: boolean — Label as AI-generated ("AI info"). Not applied to stories.
      - `userTags`: array of object (max 200 items) — People tagged in the media, not caption @mentions. x/y: 0-1 from the photo's top left (default centre). mediaUrl: which attachment (default the first). 20 per item max, public accounts only.
        - `username`: string, required
        - `x`: number (min 0, max 1)
        - `y`: number (min 0, max 1)
        - `mediaUrl`: string (uri)
      - `collaborators`: array of string (max 3 items) — Up to 3 usernames invited as collaborators. Not applied to stories.
    - `twitter`: object
      - `madeWithAi`: boolean — Label "Made with AI". Sent only when the first post has media.
    - `pinterest`: object
      - `boardId`: string (max length 64) — The board to save the Pin to. Required to publish.
      - `boardSectionId`: string (max length 64) — A section of that board.
      - `title`: string (max length 100) — The Pin's title. The text is its description.
      - `link`: string (uri) (max length 2048) — Where the Pin opens when tapped.
      - `videoCoverTimestampMs`: integer (min 0, max 9007199254740991)
      - `coverImageUrl`: string (uri)
      - `isAiModified`: boolean — Label the Pin "AI modified" (Pinterest's ai\_disclosures).
  - `aiGenerated`: boolean — Label as AI-generated where the network has a label (TikTok, Instagram, X, YouTube, Pinterest).
- `replaceGroupId`: string
- `publishNow`: boolean
- `info`: object
  - `title`: string (max length 200)
  - `tags`: array of string (max 50 items)
  - `tagColors`: object
  - `campaignId`: string | null
  - `templateId`: string | null
- `approval`: object
  - `reviewerIds`: array of string (min 1 items), required
  - `message`: string | null
  - `dueDate`: string | null
  - `requireAll`: boolean (default false)
  - `blocking`: boolean, required — Required. `true` (blocking): the post will not publish until the request is approved. `false` (non-blocking): the post publishes on schedule, and the review is advisory. If the person has not said which, ask them with these two options rather than choosing. An author without publish authority always gets a blocking request, so pass `true` for them.
  - `publishOnApprove`: boolean (default false)

### `get_post`

**Read one post in full.** Requires `post:read`.

The whole post by id: every channel's items (text, media, poll, quote, reply-to, link preview), per-channel settings, fire time, title, tags, campaign, and both approval states. This is the read list\_posts deliberately isn't — call it before editing (save\_draft replaces the whole `accounts` array, so you need the current one) and after creating, to confirm what landed.

`status` is the group's real lifecycle state: `draft`, `scheduled`, `pending_approval`, `publishing`, `published`, `failed`, or `mixed` when its channels have diverged. Unlike the app's own open path, reading never changes anything — a failed post stays failed until you act on it: retry\_publish re-queues the failed channel, and get\_post\_activity says what happened to it and when.

`approvalStatus` is what a reviewer sees (the active request, blocking or not); `approvalGateStatus` is what actually stands between the post and the worker.

**Input**

- `groupId`: string, required — The post's `groupId`: the `id` list\_posts and search\_posts return, `topGroupId` in get\_results. Not a `postId`.

### `score_post`

**Score a saved post.** Requires `post:read`.

A health check for a saved post, the same checklist the composer shows: a 0–100 score per channel and overall, and a pass/warn/fail item with advice for each of hook, length, readability, spelling slips, leftover placeholders, AI-writing tells, call to action, engagement bait, hashtags, mentions, link placement and safety, link tracking, the link card's image, visuals, alt text, posting time and the workspace's brand-voice rules. Use it after save\_draft or before publishing, fix the `warn`/`fail` items with save\_draft, and score again.

The length and hashtag ranges are published platform guidance, not this workspace's analytics: never describe them to a person as data about their audience. Hard limits (character caps, required media) aren't scored; create\_post refuses those.

**Input**

- `groupId`: string, required — The post's `groupId`: the `id` list\_posts and search\_posts return, `topGroupId` in get\_results. Not a `postId`.

### `get_post_cover`

**Read a post's video cover.** Requires `post:read`.

Each channel's video cover — the still people see before they press play — for a saved post: the cover that will publish (`null` is the platform's default frame, `defaultFrameMs`), what the channel takes (`accepts`: Instagram Reels and Pinterest videos take a `frame` or an `image`, TikTok a `frame`), `reason` when it takes none (X, Bluesky, Threads and the rest let no app set one; an Instagram story or carousel has none), the video's length, and whether the post can still change.

**Input**

- `groupId`: string, required — The post's `groupId`: the `id` list\_posts and search\_posts return, `topGroupId` in get\_results. Not a `postId`.

### `set_post_cover`

**Set a post's video cover.** Requires `post:update`.

Choose the cover of a saved post that hasn't gone out: `cover: { frameMs }` for a frame of the video (milliseconds from the start, within its length), `{ mediaId }` for an uploaded JPEG (upload\_media or upload\_media\_from\_url first) or `{ imageUrl }` for a public JPEG — images are Instagram Reels and Pinterest only — or `null` to go back to the platform's default. Without `accountIds` it lands on every channel that takes that kind of cover and says which in `updated`; naming a channel that can't take it is refused. Read get\_post\_cover first to see what each channel takes. Changes nothing else about the post.

**Input**

- `groupId`: string, required — The post's `groupId`: the `id` list\_posts and search\_posts return, `topGroupId` in get\_results. Not a `postId`.
- `accountIds`: array of string (min 1 items, max 50 items) — The channels to change. Omit for every channel on the post that takes this kind of cover.
- `cover`: one of (one of (object | object) | object) | null, required — `{ frameMs }` picks a frame of the video, `{ imageUrl }` or `{ mediaId }` an image (Instagram and Pinterest), `null` clears it back to the platform's default.
  - variant 1: one of (object | object)
    - variant 1: object
      - `frameMs`: integer (min 0, max 9007199254740991), required — Which frame of the video is the cover, in milliseconds from the start.
    - variant 2: object
      - `imageUrl`: string (uri), required — A public URL to a JPEG the platform fetches as the cover. Instagram Reels and Pinterest.
  - variant 2: object
    - `mediaId`: string, required — An uploaded JPEG from the media library, used as the cover image. Instagram and Pinterest.

### `score_content`

**Score post content before saving.** Requires `post:read`.

The same health check as score\_post, for content you haven't saved yet: pass each channel's thread (and the text of its first comment, if you'll add one) and get the score and checklist back. Nothing is stored. Use it to revise a post before create\_post — a channel's `items` are the same thread you'd send there, without ids. `scheduledAt` (optional) is judged against the platform's typical peak hours in the workspace's zone. To have the link card's image checked, pass the opening item's `linkPreview` (get\_link\_preview's answer); without it that check is skipped.

**Input**

- `accounts`: array of object (min 1 items, max 50 items), required
  - `accountId`: string, required
  - `items`: array of object (min 1 items, max 50 items), required
    - `content`: string (max length 100000), required
    - `media`: array of object (max 20 items)
      - `mimeType`: string, required
      - `altText`: string (max length 5000)
    - `poll`: any
    - `quote`: string
    - `replyTo`: string
    - `linkPreview`: object | null
      - `url`: string | null
      - `image`: string | null, required
  - `firstComment`: string (max length 10000)
  - `destinationLink`: string (max length 2048)
- `scheduledAt`: string (date-time)

### `get_publish_status`

**Check publish progress.** Requires `post:read`.

Per-channel publish outcome for one or more posts (by `groupId` — the post's id): each account's status, its error or warning when there is one, and once published the live `platformUrl` on the network plus the network's own `platformPostId`. Each account's `composerGroupId` is the post it belongs to. `done` is true once every channel has reached a terminal state. This is how you find out what happened after create\_post with `publishNow` or after publish\_post — both hand the work to the background worker and return before it runs.

**Input**

- `groupIds`: array of string (min 1 items, max 50 items), required — Post ids — the `groupId`s create\_post or publish\_post returned, or `id`s from list\_posts.

### `schedule_post`

**Schedule a post, or move its time.** Requires `post:update`.

Set when a post fires. On a draft or a planned post this is the step to scheduled: the content already stored is what goes out, so there is nothing to resend, and it must have some content. To schedule a planned post at the time it was planned for, pass that time. On a scheduled or pending-approval post the whole group shifts to the new time, keeping any per-channel stagger. `scheduledAt` must be in the future; suggest\_post\_times picks a slot that isn't already taken.

A draft whose author can't publish is scheduled behind the approval gate exactly as the app would do it: it fires once a reviewer approves.

**Input**

- `groupId`: string, required — The post's `groupId`: the `id` list\_posts and search\_posts return, `topGroupId` in get\_results. Not a `postId`.
- `scheduledAt`: string (date-time), required — ISO 8601 instant (Z or an offset), strictly in the future.

### `publish_post`

**Publish a post now.** Requires `post:update`.

Send a draft, planned or scheduled post as soon as possible, using the content already stored. Needs publish authority: an author who must request approval is refused, and a post whose blocking approval is still pending or rejected is refused too — the worker would never claim it.

Like create\_post's `publishNow`, this queues the post for the background worker rather than publishing inline, so it means 'within about a minute'. Follow up with get\_publish\_status.

**Input**

- `groupId`: string, required — The post's `groupId`: the `id` list\_posts and search\_posts return, `topGroupId` in get\_results. Not a `postId`.

### `retry_publish`

**Retry a failed publish.** Requires `post:update`.

Re-queue one channel of a post whose publish failed, using the content already stored. Name the post and the channel: `accountId` is the channel's id on its entry in get\_publish\_status (where its `error` says why it failed) or get\_post. That channel's failed rows go back to scheduled at now and the background worker picks them up, so like publish\_post this means 'within about a minute' — follow up with get\_publish\_status. Other channels of the same post are untouched. Refused when that channel has nothing to retry: it is still publishing, or it is already live on the network (including a thread whose opening post went out).

An author without publish authority gets the approval gate applied instead of a publish: `pendingApproval: true` in the result means the channel is re-queued but held until a reviewer approves, and nothing goes out until then.

**Input**

- `groupId`: string, required — The post's `groupId`: the `id` list\_posts and search\_posts return, `topGroupId` in get\_results. Not a `postId`.
- `accountId`: string, required — The channel whose publish failed — the `accountId` on that channel's entry in get\_publish\_status or get\_post.

### `unschedule_post`

**Move a post back to draft.** Requires `post:update`.

Take a planned, scheduled or pending-approval post off the calendar and return it to draft, keeping its content and freeing its slot. On a scheduled post an unresolved blocking approval keeps gating the draft and an already-approved one is dissolved, so the post lands as a plain draft; `removeApproval: true` dissolves the request regardless, and needs publish authority.

**Input**

- `groupId`: string, required — The post's `groupId`: the `id` list\_posts and search\_posts return, `topGroupId` in get\_results. Not a `postId`.
- `removeApproval`: boolean — Also dissolve the group's approval request, landing the post as a plain (ungated) draft. Needs publish authority.

### `plan_post`

**Plan a post for a time.** Requires `post:update`.

Give a draft a slot without scheduling it. A planned post shows on the calendar and takes its slot in the posting queue (suggest\_post\_times skips it), but nothing publishes it: it goes out only once schedule\_post (at its planned time, or another) or publish\_post is called, and both need it to have content. Content may be empty while it's planned. On an already-planned post this replans it: the whole group moves, keeping per-channel stagger. `plannedAt` must be in the future. A scheduled post is refused (unschedule\_post it first); unschedule\_post also takes a planned post back to draft. To plan a new post, save\_draft it first.

**Input**

- `groupId`: string, required
- `plannedAt`: string (date-time), required

### `delete_post`

**Delete a draft or scheduled post.** Requires `post:delete`.

Discard a post that hasn't gone out — draft, planned, scheduled or pending approval. Refused once any channel is publishing, published or failed: removing a live post from the networks is a different operation this server doesn't perform, and a failed channel is recovered with retry\_publish rather than deleted.

**Input**

- `groupId`: string, required — The post's `groupId`: the `id` list\_posts and search\_posts return, `topGroupId` in get\_results. Not a `postId`.

### `update_post_info`

**Update a post's title, tags or campaign.** Requires `post:update`.

Metadata only — the content and schedule are untouched, so this is safe on scheduled and published posts. Fields you omit are left as they are; `campaignId: null` removes the post from its campaign. Tags are created by name if they don't exist yet.

**Input**

- `groupId`: string, required — The post's `groupId`: the `id` list\_posts and search\_posts return, `topGroupId` in get\_results. Not a `postId`.
- `title`: string (max length 200)
- `tags`: array of string (max 50 items)
- `tagColors`: object
- `campaignId`: string | null
- `templateId`: string | null

### `duplicate_post`

**Duplicate a post as a new draft.** Requires `post:create`.

Copy a post into a new draft and return its `groupId`: the content of every channel (text, threads, media, polls, settings), its title, tags and campaign are carried over; the schedule, the approval request and anything the networks assigned (the live URL, the platform id) are not. Works on drafts, scheduled, pending-approval and published posts — not on one mid-publish or failed. This is how to repost a winner: find it with get\_results or list\_posts, duplicate it, rewrite the hook with save\_draft (get\_post the copy first; save\_draft replaces the whole `accounts` array), then schedule\_post. The original is untouched.

**Input**

- `groupId`: string, required — The post's `groupId`: the `id` list\_posts and search\_posts return, `topGroupId` in get\_results. Not a `postId`.

### `get_post_activity`

**Read a post's history.** Requires `post:read`.

What happened to one post, newest first: who created it, what they edited (per channel), title, tag and campaign changes, when it was scheduled, moved or taken off the calendar, each approval step and the reviewer's note, comments, and the publish outcome per channel (published, or failed and why). Each entry carries its `type`, the `actor` (null for the worker's own moves), `createdAt` and type-specific `data` such as a text excerpt, the channels concerned or the time it was moved to. A run of edits by one person is one entry. Use it to answer "who changed this?" or "why didn't this go out?"; list\_activity is the same feed for the whole workspace.

**Input**

- `groupId`: string, required — The post's `groupId`: the `id` list\_posts and search\_posts return, `topGroupId` in get\_results. Not a `postId`.
- `limit`: integer (min 1, max 100, default 20) — How many of the newest entries to return, 1–100. Default 20.

### `list_activity`

**Read the workspace activity feed.** Requires `analytics:read`.

What happened across the workspace, newest first: posts scheduled, moved, published or failed, approvals requested and decided, comments, campaigns created and finished, members invited and joined. One entry per event (a post going out to four channels is one entry naming all four), each with its `type`, `actor`, `createdAt`, the post or campaign it concerns (`targetId`) and type-specific `data`. Filter with `types`; page with `cursor` from the previous page's `nextCursor`. Use it for "what happened since yesterday?" or "did anything fail overnight?". A post's own drafting history (its edits and metadata changes) is on get\_post\_activity, not here.

**Input**

- `limit`: integer (min 1, max 100, default 50) — Page size, 1–100. Default 50.
- `cursor`: string — The `nextCursor` from the previous page. Omit for the first page; a made-up value is refused.
- `types`: array of one of `"post.scheduled"` | `"post.rescheduled"` | `"post.unscheduled"` | `"post.published"` | `"post.failed"` | `"post.returned_to_draft"` | `"post.deleted"` | `"approval.requested"` | `"approval.reviewers_changed"` | `"approval.approved"` | `"approval.rejected"` | `"approval.reset"` | `"approval.removed"` | `"comment.added"` | `"campaign.created"` | `"campaign.completed"` | `"member.invited"` | `"member.joined"` | `"streak.reached"` — Only these event types, any of (OR). Omit for everything the workspace feed carries.

### `list_members`

**List workspace members.** Requires `member:read`.

Everyone in the workspace with their role. The `userId` values are what request\_approval takes as `reviewerIds`, and what a member's name resolves to in activity and comments.

**Input:** none

### `get_approval`

**Read a post's approval request.** Requires `approval:read`.

The live approval request on a post, or null when there is none: status, whether it blocks publishing, whether every reviewer must approve, the due date, and each reviewer with their decision so far.

**Input**

- `groupId`: string, required — The post's `groupId`: the `id` list\_posts and search\_posts return, `topGroupId` in get\_results. Not a `postId`.

### `request_approval`

**Request review of a post.** Requires `approval:request`.

Ask named teammates to approve a post before it publishes, or edit an existing request's roster, message and due date (`intent: "edit"`). `reviewerIds` are member `userId`s from list\_members. `blocking` is required: `true` holds the post at the publish gate until approved, `false` lets it publish on schedule with the review as advice. When the person hasn't said which, ask them to choose between those two before calling. An author without publish authority always gets a blocking request whatever they pass. `requireAll` needs every reviewer's approval rather than the first.

New reviewers are notified in-app and on the workspace's Slack/Discord routes; the reviewer email is only sent when the request is made from the app.

**Input**

- `groupId`: string, required — The post's `groupId`: the `id` list\_posts and search\_posts return, `topGroupId` in get\_results. Not a `postId`.
- `intent`: one of `"create"` | `"edit"` (default "create")
- `reviewerIds`: array of string (min 1 items), required
- `message`: string | null
- `dueDate`: string (date-time) | null
- `requireAll`: boolean (default false)
- `blocking`: boolean, required — Required. `true` (blocking): the post will not publish until the request is approved. `false` (non-blocking): the post publishes on schedule, and the review is advisory. If the person has not said which, ask them with these two options rather than choosing. An author without publish authority always gets a blocking request, so pass `true` for them. On `intent: "edit"`, pass the request's current value (get\_approval) unless the person asked to change it.

### `decide_approval`

**Approve or reject a post.** Requires `approval:decide`.

Record a decision on a post's approval request. `comment` is the note that goes back to whoever asked — on a rejection it is the reason, so say what should change. Only a named reviewer or a holder of approval:decide may decide, never the post's own author unless they could have published it themselves. On a require-all request one approval is a vote, not the outcome: `resolvesRequest` says whether this decision settled it. Approving a blocking request releases the post to publish at its scheduled time.

**Input**

- `groupId`: string, required — The post's `groupId`: the `id` list\_posts and search\_posts return, `topGroupId` in get\_results. Not a `postId`.
- `decision`: one of `"approved"` | `"rejected"`, required
- `comment`: string | null

### `remove_approval`

**Withdraw a post's approval request.** Requires `approval:request`.

Remove the request entirely, which opens the publish gate — so it needs publish authority, the same as approving would. An author who can't publish cannot withdraw their own request; a reviewer has to decide it.

**Input**

- `groupId`: string, required — The post's `groupId`: the `id` list\_posts and search\_posts return, `topGroupId` in get\_results. Not a `postId`.

### `list_comments`

**Read a post's comments.** Requires `comment:read`.

The review comments on a post, oldest first: each top-level comment and its replies (`parentId` names the thread), who wrote it, whether the thread is resolved, and any tracked text suggestions with their status. A comment's `quote` is the passage it is anchored to, when it has one.

**Input**

- `groupId`: string, required — The post's `groupId`: the `id` list\_posts and search\_posts return, `topGroupId` in get\_results. Not a `postId`.

### `add_comment`

**Comment on a post.** Requires `comment:create`.

Leave a review comment on a post, or reply under an existing thread by passing its top-level comment's `id` from list\_comments as `parentId` (replies don't nest). The comment is written as the person this connection acts for, and teammates following the post see it in the composer. To propose new wording, say so in the comment and quote the passage; tracked suggestions are anchored inside the editor and can only be made there.

**Input**

- `groupId`: string, required — The post's `groupId`: the `id` list\_posts and search\_posts return, `topGroupId` in get\_results. Not a `postId`.
- `body`: string (max length 5000), required — The comment text.
- `parentId`: string — Reply under this top-level comment instead of starting a thread.

### `resolve_comment`

**Resolve or reopen a comment thread.** Requires `comment:update`.

Mark a comment thread resolved, or reopen it with `resolved: false`. Team state: anyone who can comment may set it, and it changes the thread for everyone.

**Input**

- `groupId`: string, required — The post's `groupId`: the `id` list\_posts and search\_posts return, `topGroupId` in get\_results. Not a `postId`.
- `commentId`: string, required — The thread's top-level comment id, from list\_comments.
- `resolved`: boolean, required — `true` resolves the thread, `false` reopens it.

### `list_conversations`

**List inbox conversations.** Requires `inbox:read`.

Replies and mentions across the workspace's channels, newest activity first. Filter by `status` (open / resolved / snoozed), `platform`, `type` (reply / mention), `accountId`, `assignee` (`unassigned` or a teammate's user id), `inboxId` (a team inbox), or one post (`groupId` or `postId`). Cursor-paged: pass `nextCursor` back as `cursor` for the next page. Snoozes that have come due are woken before the list is read, so `open` is always current.

**Input**

- `platform`: one of `"bluesky"` | `"twitter"` | `"threads"` | `"mastodon"` | `"facebook_page"` | `"instagram"` | `"linkedin_page"`
- `type`: one of `"reply"` | `"mention"`
- `postId`: string — Conversations under one published post row: the `postId` on a list\_posts entry or in get\_results — not the post's `id`/`groupId`. For that, pass `groupId` instead.
- `groupId`: string — Conversations under a post by its `groupId` (the `id` list\_posts and get\_post use), across every channel it went out on. Not alongside `postId`.
- `accountId`: string
- `assignee`: string
- `inboxId`: string
- `status`: one of `"open"` | `"resolved"` | `"snoozed"`
- `cursor`: string — Only to fetch the next page: the `nextCursor` a previous call returned, unchanged. Omit on the first call — never make one up.
- `limit`: integer (min 1, max 100)

### `get_conversation`

**Read a conversation thread.** Requires `inbox:read`.

One conversation with its full message thread — the post it hangs off, the participant, and every message in order. Read this before deciding how to triage; list\_conversations carries only the latest message. `originalPost.groupId` is the post's id for get\_post and the other post tools (`originalPost.id` is the row the inbox's `postId` filter takes; for a post not published through Orbitwrite it's the platform's own id and `groupId` is null).

**Input**

- `conversationId`: string, required — A conversation id from list\_conversations.

### `count_conversations`

**Count the conversations in a view.** Requires `inbox:read`.

How many conversations a view holds — the number on the inbox's badges. Takes the same filters as list\_conversations (`type`, `platform`, `accountId`, `groupId` or `postId` — a post's id or one channel's row, not both — `assignee`, `inboxId`), plus `status` (open by default). `unread: true` narrows to the ones whose latest message nobody in the workspace has read, which is a different question from how much is still open.

**Input**

- `platform`: one of `"bluesky"` | `"twitter"` | `"threads"` | `"mastodon"` | `"facebook_page"` | `"instagram"` | `"linkedin_page"`
- `type`: one of `"reply"` | `"mention"`
- `postId`: string — Conversations under one published post row: the `postId` on a list\_posts entry or in get\_results — not the post's `id`/`groupId`. For that, pass `groupId` instead.
- `groupId`: string — Conversations under a post by its `groupId` (the `id` list\_posts and get\_post use), across every channel it went out on. Not alongside `postId`.
- `accountId`: string
- `assignee`: string
- `inboxId`: string
- `status`: one of `"open"` | `"resolved"` | `"snoozed"`
- `unread`: boolean

### `get_inbox_sync_status`

**Where each channel's inbox polling stands.** Requires `inbox:read`.

Per connected channel: when its inbox was last pulled, the schedule the worker keeps (`mode` active/idle, `pollIntervalSec`, `nextPollAt`) and any steps the platform is rate-limiting right now (`throttles`, each with when it will be asked again). Read this before sync\_inbox: a platform that has rate-limited the application is left alone until its reset even by a forced sync, so a throttled channel will not fill by syncing again — say when it resumes instead.

**Input:** none

### `set_conversation_status`

**Resolve, reopen or snooze a conversation.** Requires `inbox:update`.

Move one conversation: `resolved` marks it handled (and clears any snooze), `open` returns a resolved or snoozed one to the open list, `snoozed` hides it until `until` (required, strictly in the future), when it comes back open and unread. Status is shared workspace state: everyone sees the change.

**Input**

- `conversationId`: string, required — A conversation id from list\_conversations.
- `status`: one of `"open"` | `"resolved"` | `"snoozed"`, required
- `until`: string — When a snoozed conversation comes back. Only with `snoozed`. An ISO 8601 instant, Z or an offset.

### `mark_conversation`

**Mark a conversation read or unread.** Requires `inbox:update`.

`read: true` moves the workspace's read watermark on a conversation to now; `read: false` clears it so the latest incoming message counts as unread again (the way to flag something for a teammate). Read state is shared, not per person.

**Input**

- `conversationId`: string, required — A conversation id from list\_conversations.
- `read`: boolean, required

### `set_all_conversations_status`

**Resolve, reopen or snooze every conversation in a status.** Requires `inbox:update`.

Move every conversation currently in `from` to `to`: 'resolve everything open', 'reopen everything resolved', or snooze them all until `until` (required with `to: "snoozed"`, strictly in the future). Takes the list\_conversations filters and moves exactly what that list would return; with none it sweeps the whole workspace. Returns how many moved. If the sweep is at all selective, read the list first with the same filters.

**Input**

- `platform`: one of `"bluesky"` | `"twitter"` | `"threads"` | `"mastodon"` | `"facebook_page"` | `"instagram"` | `"linkedin_page"`
- `type`: one of `"reply"` | `"mention"`
- `postId`: string — Conversations under one published post row: the `postId` on a list\_posts entry or in get\_results — not the post's `id`/`groupId`. For that, pass `groupId` instead.
- `groupId`: string — Conversations under a post by its `groupId` (the `id` list\_posts and get\_post use), across every channel it went out on. Not alongside `postId`.
- `accountId`: string
- `assignee`: string
- `inboxId`: string
- `from`: one of `"open"` | `"resolved"` | `"snoozed"`, required
- `to`: one of `"open"` | `"resolved"` | `"snoozed"`, required
- `until`: string — When snoozed conversations come back. Only with `to: "snoozed"`. An ISO 8601 instant, Z or an offset.

### `reply_to_conversation`

**Reply in a conversation.** Requires `inbox:reply` or `approval:request`.
Rate limited: 30 calls per 60s, shared with the HTTP route for the same operation.

Answer a reply or mention on the platform it came from. Answers the reply or mention the conversation was opened for, or the message named by `replyToMessageId` (a message id from get\_conversation). `text` may be empty when `media` carries an attachment. This tool doesn't load the thread: get\_conversation returns the messages the reply answers.

What comes back depends on the key. With `inbox:reply` the reply is published and `status` is `sent`, with the updated thread. Without it (but with `approval:request`) nothing is published: the reply is held for someone who can approve it, `status` is `held`, and the approvers are told — list\_held\_replies shows where it got to. Replies are published in public as the channel's account.

**Input**

- `conversationId`: string, required — A conversation id from list\_conversations.
- `text`: string (default "")
- `replyToMessageId`: string | null — Reply to a specific comment (its message id). Omit to reply to the post.
- `media`: array of object — Optional attachments (e.g. a GIF)
  - `url`: string, required
  - `mimeType`: string, required
  - `altText`: string
  - `sourceUrl`: string
  - `videoUrl`: string
  - `width`: number
  - `height`: number
  - `bytes`: number

### `assign_conversation`

**Assign a conversation.** Requires `inbox:update`.

Give a conversation an owner, or hand it back to the shared pool with `assigneeUserId: null`. Owners are teammates' `userId`s from list\_members (list\_team\_availability shows who is away or already busy). Ownership is shared triage state, and changing it is recorded in the audit log.

**Input**

- `conversationId`: string, required — A conversation id from list\_conversations.
- `assigneeUserId`: string | null, required — A member's userId, or null to unassign.

### `sync_inbox`

**Pull new replies from the platforms.** Requires `inbox:update`.
Rate limited: 6 matching calls per 60s, shared with the HTTP route for the same operation.

Fetch replies and mentions the platforms have that the inbox doesn't yet. The inbox already syncs in the background, each channel at its own cadence (see get\_inbox\_sync\_status), so only call this when something is known to be missing. A platform that is rate-limiting us is left alone until its reset even when forced. Skipped (`skipped: true`) when a sync ran in the last two minutes; `force: true` overrides that, but spends the platforms' metered read budget (X reads are billed) — use it sparingly.

**Input**

- `force`: boolean

### `list_held_replies`

**List replies waiting for approval.** Requires `approval:read`.

Replies drafted by someone who can't publish directly, held until an approver releases them — newest first, with who drafted each, its text, and where it stands (`pending`, `approved`, `rejected`, `sent`, `failed`). `decidable: true` is what decide\_held\_reply will accept: a pending reply, a failed send, or an approval whose send never finished. Filter with `statuses` (e.g. `["pending"]` for the queue).

**Input**

- `statuses`: array of one of `"pending"` | `"approved"` | `"rejected"` | `"sent"` | `"failed"`

### `decide_held_reply`

**Approve or reject a held reply.** Requires `approval:decide`.
Rate limited: 30 matching calls per 60s, shared with the HTTP route for the same operation.

Release a held reply to its platform (`approve`), release it with your own wording (`edit_approve`, with the replacement in `body`), or turn it down (`reject`, with the reason in `note` — the drafter is told). Approving publishes: `status: sent` means it is live; `send_failed` means the decision stands but the send failed, and deciding again retries it — unless the error says the send couldn't be confirmed, in which case read the conversation first: it may already be live. You can't approve a reply attributed to this key's own creator unless the key could have replied directly.

**Input**

- `pendingReplyId`: string, required — A held reply's id from list\_held\_replies.
- `action`: one of `"approve"` | `"edit_approve"` | `"reject"`, required
- `body`: string
- `note`: string

### `get_inbox_assignment`

**Read how conversations are assigned.** Requires `teamInbox:read`.

How the workspace hands out every conversation no team inbox claims: `method` (`manual` leaves them unassigned, `round_robin` rotates, `balanced` picks whoever has the fewest open), who is in the rotation (`allMembers`, or `members`), and how many conversations are waiting for an owner — `waitingInManual` says which of those sit in a manual inbox, which distribute\_waiting\_conversations can't assign.

**Input:** none

### `update_inbox_assignment`

**Change how conversations are assigned.** Requires `teamInbox:update`.

Set the workspace's assignment `method`, who's in the rotation (`allMembers: true`, or `members` — replacing the list), whether round robin keeps assigning to people who are away (`rrAssignWhenAway`), and a `wrapUpMinutes` pause after someone resolves a conversation. Fields you omit are left as they are. Applies to conversations as they arrive; call distribute\_waiting\_conversations for the ones already waiting.

**Input**

- `method`: one of `"manual"` | `"round_robin"` | `"balanced"`
- `allMembers`: boolean
- `members`: array of object (max 200 items)
  - `userId`: string, required
  - `tier`: one of `"primary"` | `"secondary"`
  - `capacity`: integer | null
  - `skills`: array of string | null
- `rrAssignWhenAway`: boolean
- `wrapUpMinutes`: integer (min 0, max 240)

### `distribute_waiting_conversations`

**Assign the waiting backlog.** Requires `teamInbox:update`.

Hand out conversations already waiting without an owner, oldest first, through the same routing a new arrival gets (each is re-matched against today's team inbox sources). Routing only decides a conversation as it arrives, so this is how a backlog from before assignment was set up gets owners. Up to 500 a call; `waiting` says how many are still unowned afterwards.

**Input:** none

### `list_team_inboxes`

**List team inboxes.** Requires `teamInbox:read`.

The team inboxes carved out of the workspace inbox, in the order routing checks them: each with its sources (which conversations it claims — by channel, platform, type or keywords), its members, and its assignment method. A new conversation goes to the first inbox with a matching source, otherwise to the workspace's own assignment. Pass an inbox's `id` as `inboxId` to list\_conversations to see what it holds.

**Input:** none

### `create_team_inbox`

**Create a team inbox.** Requires `teamInbox:create`.

Carve a team inbox out of the workspace inbox. `sources` decide what it claims — each needs at least one condition (`accountId` from list\_channels, `platform`, `type`, or any-of `keywords`) and every condition in a source must match. `members` are teammates from list\_members, `primary` or `secondary` (secondaries take overflow when primaries are away or full). It goes last in the routing order; reorder with reorder\_team\_inboxes.

**Input**

- `name`: string (max length 120), required
- `description`: string | null
- `method`: one of `"manual"` | `"round_robin"` | `"balanced"`
- `skillsEnabled`: boolean
- `awayReassign`: boolean
- `rrAssignWhenAway`: boolean
- `wrapUpMinutes`: integer (min 0, max 240)
- `sources`: array of object (max 50 items)
  - `accountId`: string | null
  - `platform`: one of `"bluesky"` | `"twitter"` | `"threads"` | `"mastodon"` | `"facebook_page"` | `"instagram"` | `"linkedin_page"` | null
  - `type`: one of `"reply"` | `"mention"` | null
  - `keywords`: array of string | null
- `members`: array of object (max 200 items)
  - `userId`: string, required
  - `tier`: one of `"primary"` | `"secondary"`
  - `capacity`: integer | null
  - `skills`: array of string | null

### `update_team_inbox`

**Change a team inbox.** Requires `teamInbox:update`.

Edit a team inbox. Fields you omit are left as they are; `sources` and `members`, when given, replace the whole list — read it with list\_team\_inboxes first.

**Input**

- `inboxId`: string, required — A team inbox id from list\_team\_inboxes.
- `name`: string (max length 120)
- `description`: string | null
- `method`: one of `"manual"` | `"round_robin"` | `"balanced"`
- `skillsEnabled`: boolean
- `awayReassign`: boolean
- `rrAssignWhenAway`: boolean
- `wrapUpMinutes`: integer (min 0, max 240)
- `sources`: array of object (max 50 items)
  - `accountId`: string | null
  - `platform`: one of `"bluesky"` | `"twitter"` | `"threads"` | `"mastodon"` | `"facebook_page"` | `"instagram"` | `"linkedin_page"` | null
  - `type`: one of `"reply"` | `"mention"` | null
  - `keywords`: array of string | null
- `members`: array of object (max 200 items)
  - `userId`: string, required
  - `tier`: one of `"primary"` | `"secondary"`
  - `capacity`: integer | null
  - `skills`: array of string | null

### `delete_team_inbox`

**Delete a team inbox.** Requires `teamInbox:delete`.

Remove a team inbox. Its conversations are kept and count as the workspace inbox's from then on; their owners are unchanged.

**Input**

- `inboxId`: string, required — A team inbox id from list\_team\_inboxes.

### `reorder_team_inboxes`

**Set the routing order.** Requires `teamInbox:update`.

The order routing checks team inboxes in — the first one with a matching source wins. `inboxIds` must name every team inbox exactly once.

**Input**

- `inboxIds`: array of string (max 200 items), required

### `list_team_availability`

**Who's available in the inbox.** Requires `inboxStatus:read`.

Every teammate with their inbox status (`active`, `away`, or `away_reassign`, which also hands their open conversations back), whether it was set automatically after inactivity (`autoAway`), and how many open conversations they own (`openCount`). Check this before assigning.

**Input:** none

### `set_teammate_status`

**Set a teammate's inbox status.** Requires `inboxStatus:manageAny`.

Mark a teammate `active`, `away` (no new conversations are routed to them), or `away_reassign` (away, and their open conversations are handed back out now). An API key has no status of its own, so `userId` (from list\_members) is required and this is an override of that person's own setting.

**Input**

- `userId`: string, required — The teammate's userId from list\_members.
- `status`: one of `"active"` | `"away"` | `"away_reassign"`, required

### `get_inbox_settings`

**Read the inbox settings.** Requires `teamInbox:read`.

Workspace-wide inbox defaults: how many open conversations a teammate carries before balanced assignment skips them (`defaultMemberCapacity`), after how many idle minutes someone is marked away automatically, whether that pauses their assignments, and how a teammate opening a conversation someone else is on is handled (`collisionHandling`).

**Input:** none

### `update_inbox_settings`

**Change the inbox settings.** Requires `teamInbox:update`.

Change any of the workspace-wide inbox defaults get\_inbox\_settings reads. Fields you omit are left as they are.

**Input**

- `defaultMemberCapacity`: integer (min 1, max 1000)
- `autoAwayThresholdMinutes`: integer (min 1, max 1440)
- `autoAwayPausesAssignment`: boolean
- `collisionHandling`: one of `"off"` | `"warn"` | `"soft_lock"`

### `list_channel_posting_schedules`

**Posting slots per channel.** Requires `postingSchedule:read`.

Every connected channel with the weekly slots it publishes on, kept separate rather than collapsed — what a queue needs to know which times are *this* channel's. A channel with no slots of its own inherits the workspace's, and is shown with them. Times are (dayOfWeek, minuteOfDay) in the channel's timezone.

**Input:** none

### `set_posting_schedule`

**Replace the posting schedule.** Requires `postingSchedule:update`.

Rewrite the weekly slots — for one channel with `accountId`, or for every channel (and as the default for channels connected later) without it. `mode: "smart"` spreads `frequencyPerWeek` slots across the week; `mode: "manual"` takes an explicit `slots` grid of times × days (Monday first). `timezone` is an IANA zone. Replaces rather than adds: re-running with the same input is a no-op in effect.

**Input**

- `accountId`: string
- `mode`: one of `"smart"` | `"manual"` (default "smart")
- `frequencyPerWeek`: number (max 100, default 1)
- `slots`: array of object (max 50 items)
  - `time`: string, required
  - `days`: array of boolean (max 7 items), required
- `timezone`: string (max length 100)

### `update_tag`

**Rename or recolour a tag.** Requires `tag:update`.

Change a tag's name and colour. Posts follow automatically — they link by id. Renaming onto a name that already exists (case-insensitively) is a conflict.

**Input**

- `tagId`: string, required — A tag id from list\_tags.
- `name`: string (max length 60), required
- `color`: one of `"grey"` | `"dark-grey"` | `"purple"` | `"blue"` | `"teal"` | `"green"` | `"yellow"` | `"orange"` | `"pink"` | `"red"`, required

### `delete_tag`

**Delete a tag.** Requires `tag:delete`.

Remove a tag from the workspace and from every post carrying it. The posts themselves are untouched.

**Input**

- `tagId`: string, required — A tag id from list\_tags.

### `list_hashtag_sets`

**List saved hashtag sets.** Requires `hashtagSet:read`.

The workspace's saved hashtag sets — reusable lists of tags the team adds to posts — sorted by name. Tags come back without the #.

**Input:** none

### `get_text_with_hashtag_set`

**Add a hashtag set to post text.** Requires `hashtagSet:read`.

Return `text` with a saved set's hashtags added, the way the app's Insert does it: tags the text already has are skipped, and with `platform` the platform's ceiling holds (Instagram 5, Threads 1, YouTube 60) — `overLimit` lists what was left out. Saves nothing: pass the returned text to create\_post or save\_draft. Use this rather than appending tags yourself, so the post doesn't carry more hashtags than its platform accepts.

**Input**

- `hashtagSetId`: string, required — A hashtag set id from list\_hashtag\_sets.
- `text`: string (max length 100000), required — The post text to add the set to. Empty is fine — the result is just the tags.
- `platform`: string — The platform the text is for (a channel's `platform`, e.g. instagram). Sets the hashtag ceiling; omit for no ceiling.

### `create_hashtag_set`

**Save a hashtag set.** Requires `hashtagSet:create`.

Save a reusable list of hashtags under a name. Tags may include the # or not; letters, digits and underscores only, at least one letter, at most 30, no repeats — a bad tag is refused with its name, never dropped. Names are unique per workspace, case-insensitively.

**Input**

- `name`: string (max length 60), required
- `hashtags`: array of string (min 1 items, max 30 items), required — Up to 30 tags, with or without the leading #. Letters, digits and underscores only; stored without the #.

### `update_hashtag_set`

**Rename a hashtag set or replace its tags.** Requires `hashtagSet:update`.

Change a set's name, its tags, or both. `hashtags`, when given, replaces the whole list (same rules as create\_hashtag\_set). Posts the set was already added to keep their text.

**Input**

- `hashtagSetId`: string, required — A hashtag set id from list\_hashtag\_sets.
- `name`: string (max length 60)
- `hashtags`: array of string (min 1 items, max 30 items)

### `delete_hashtag_set`

**Delete a hashtag set.** Requires `hashtagSet:delete`.

Remove a saved hashtag set. Posts it was added to keep their text.

**Input**

- `hashtagSetId`: string, required — A hashtag set id from list\_hashtag\_sets.

### `list_media`

**List the media library.** Requires `media:read`.

The workspace's uploaded images, videos and PDFs, newest first, with `total` and a `nextOffset` for paging. Any `pending` record is checked against storage as it's read, so an upload that has landed reports `uploaded` without a separate confirmation. Externally hosted media (a GIF picked from GIPHY) has no record here.

**Input**

- `offset`: integer (min 0, max 9007199254740991, default 0)
- `limit`: integer (min 1, max 100, default 50)

### `get_media`

**Read one media record.** Requires `media:read`.

One library record by id: URL, type, size, dimensions or duration, alt text, and whether the bytes have landed.

**Input**

- `mediaId`: string, required — A media id from upload\_media, upload\_media\_from\_url or list\_media.

### `delete_media`

**Delete a media record and its file.** Requires `media:delete`.

Remove the record and the stored object. Posts already carrying the file keep working — attaching copies the URL into the post — but the URL stops serving once the CDN's cached copy expires, so a scheduled post that still has to fetch it at publish time will fail then. Check list\_posts before deleting something recent. If the stored object can't be removed the record is kept, so the delete can be retried.

**Input**

- `mediaId`: string, required — A media id from upload\_media, upload\_media\_from\_url or list\_media.

### `get_media_usage`

**Media uploads this month.** Requires `media:read`.

How many files and bytes the workspace has uploaded this UTC calendar month. Uploads are metered rather than capped today; this is the number a plan limit would be judged on.

**Input:** none

### `read_link_tracking`

**Read the link tracking settings.** Requires `linkTracking:read`.

How the workspace tags the links in outgoing posts at publish time (Matomo `pk_*` / GA `utm_*` query parameters): `workspace` holds `enabled`, the ordered `params` (values may use `{{platform}}` and `{{campaign}}`, resolved per post) and `domains` (hostnames to tag; empty means every URL in the post). `workspace` is `null` when link tracking has never been configured — that is a normal answer, not an error; update\_link\_tracking creates it. `accounts` lists only the channels carrying an override (set\_channel\_link\_tracking); every other channel inherits the workspace settings.

**Input:** none

### `update_link_tracking`

**Replace the link tracking settings.** Requires `linkTracking:update`.

Replace the workspace's link tracking settings — all three fields, every time: `enabled`, the full `params` list and the full `domains` list (empty `domains` tags every URL). A full save, not a patch, so read\_link\_tracking first and send back everything you don't mean to change. A repeated parameter key is refused rather than de-duplicated. Domains are normalised (a pasted URL collapses to its hostname) and the stored settings are returned. Per-channel overrides are untouched; see set\_channel\_link\_tracking.

**Input**

- `enabled`: boolean, required
- `params`: array of object (max 10 items), required
  - `key`: string (max length 64), required
  - `value`: string (max length 256), required
- `domains`: array of string (max 20 items), required

### `set_channel_link_tracking`

**Set one channel's link tracking override.** Requires `linkTracking:update`.

Set or clear one channel's override of the workspace link tracking settings. `override` has two fields, each three-state: `enabled` (`true`/`false`, or `null` to inherit the workspace value) and `params` (a list that REPLACES the workspace list for this channel, or `null` to inherit it). Pass `override: null` to remove the override entirely, so the channel inherits everything again — an override with both fields `null` means the same and is stored as cleared. Domains can't be overridden per channel. Returns the stored override, or `null` when the channel now inherits. `accountId` is a channel id from list\_channels; a channel outside this workspace is `not_found`.

**Input**

- `accountId`: string, required
- `override`: object | null, required
  - `enabled`: boolean | null, required
  - `params`: array of object | null, required
    - `key`: string (max length 64), required
    - `value`: string (max length 256), required

### `list_webhooks`

**List webhook endpoints.** Requires `webhook:read`.

Every outbound webhook endpoint in the workspace, oldest first: `id`, `label`, `url`, the `events` it fires on, the `tagId` it is narrowed to (null means every post), whether it is `enabled`, and `secretHint` — a masked preview of the signing secret. The secret itself is never part of a read; it is shown once, by create\_webhook or rotate\_webhook\_secret.

**Input:** none

### `create_webhook`

**Register a webhook endpoint.** Requires `webhook:create`.

Register a URL to receive signed POSTs when things happen in the workspace. `url` is http(s). `events` is at least one of `post_published`, `post_failed`, `approval_requested`, `approval_decided`, `account_needs_reconnect` (the vocabulary is `WEBHOOK_EVENTS` in the webhook-endpoints service; `daily_digest` is deliberately not offered). `tagId` (from list\_tags) narrows post events to posts carrying that tag; omit it for every post. `label` is a display name. The result is `{ endpoint, secret }`, and `secret` — the signing secret the receiver verifies deliveries with — is returned ONCE, here: no later read returns it, list\_webhooks shows only a masked hint, and the only way back to a plaintext secret is rotate\_webhook\_secret, which invalidates this one. Relay it to the person or the receiver's configuration before moving on.

**Input**

- `url`: string (max length 2048), required
- `events`: array of one of `"post_published"` | `"post_failed"` | `"approval_requested"` | `"approval_decided"` | `"account_needs_reconnect"` (min 1 items, max 5 items), required
- `label`: string | null
- `tagId`: string | null

### `update_webhook`

**Edit a webhook endpoint.** Requires `webhook:update`.

Change where an endpoint points and what it fires on: `url`, `events` (from `post_published`, `post_failed`, `approval_requested`, `approval_decided`, `account_needs_reconnect`), `label` and `tagId` are all replaced by what you send, so read list\_webhooks first and send the whole endpoint back. The signing secret is untouched — rotate\_webhook\_secret is the only thing that changes it — and so is `enabled` (toggle\_webhook). Returns the updated endpoint, without the secret.

**Input**

- `endpointId`: string, required — A webhook endpoint id from list\_webhooks or create\_webhook.
- `url`: string (max length 2048), required
- `events`: array of one of `"post_published"` | `"post_failed"` | `"approval_requested"` | `"approval_decided"` | `"account_needs_reconnect"` (min 1 items, max 5 items), required
- `label`: string | null
- `tagId`: string | null

### `toggle_webhook`

**Pause or resume a webhook endpoint.** Requires `webhook:update`.

Pause (`enabled: false`) or resume (`enabled: true`) deliveries to an endpoint. A paused endpoint keeps its URL, events and signing secret and simply receives nothing until resumed — prefer this over delete\_webhook when the receiver is temporarily down. Returns the endpoint, without the secret.

**Input**

- `endpointId`: string, required — A webhook endpoint id from list\_webhooks or create\_webhook.
- `enabled`: boolean, required — `true` to deliver, `false` to pause.

### `delete_webhook`

**Delete a webhook endpoint.** Requires `webhook:delete`.

Remove an endpoint. Deliveries stop immediately and the signing secret is gone with it; a receiver that should only pause is a toggle\_webhook instead.

**Input**

- `endpointId`: string, required — A webhook endpoint id from list\_webhooks or create\_webhook.

### `rotate_webhook_secret`

**Rotate a webhook endpoint's signing secret.** Requires `webhook:update`.

Mint a new signing secret for an endpoint. The old secret stops verifying immediately, so the receiver breaks until it is given the new one. The result is `{ endpoint, secret }`, and `secret` is returned ONCE, here — no read returns it afterwards, and the only way to a plaintext secret again is another rotation. Relay it to the person or the receiver's configuration before moving on. Only when the person asks: this is not a way to look up the current secret.

**Input**

- `endpointId`: string, required — A webhook endpoint id from list\_webhooks or create\_webhook.

### `list_auto_dm_channels`

**Which channels can send Auto-DMs.** Requires `autoDm:read` and `account:read`.

The workspace's Bluesky and Mastodon channels with whether each can send direct messages as connected today (`status`: `ready`, or `needs_reconnect` — the person must reconnect that channel in the app once to grant the DM permission), plus each platform's message length and daily send limit. Check this before start\_auto\_dm or before putting `autoEngagement.dm` on a post. Other platforms aren't listed — X's DM access is pending approval, and the rest have no DM API.

**Input:** none

### `list_auto_dms`

**List Auto-DM campaigns.** Requires `autoDm:read`.

Auto-DM campaigns, newest first: each post's rule (message, triggers, `match`, follow requirement, reply keyword, window), its status (`active`, `paused`, `completed`, `stopped`), its window, and counts of `sent`, `failed` and `waiting` recipients (engaged but haven't met the whole rule yet). `lastError` says why the last poll or send didn't go through — a reconnect needed, a rate limit, the daily cap.

**Input**

- `status`: array of one of `"active"` | `"paused"` | `"completed"` | `"stopped"` (min 1 items)
- `accountId`: string
- `groupId`: string
- `campaignId`: string — Just this Auto-DM campaign, by its id from start\_auto\_dm or an earlier page.
- `limit`: integer (min 1, max 100, default 25)
- `cursor`: string — Only to fetch the next page: the `nextCursor` a previous call returned, unchanged. Omit on the first call — never make one up.

### `list_auto_dm_recipients`

**Who an Auto-DM campaign messaged.** Requires `autoDm:read`.

Everyone the campaign has seen engage, newest first: `sent` (with the message exactly as it went out), `failed` (with why — usually the person doesn't accept DMs; a failed DM is never retried), `waiting` (engaged but hasn't met the whole rule yet — replied but not reposted under `match: all`, or not following under `requireFollow`).

**Input**

- `campaignId`: string, required — An Auto-DM campaign id from list\_auto\_dms or start\_auto\_dm.
- `status`: array of one of `"waiting"` | `"sending"` | `"sent"` | `"failed"` (min 1 items)
- `limit`: integer (min 1, max 200, default 50)
- `cursor`: string — Only to fetch the next page: the `nextCursor` a previous call returned, unchanged. Omit on the first call — never make one up.

### `start_auto_dm`

**Start an Auto-DM on a published post.** Requires `autoDm:create` and `post:publish`.

For `durationDays` (1–7, default 4) from now, direct-message everyone who reposts and/or replies to a post that has ALREADY been published — people who engaged before it started count too. For a post that hasn't gone out yet, don't use this: put the same rule on the post as `autoEngagement.dm` (with `enabled: true`) in create\_post or save\_draft, and it starts when the post publishes.

`triggers` is what earns the message (`repost`, `reply`, or both) and `match` is whether `all` or `any` of them must happen; `replyKeyword` narrows replies to ones containing that text; `requireFollow` only messages followers. `{first_name}` (first name), `{name}` (full name), `{username}` (username), `{action}` (action), `{post_url}` (post url) in `message` are filled in per person. These messages land in strangers' inboxes in the workspace's name, so confirm the wording and the audience with the person before starting. Pass `accountId` when the post went out on several channels. Needs publish authority as well as Auto-DM permission.

**Input**

- `message`: string (max length 10000, default "")
- `triggers`: array of one of `"repost"` | `"reply"` (max 2 items, default \["repost","reply"])
- `match`: one of `"all"` | `"any"` (default "all")
- `requireFollow`: boolean (default false)
- `replyKeyword`: string (max length 100)
- `durationDays`: integer (min 1, max 7, default 4)
- `groupId`: string, required
- `accountId`: string

### `update_auto_dm`

**Pause, resume or change an Auto-DM.** Requires `autoDm:update`.

`status: "paused"` stops sending until `status: "active"` resumes it. Any rule field you pass replaces the stored one and applies to the next send; nobody already messaged is messaged again. `replyKeyword: null` clears the keyword. `durationDays` is measured from the campaign's start. Finished or stopped campaigns can't be changed.

**Input**

- `campaignId`: string, required — An Auto-DM campaign id from list\_auto\_dms or start\_auto\_dm.
- `status`: one of `"active"` | `"paused"`
- `message`: string (max length 10000)
- `triggers`: array of one of `"repost"` | `"reply"` (min 1 items, max 2 items)
- `match`: one of `"all"` | `"any"`
- `requireFollow`: boolean
- `replyKeyword`: string | null
- `durationDays`: integer (min 1, max 7)

### `stop_auto_dm`

**Stop an Auto-DM for good.** Requires `autoDm:delete`.

Nothing more is sent from this campaign; it and its recipients stay readable. Can't be undone — to hold it temporarily, use update\_auto\_dm with `status: "paused"` instead.

**Input**

- `campaignId`: string, required — An Auto-DM campaign id from list\_auto\_dms or start\_auto\_dm.
