MCP connector
xpost speaks Model Context Protocol natively, on every plan. One hosted URL gives any MCP client 19 tools — posting, media, guardrail dry-runs, delivery receipts, and the engagement insights that let an agent learn what works. Everything goes through the same API as the dashboard, so guardrails, copilot approval, key scopes, and rate limits all apply. An agent cannot bypass the approval queue through MCP, by design.
https://xpost.to/api/mcp · Streamable HTTP · Bearer auth
Create the key under Dashboard → AI agent (agent key). That tab generates all of the snippets below with your key filled in.
Claude Code
claude mcp add --transport http xpost https://xpost.to/api/mcp \ --header "Authorization: Bearer xp_live_YOUR_KEY"
Claude Desktop & claude.ai
Settings → Connectors → Add custom connector and paste this URL. The client finds our sign-in on its own: you sign in, pick the project, press Allow. No key to copy.
https://xpost.to/api/mcp
The connection shows up under Project → AI agent as “connected by signing in”, with a Disconnect button. A connector UI that can't open a browser can still use the keyed form, https://xpost.to/api/mcp/xp_live_YOUR_KEY— treat that URL as a secret.
ChatGPT
Nothing to paste. xpost is in ChatGPT's plugin directory — add it there and sign in. Connect xpost to ChatGPT has the two steps.
Cursor
Add to .cursor/mcp.json (project) or ~/.cursor/mcp.json (global):
{
"mcpServers": {
"xpost": {
"url": "https://xpost.to/api/mcp",
"headers": { "Authorization": "Bearer xp_live_YOUR_KEY" }
}
}
}Any other MCP client
The connector is standard Streamable HTTP at https://xpost.to/api/mcp with Authorization: Bearer <key>. Clients that can't send headers can use the /api/mcp/<key> form.
Local stdio server (self-hosting)
Running xpost on localhost, or want the agent to upload files straight from the machine it runs on? Use the stdio server that ships in the repo (mcp/server.mjs):
claude mcp add xpost \ -e XPOST_API_KEY=xp_live_YOUR_KEY \ -e XPOST_URL=http://localhost:3001 \ -- node /path/to/xpost/mcp/server.mjs
Same tools as the hosted connector, plus upload_media can read local file paths (the hosted connector accepts URLs and base64 only).
The 19 tools
The rules this project posts by — read it before writing anything. Returns: timezone (the clock a bare date-time like "tomorrow at 9" is on — convert with it, do not assume UTC), approval (in copilot mode your posts wait for a human, which is the product working rather than a failure), signature (a footer added to the end of everything you write, before guardrails and before saving — do not write your own copy of it, and do not offer to remove it), guardrails (the banned words, topics, link rules and caps a post is checked against — write inside them rather than around them), and posts_today (what is left of this key's daily allowance).
List the connected social accounts (ref, id, platform, username, status). USE ref WHEN POSTING — it is the readable name for the account ("x:someone") and it is what a person sees on the card they have to approve before anything is created; the uuid tells them nothing. ref is always sendable as it comes back, including the rare account whose ref is its uuid because two accounts share one username.
Each account also carries limits and can — what THIS connection will publish. limits: caption_chars (the ceiling), caption_chars_available (the same ceiling minus the project's signature, which is the room your words actually have — write to this one), max_media (attachments that survive; extras are dropped or the post is refused), media_required (true where a text-only post is refused outright) and video_seconds. can: placements (surfaces besides the feed — reels, stories), first_comment, people_tags, reel_music, documents (PDF), and mixed_media (false where photos and video cannot both go out in one post).
Read them BEFORE writing, and write to the tightest one in the send — the caption is shared across every account unless you give a platform its own in platform_configurations. Several depend on how the account was connected rather than on the platform, so check the account you are actually posting to; they are what the wire does, not what the platform advertises.
AN EMPTY LIST IS AN ANSWER, and it comes with connect_message and connect_url. Say what the message says and hand over the link: nothing can be posted anywhere until they connect an account, and the person cannot see our dashboard from here, so you are the only thing that can tell them.
The full rulebook for the accounts you are about to post to, as data — call it BEFORE building platform_configurations instead of guessing keys from prose. Per account: the same limits/can brief as list_accounts, plus options — every platform_configurations key THIS account accepts, each with kind (text | textarea | list | toggle | select), accepted values, default, max_length/max_items, placement gates (only_placement/except_placement) and a note where the key alone would mislead.
A key that is ABSENT is absent because this account's connection cannot publish it — sending it anyway is refused at create time or loses the delivery, so never copy an option from one account to another. Pinterest accounts also answer boards: the live boards board_ids accepts, each { name, eligible, reason? } (null means the lookup is unavailable right now — boards can be omitted and the pin lands on Quick Saves). Pin only to boards marked eligible. An ineligible board is LISTED rather than hidden, because it is a real board someone can see on pinterest.com — but it cannot take a pin (a board made on Pinterest's sandbox refuses every real one), and sending it loses that delivery. Its reason is the sentence to repeat if you have to explain the choice.
Get an upload slot, for when a file has to reach xpost and you cannot send it — the usual case being a picture or video the user attached to this conversation. Call this whenever the user attaches media and asks for it to be posted. Do not ask them to upload it somewhere public first, and do not try to retype the bytes as base64 (you cannot — you were shown the picture, not its bytes). Do not weigh up whether the file would fit inline, or measure it first: call this straight away, it is the cheapest step either way. The result IS a drop zone, drawn in this conversation, and it already says what to do. SAY NOTHING ABOUT THE UPLOAD: not that you cannot reach the file, not what a sandbox or an upload host is, not "drop it here" — the card says that, and saying it again in your own words is both a second copy and our plumbing showing. Spend your words on what is genuinely still open (a missing caption, which account) and then wait: the media_id arrives in the conversation by itself when the file lands. A slot lasts a few minutes and covers the files added while it is open. Get a fresh one if a step fails.
Attach an image, video or PDF for a post. Ways to supply it: a public https url, a ticket from get_upload_ticket (the right one for a file the USER ATTACHED to this conversation — see that tool), or base64 data with mime_type for something small you generated yourself. Local file paths are not available over the hosted connector. Returns a media_id for create_post. Required for Instagram, TikTok, YouTube, Pinterest (no text-only posts). A PDF becomes a LinkedIn document post — one PDF, on its own, LinkedIn only, and the account must be one that supports documents (create_post says so if not). Accepted formats, size caps and the ticket flow: https://xpost.to/docs/api#media.
Create a social media post targeting one or more connected accounts. In copilot mode the post is held for human approval before publishing — the response says so; do not treat 'pending_approval' as an error. It comes back with approval_link — a ready-made Markdown link to a page showing this exact post with Approve and Reject buttons, no login needed. Print that string EXACTLY as it is, on its own line at the end of your reply: it is the last step of the job, not an extra. It is already written — do not unwrap it, do not print the address inside it, and do not describe it. Omit scheduled_at to post as soon as it can go — the reply then says scheduled_at: null, which means the post has NO time, not that the time is unknown. publishes says what it is waiting for: "on_approval" (a person's yes — tell the user "as soon as you approve it"), or "now" (nothing at all, it is already on its way). A post with a real slot answers "at_time" and the slot is in scheduled_at. Never invent a time to report. Instagram/TikTok/YouTube/Pinterest require media_ids (see upload_media). is_draft: true files it as a DRAFT instead — a scratchpad post that publishes nowhere and skips the approval queue, for when the user asked you to prepare options rather than to post. It has no approval link; the user sends it from its page in xpost when ready. update_post can keep editing it (it stays a draft); delete_post clears the versions they didn't pick. A project with no plan can still send ONE post, free, and the reply says free_first_post: true when this was it. Say that this one was free and that posting beyond it needs a plan — they are otherwise never told, and the offer was made so they could see it work first. plans_url is a page explaining the plans; offer it only if they ask what a plan costs. Never pitch an upgrade unprompted.
List recent posts with their status (pending_approval, scheduled, processing, posted, partial, failed, rejected). A post still waiting carries approval_link, a ready-made Markdown link to its one-click approve/reject page — print that string verbatim whenever you mention the post, never unwrapped into a bare address.
CALL THIS BEFORE ASSUMING A POST IS STILL WAITING. A person can reject one at any moment through a link you never see, and this is how you find out: status: "rejected" with rejectionReason — what they actually objected to, in their words. Read that reason before rewriting anything, rather than guessing at what they disliked; then fix it with update_post on the SAME post. rejectionReason is often EMPTY, and that is normal rather than a fault: a one-tap rejection from an approval link or a chat card has nowhere to type one. When it is empty, ASK the person what was wrong before rewriting — a guess dressed as a fix wastes their second look.
publishes says what has to happen before each post goes out: at_time (scheduledAt is a real slot somebody chose), on_approval (a person's yes, scheduledAt null), or now (nothing — it is on its way). It is NULL on a draft or a rejected post: those are going nowhere until a person decides something, so there is no answer to report — say what status says instead, and never tell somebody a draft is about to publish.
ASK FOR WHAT YOU WANT. status narrows the list — "pending_approval" answers "what is waiting on me?" on its own, and several may be named at once (["pending_approval", "scheduled"]). Without it you get every status mixed together, newest first, and reading captions to find the ones you mean is how the wrong post gets approved or deleted. The reply echoes filter: what actually applied.
Take a post you created off the board. Use it to clear up after yourself — a draft the user turned down, an extra version they didn't pick, a post they asked you to cancel — so nothing you wrote is left sitting in their approval queue for them to reject by hand. Works on your own posts only. A post waiting for approval, a draft, a rejected or failed one is deleted outright, permanently, along with its delivery records. A post that already has a time is taken OFF the schedule instead and handed back to the user, so it publishes nowhere; the reply says which happened, and you should repeat that to the user rather than reporting it as deleted. Anything already published, and anything a person approved that is no longer on the schedule, is theirs and is refused.
Change a post that has not gone out — its caption, its pictures, its time, its per-platform options. Use this for "make it shorter", "swap the photo", "move it to Friday": deleting and writing a new post loses its place in the queue and its approval link. Anything you leave out is kept as it was.
Works on any post in the project that has not gone out — the person's own posts as well as yours — while it is a draft, pending approval, scheduled or rejected. A post that came back REJECTED is fixed the same way: read its rejectionReason in list_posts first, change what that reason actually named, and send it again through this tool — rather than guessing at the objection, or writing a second post and leaving the turned-down one behind. Editing a post a human already approved (or rejected) puts it back in the queue and the reply carries a fresh approval_link, so print that one and stop offering the old. Say plainly which of the two happened: the reply's message is written for the rejected case, and a person whose "no" has just been reopened deserves to be told so rather than reading "edited".
The reply's timing fields work like create_post's: a null scheduled_at means the post has no time, and publishes says whether it waits on a person ("on_approval") or on nothing ("now"). Leaving scheduled_at out of your call changes nothing about the timing — and a slot that has already PASSED is dropped rather than carried forward, because a dead time is not a plan.
A post that has already been TRIED is not editable, and rewriting it would send a second copy to the accounts that worked: use retry_delivery on the delivery that failed.
Send one FAILED delivery again — the row from get_delivery_receipt, by its id. This is the right answer to "it failed on Instagram": writing the post again would send a second copy to the accounts that worked. A delivery that succeeded is refused; there is nothing to repeat.
A refusal answers in error_code, not in prose: already_succeeded (it landed — post_url comes back with it, report that and stop; with taken_down: true and no url it went out and was removed, so say that rather than offering a link), in_flight (still going, ask again later), already_claimed (something else is retrying it) or not_found. retriable is false when asking again can never change the answer — read those two fields rather than the sentence, and never loop on a refusal that says false.
Delete a delivery from the platform it was published to — the actual post on X, Instagram, LinkedIn. It is gone for the people who saw it; there is no undo, and reposting is a new post with a new URL.
ONLY when the user has asked for this post to be taken down, in words. Never as tidying up, never because something looks wrong to you, never as part of a larger task they asked for. If you think a live post should come down, say so and let them decide. The receipt stays either way, and the take-down is recorded against your key.
A refusal answers in error_code: never_went_out, account_gone or unsupported all mean the person has to delete it on the platform themselves — say that plainly. Only platform_refused is worth another go, and retriable says so.
What is broken right now: accounts whose token has expired or whose platform cannot be reached. A post to one of these will fail, so read it when a delivery failed for no obvious reason, or before a big send. Each issue names the account and what happened; the fix is always the user reconnecting that account in xpost — you cannot do it for them, so say which account and why.
Per-platform delivery receipt for a post: status, live post URL on success, error detail on failure. A successful row may also carry note — the post went out, but published less than was attached (X takes 4 images, Facebook won't let an app post photos and video together, a reel is one clip). Report it: a bare 'success' would overstate what was published. Check this after creating a post.
THIS IS ALSO HOW YOU SHOW A POST AGAIN. Asked to see a post, its preview, or what it looks like — call this with the post id. In a host that draws MCP Apps the result IS the post: caption, pictures, every destination as that platform will render it, and Approve and Reject while it is still waiting. Elsewhere approval_link is a ready-made Markdown link to the same thing; print it verbatim.
Dry-run a caption against this workspace's brand guardrails (banned words/topics, link policy, posting cap) WITHOUT creating a post. Use this to fix a draft before create_post instead of burning a rejection. Returns { allowed, violations[], rules_checked[], rules_skipped[] }.
READ rules_checked BEFORE REPORTING A PASS. An empty rules_checked means this project has NO guardrails configured, so allowed: true says nothing objected — not that the caption was checked and cleared. Saying "it passed the brand rules" about a project with an empty list is a claim nothing verified. rules_skipped lists rules that were asked and could not answer (the topic and tone classifiers fail OPEN by design); a clean verdict listing any of those is an incomplete check, not a pass.
Create up to 100 posts in one call. Each row is independent and runs the full single-post path — guardrails, plan limits, and copilot approval routing all apply, so in copilot mode the batch lands in the human approval queue. accounts entries may be account ids, platform:username, or a bare username when unambiguous. Returns a per-row report; check it — some rows can fail while others succeed. A held row carries its own approval_link, already written as a Markdown link; print each one verbatim so every post can be approved with one click. Give every row an idempotency_key of its own: a call that times out may still have written some rows, and a resend with the same keys returns those as repeated: true instead of posting them twice.
Per-platform engagement for ONE published post: views, likes, comments, shares, reach, saves, engagement, plus the live URL. A null metric means the platform doesn't report it (not zero); empty data means metrics haven't synced yet (they refresh roughly every 6h).
Workspace engagement roll-up over a window: post/delivery counts, metric totals, and a per-platform breakdown. Use this for 'how are we doing' questions and to compare platforms.
Every answer carries coverage, and it is what tells an empty window apart from an unmeasurable one. deliveries is what actually went out, and the three counts under it add up to exactly that — with_metrics (what the totals are built from) + unreportable (no numbers, and none coming: deleted, expired, or never reported on) + awaiting (not in yet). unsupported is a SUBSET of unreportable, not a fourth bucket: deliveries nobody will ever report on — STORIES, which Instagram and Facebook report nothing about, ever. So format: "story" comes back with zero totals however many stories went out, and reporting that as "no stories were posted" is false: say the stories went out and their numbers are not something the platform gives anyone. ever_delivered ignores the window and separates "nothing in this window" from "nothing ever".
Best-performing deliveries ranked by a metric — study these before drafting new content to learn what works for THIS audience. Returns caption, platform, username, metrics, and live URL per entry.
The workspace's best posting slots (weekday × hour, UTC) ranked by average engagement from its OWN past results. Use when choosing scheduled_at.
READ signal BEFORE THE ORDER. A ranking always comes back, and it is only advice when there is something to rank on. "none" means no engagement has been recorded at all — the order is arbitrary, so say there is no history yet and pick a time on other grounds rather than presenting a slot as best. "thin" means some slot rests on one or two posts: name the sample count when you quote it. "ok" means every slot has three or more.
What your agent should expect
- · pending_approval is not an error. In copilot mode every agent post waits for a human. The right behavior is to tell the user and stop — not retry.
- · 422 = a guardrail spoke. The violations name the rule. Rewrite to comply and try once; never attempt to evade a guardrail.
- · Check receipts. Delivery is per platform — report partial failures honestly.
- · Learn before drafting.
get_top_postsandget_best_timesread this project's own results — use them to pick angles and times.