Postonus
MCP

Tools

The eight MCP tools — arguments, what comes back, and what each one refuses.

Last updated on

Every tool is scoped to your workspace by the connection itself — none of them takes an organization or workspace argument. A failed call comes back as a normal tool result with an error message, not a broken connection.

Every tool also declares an output schema and returns structured content, so an assistant reads typed fields rather than parsing text.

Resources

Three read-only resources sit alongside the tools. An assistant fetches them when it needs them, so they cost nothing until then.

URIWhat it holds
postonus://workspace/brand-voiceHow your workspace wants to sound. Worth reading before drafting anything.
postonus://platforms/rulesCharacter limits, media counts and types, thread and first-comment support, per platform.
postonus://workspace/quotaYour plan and how much of this month's per-platform allowance is left.

list_channels

No arguments.

Returns your connected channels — each with its health and the limits the platform enforces — plus the active workspace:

{
  "workspace": { "id": "org_...", "name": "Acme", "timezone": "Europe/Berlin", "otherWorkspaces": [] },
  "channels": [
    {
      "id": "int_...", "platform": "BLUESKY", "name": "acme.bsky.social",
      "connectStatus": "ACTIVE", "tokenStatus": "VALID", "healthy": true,
      "maxChars": 300, "maxCharsWithMedia": null, "countUnit": "grapheme",
      "supportsThreads": true, "supportsFirstComment": false, "maxFirstCommentChars": null,
      "maxMediaCount": 4, "supportedMediaTypes": ["IMAGE", "VIDEO", "GIF"]
    }
  ]
}

The limits are there so an assistant can size a post before calling schedule_post instead of discovering each one from a rejection. countUnit: "grapheme" means the platform counts a family emoji as one character where String.length would charge eleven.

otherWorkspaces lists workspaces you belong to that this connection cannot reach. A connection made over OAuth resolves your default workspace only; use an API key bound to a specific workspace to reach another one.

get_best_times

ArgumentTypeRequired
platformsarray of platform namesyes, at least one
countnumber, 1–20no, defaults to 5

Returns upcoming send times as concrete instants, soonest first — pass one straight to schedule_post:

{
  "timezone": "Europe/Berlin",
  "slots": [
    { "scheduledAt": "2026-09-01T06:00:00.000Z", "platforms": ["LINKEDIN", "LINKEDIN_PAGE"], "strength": 10, "source": "consensus" }
  ],
  "platformsWithoutData": ["BLUESKY"]
}

source says where a slot came from: consensus when several of the platforms you asked about share a window, platform when they do not intersect and each platform's own windows are used, default when we hold no research for any of them. platformsWithoutData names the platforms in the last case, so a generic answer is never mistaken for a researched one.

This is the same resolution useOptimalTime performs, so the two always agree.

list_queue

ArgumentTypeRequired
statusesarray of DRAFT, SCHEDULED, PUBLISHING, PUBLISHED, FAILEDno, defaults to ["DRAFT", "SCHEDULED", "FAILED"]
limitnumber, 1–100no, defaults to 20

Returns posts with their per-platform schedules. The default covers everything you can still act on; widen statuses to read published history.

This is where postId and scheduleId come from, so anything you intend to edit, delete, or retry has to be listed here first.

schedule_post

ArgumentTypeRequired
contentstringone of this or thread
threadarray of 2–25 stringsone of this or content
platformsarray of platform namesone of this or channelIds
channelIdsarray of channel idsone of this or platforms
scheduledAtISO 8601 datetimeone of this, useOptimalTime, or asDraft
useOptimalTimebooleanone of this, scheduledAt, or asDraft
asDraftbooleanone of this, scheduledAt, or useOptimalTime
mediaarray of references from import_mediano
perChannelContentmap of channel id to textno
firstCommentmap of channel id to textno

Stages a post. It never publishes immediately — there is no "publish now" option here, unlike the raw API's PUBLISH_NOW.

Returns the post's resulting state: its text, media, and every platform's schedule id, time, and status, plus a dashboard link. You never have to read the queue back to find out what your own call did.

Targeting a specific channel

platforms posts to every connected channel on those platforms. channelIds names exact channels, which is the only way to pick between two pages on the same platform.

Per-channel copy

perChannelContent maps a channel id to text that replaces the shared body on that channel. This is how one idea ships as 280 characters on X and 3000 on LinkedIn in a single post, rather than being written down to the smallest limit.

Media

Import each file with import_media first, then pass what it returned in media, adding altText.

Threads

Pass thread instead of content and you get a real reply chain, not a run of separate posts: the segments publish in order, each one replying to the last. You choose where the breaks land, so numbering and punchlines stay where you put them.

{ "thread": ["1/3 the setup", "2/3 the middle", "3/3 the payoff"], "platforms": ["BLUESKY"], "scheduledAt": "2026-09-01T09:00:00Z" }

Each segment is checked against the platform's limit on its own, and each one spends a post of your monthly allowance — a 5-segment thread costs 5. The post's own text is the segments joined together, which is what you see in list_queue and on the calendar.

Refuses when:

  • No body, no target, or no timing is given. All the missing pieces are named in one message rather than one per call.
  • Both content and thread, both platforms and channelIds, or asDraft together with a send time.
  • None of the requested platforms has a connected, active channel.
  • content or any thread segment is longer than the platform allows — it is rejected, not truncated. Check maxChars from list_channels first.
  • A target platform does not publish threads. Facebook takes a long post directly, so there is nothing to split and the call is refused rather than flattened. supportsThreads on list_channels says which platforms can.
  • The thread is over 25 segments.
  • Your workspace has hit its monthly post quota for a platform, or cannot schedule at all (a Free workspace with no card) — see Limits.

edit_post

ArgumentTypeRequired
postIdstringyes
contentstringno, one of this or thread
threadarray of 2–25 stringsno, one of this or content
platformsarray of platform namesno, one of this or channelIds
channelIdsarray of channel idsno, one of this or platforms
scheduledAtISO 8601 datetimeno, one of this or scheduleTimes
scheduleTimesarray of { scheduleId, scheduledAt }no, one of this or scheduledAt
mediaarray of references from import_mediano
perChannelContentmap of channel id to textno
firstCommentmap of channel id to textno

Rewrites a staged post in place, so fixing a typo does not leave a second post on the calendar. Pass only what changes — anything you omit stays as it is, including the attached media and the time. Returns the post's resulting state.

Moving a post

scheduledAt moves every platform's copy. scheduleTimes moves named copies individually, which is what makes one post go out at different times on different platforms — deliberate when you want it, surprising when you do not, so the two are separate arguments and you cannot pass both.

This replaces the old reschedule_post tool, which moved a single platform's schedule while looking like it moved the post.

platforms and channelIds replace the target channels rather than adding to them: a post staged to Bluesky and LinkedIn, edited with platforms: ["BLUESKY"], loses its LinkedIn schedule. media likewise replaces the whole media set. An edit never changes a post's status — a draft stays a draft, a scheduled post stays scheduled.

Refuses when:

  • Nothing is passed to change.
  • Both content and thread, both platforms and channelIds, or both scheduledAt and scheduleTimes.
  • Any schedule on the post has already published.
  • The post is publishing right now. Wait for it to finish.

retry_schedule

ArgumentTypeRequired
scheduleIdstringyes

Re-queues one failed platform copy of a post. Find the id with list_queue and statuses: ["FAILED"]. Returns the post's resulting state.

Only a failed schedule can be retried. If the failure was the content or a disconnected channel, fix that first — a retry on its own will fail the same way.

import_media

ArgumentTypeRequired
urlpublic URL of an image or videoyes

Copies a file into your workspace so it can be attached to a post:

{ "mediaId": "med_...", "url": "https://cdn.postonus.com/...", "type": "IMAGE", "bytes": 184320 }

Pass that object back in schedule_post or edit_post under media, adding altText.

Refuses a private or local address, anything that is not an image or video, and files over 200MB.

create_media_upload

ArgumentTypeRequired
fileNamename to store the file underyes
fileTypeMIME type, e.g. image/pngyes
fileSizesize in bytesyes
checksumbase64 SHA-256 of the bytesyes

For a file you hold the bytes of, when there is no public URL to give import_media. Reserves storage against your plan quota and returns a presigned upload:

{
  "uploadId": "upl_...",
  "uploadUrl": "https://...",
  "requiredHeaders": { "content-type": "image/png", "x-amz-checksum-sha256": "..." },
  "expiresAt": "2026-08-25T10:00:00.000Z"
}

PUT the bytes to uploadUrl with exactly those headers, then call complete_media_upload. Storage verifies the checksum, so a body that does not match it is rejected.

Single-part only, which caps a file at 100MB. Use postonus media upload from the CLI for anything larger — it splits the file into parts.

complete_media_upload

ArgumentTypeRequired
uploadIdfrom create_media_uploadyes
checksumthe same digest you sentyes

Turns a finished upload into a media row, and returns the same shape import_media does:

{ "mediaId": "med_...", "url": "https://cdn.postonus.com/...", "type": "IMAGE", "bytes": 184320 }

The media row only exists after this call. Calling it twice with the same checksum is safe.

delete_post

ArgumentTypeRequired
postIdstringyes

Deletes a post and every schedule and thread segment on it. This cannot be undone. The tool is marked destructive, so an assistant that respects tool annotations asks you before calling it.

Returns { "deleted": true, "postId": "pst_..." }.

Refuses when:

  • Any schedule on the post has already published. A published post is real on a real platform, and deleting the local row would leave it with no record here.
  • Someone else in the workspace created the post. Delete those from the dashboard.

A caller using an x-api-key gets no prompt, because there is nobody to prompt. Treat a key that can reach delete_post as a key that can delete any unpublished post its user created.

On this page