You schedule social posts with AI by describing the week to an assistant that holds your calendar over MCP, reviewing the drafts it produces, and approving the ones worth publishing. The scheduling is the easy part. The inputs are not, and that is where every content calendar quietly fails.
This post is the loop we actually run, written down: which tools get called on a Monday, what the planning prompt says, what to check before a draft becomes a scheduled post, the time zone bug that puts your best post live at four in the morning, and the monthly review that decides what survives. It assumes you have already connected an assistant to CRM Solid over the Model Context Protocol. If you have not, our companion piece on the MCP server for social media covers the install, the key and the client config, and the API and MCP integration guide is the shorter version.
Why your content calendar dies in week three
The pattern is consistent enough to be a law. Week one, you fill the calendar and it feels great. Week two, you fill it again, slower. Week three, you open the empty grid on Monday and nothing comes. By week five the calendar is a document nobody opens, and the story you tell yourself is that the team lacked discipline.
That story sends you looking for the wrong fix: a better calendar, a reminder, a blocked Friday afternoon. None of it works, because what ran out was not time and it was not discipline.
What ran out was supply.
Weeks one and two were not you generating content at a sustainable rate. They were you draining a backlog: the opinion you have repeated in four sales calls, the thing that annoyed you about a competitor's onboarding, the war story from the migration last quarter. That backlog took six months to accumulate and nine days to spend. Week three is the first week you produce with no reserve, and nothing comes because nothing has come in. The same mechanism explains why "batch a month in one afternoon" works exactly once.
The template makes it worse
Most calendar templates hand you a shape before you have a supply: three posts a week, one educational, one promotional, one piece of social proof. That converts a supply problem into a compliance problem. You stop asking "what is worth saying" and start asking "what goes in the Tuesday tips slot." The post written to fill a labelled slot gets no replies, and the flat result feeds the conclusion that posting does not work for your business. It works. You published filler on a schedule.
So here is the honest diagnosis, and it is the reason an assistant helps at all: the calendar was never the bottleneck. The bottleneck is the pipeline of things worth saying. Leave that broken and an AI writing tool will produce filler faster, which is worse than an empty calendar because it is harder to notice.
What the assistant already knows that a blank calendar does not
The useful property of an assistant with tool access is not that it can write. Writing is the commodity part. It is that it can read three sources of raw material you already own and never open on a Monday morning.
What shipped. Every fix and feature your team made last week is a candidate post, and about one in four is a good one. The CRM does not hold this, so you supply it: a changelog, or a list of merged pull request titles.
What customers asked. Your DM inbox is a live corpus of the exact phrasing real buyers use, which is not the phrasing your marketing site uses and not the phrasing a keyword tool suggests. Highest value input of the three, and almost nobody mines it, because reading a month of DMs by hand is miserable.
What already worked. Thirty days of your own post performance, looked at maybe twice, and never beside the plan you were about to write.
| Input | Tool | The question it answers | What you do with the answer |
|---|---|---|---|
| Publishing outcomes | crm_social_post_stats | How much actually went out in the last 30 days, and how much failed | Find the weeks you thought you posted and did not, and the failures nobody saw |
| Outbound send health | crm_messaging_stats | Whether your outgoing messages are landing at all | Catch an expired connection before it costs you a week of replies |
| Live inbox shape | crm_social_inbox_summary | Where your audience actually talks to you, per platform, right now | Catch the mismatch between where you post and where you get replies |
| Shipped work | You paste it | What changed in the product this week | Turn the release into three specific posts instead of one vague one |
Be exact about what those first two tools return, because their names promise more than they deliver and a plan built on a misreading is worse than a plan built on nothing. crm_social_post_stats counts publishing outcomes over a window: total, published, pending, processing, failed, cancelled, and the same split per platform. There are no impressions in it, no engagement, no reach. It answers "what went out and what failed", never "how did it perform", and performance at the network level still comes from each platform's own analytics. crm_messaging_stats is narrower still: it takes a windowDays argument restricted to 1, 7 or 30, and returns outbound send volume with a success rate, drawn from the send queue. Queued, sent, failed, total. It is delivery health, not audience behaviour, and it does not break down by platform.
That sounds like a demotion and it is not. A failure count is the single most actionable number in a Monday session, because it is the one nobody is watching: three failed posts in a week means a token expired or a media URL went unreachable, and you have been publishing to an audience that did not receive the last three things you wrote. No engagement dashboard tells you that, because from its point of view those posts never existed.
The third row catches a mismatch almost every team has, and it is the one that carries the audience-side signal. If crm_social_inbox_summary comes back with eighteen active conversations on Instagram and seven on X, and your calendar has four X posts and one Instagram post, your weighting is inverted relative to where your audience is willing to talk. That is a fifteen second check no calendar template will prompt you to make, and it works because the unified inbox and the post scheduler read from the same account connections.
The Monday planning session, step by step
Twenty minutes, once a week, in the same order every time. The order is what stops you writing the plan you already had in your head before you opened the client.
- Pull the numbers before you say what you want to post. This sounds fussy and it is the whole trick. If you open with "I want to post about the new import feature," every number pulled afterwards becomes supporting evidence for a decision you already made.
- Paste what shipped. One line per item, plain language, no marketing framing. "Fixed WhatsApp attachments rejecting image/png" beats "improved attachment reliability," because the specific version contains a story and the vague one does not.
- Run the planning prompt. Either your own, or the packaged
weekly-content-planprompt, which the server exposes alongsidesocial-inbox-triageanddm-reply-draft. - Read the plan and argue with it. It is a proposal from a system that has read your numbers and has no taste.
- Kill at least two items. Not as a ritual. Because a plan you did not cut is a plan you did not read.
- Stop before any post object exists. Nothing gets an ID until you have agreed the list.
The prompt we use looks like this. Adapt the platforms and the rules, keep the last two lines.
Plan next week's social posts.
Before you propose anything, pull these and print what you found:
1. crm_social_post_stats with days=30
2. crm_messaging_stats with windowDays=30
3. crm_social_inbox_summary
Print the failed and cancelled counts from step 1 even if they are
zero. Do not describe step 1 or 2 as engagement or reach: they are
publishing outcomes and send health, nothing more.
Here is what we shipped since last Monday:
- CSV contact import with column mapping
- Per contact notification mute
- Fixed WhatsApp attachments rejecting image/png
- Outbox retry for failed sends
Rules:
- 6 posts maximum, across LinkedIn, X and Instagram.
- Every proposed post names its source: a stat, an inbox theme,
or a shipped item.
- Anything sourced to "general best practice" is not a post. Drop it.
- Do not call crm_schedule_social_post yet.
A plan you can argue with
The provenance rule is the part worth stealing even if you use none of the rest. Every row in the plan names where the idea came from: a stat, an inbox theme, or a shipped item. Three legitimate sources, nothing else counts.
Rows sourced to "general best practice" or "industry trend" get deleted without discussion. Those are not ideas, they are the shape of an idea, and they are what a language model produces when it has nothing to work with. The provenance column turns that failure from invisible to obvious: you do not judge whether a post is generic, you read the source column.
The second thing to argue about is volume. Six proposals that produce four decent posts is a good week. Six proposals that produce six means you cut nothing, so the two weakest go out under your name. Ask for more than you intend to publish, then cut. The cutting is the editorial work and the part you cannot delegate.
How to schedule social posts with AI without losing the review gate
Now the plan becomes objects, and this is where most people hand over too much at once. Ask for a table before any tool call. Not because the tool calls are dangerous (they are not, and we will come back to why), but because nine tool calls scrolling past is unreviewable and a nine row table is not.
Draft the four posts we kept.
Print a table first: platform, hook line, the claim it makes, the source,
the slot I asked for. Do not call any tool until I reply "go".
When I say go:
- Create each post with crm_schedule_social_post, with an explicit
scheduledAt carrying a Z or an offset, in a review slot at least
36 hours out.
- Never pass publishNow.
- Print the returned postIds and the echoed scheduledAt for each one.
Which brings us to the single most important rule on this surface, and the reason we are comfortable letting an assistant call a write tool at all:
crm_schedule_social_post requires scheduledAt unless publishNow: true is passed. Omit both and the call is rejected with scheduledAt is required unless publishNow is true. Nothing is created, and nothing is published.
Read that carefully, because it is not the rule people expect and the difference matters. There is no draft status. A post is pending, processing, published, failed or cancelled, and that is the entire vocabulary. You cannot create a post with no time attached and let it sit in a holding pen, because the holding pen does not exist.
The safety property survives the correction intact, it just works by a different mechanism than a safe default. An assistant that misreads your instruction and forgets to say when does not publish something half-finished to 40,000 followers, and it does not quietly stash a mess for you to clean up on Tuesday either. It gets an error and has to come back to you for the missing time. Immediate publication is never inferred from vagueness: it takes an explicit publishNow: true, a field you can look for in the confirmation before you approve it. The failure mode of ambiguity is a rejection, which is the behaviour you want from anything that can reach your audience.
So the review gate is built out of the schedule itself rather than out of a draft folder. Put the post in a slot far enough out that you will see it before it fires, which in practice means at least 36 hours, and read the queue back with crm_list_social_posts filtered to status=pending. That gets you what people actually want from drafts, a holding area with your eyes on it, plus something a draft folder never provides: a deadline. Drafts rot because nothing forces a decision. A pending post at Wednesday 09:00 forces one by Tuesday night. If you change your mind, crm_update_social_post edits it and crm_cancel_social_post stops it, both while it is still pending.
Here is the call and what comes back. MCP tool output is camelCase throughout, while the public v1 REST API underneath it is PascalCase. If you read both in one session, that difference will catch you once.
crm_schedule_social_post
{
"content": "Three things we learned migrating 40 support inboxes.",
"platforms": ["linkedin", "x"],
"accountIds": [12, 15],
"scheduledAt": "2026-08-26T06:00:00Z",
"timeZone": "Europe/Istanbul"
}
returns
{
"count": 2,
"postIds": [993, 994],
"platforms": ["linkedin", "x"],
"scheduledAt": "2026-08-26T06:00:00Z",
"status": "pending",
"skipped": null,
"message": "Scheduled on 2 account(s) for 2026-08-26 06:00 UTC."
}
Three details matter later. The response returns postIds as an array, not a single id, because one post row is created per target account: two accounts is two posts that happen to share a schedule, not one post on two networks. That is the fact behind almost everything in the cross posting section below. Every id is an integer, and they are stable, so keep the transcript: every subsequent edit, cancellation and audit question keys on them. And accountIds is explicit, which is what makes this workable for an agency running several client accounts in one workspace. Omit it and you get the default account per platform, fine for a single brand and quietly wrong for anyone else.
Two limits are enforced before anything is written. At most 20 target accounts per call, and each target's daily post limit is checked up front, so a fan out cannot half succeed against a quota you could already have seen. When a target is skipped for that reason it is named in skipped rather than failing silently, which is worth printing in your session even though it is null most weeks.
The review gate: five checks on every draft
Read every draft, every week. The moment you start approving batches on the strength of the first two, the loop has become a machine for publishing things you have not read. Five checks, in this order, roughly ninety seconds per post once you have the habit.
- Every claim. If a draft says a feature "cuts response time by 60 percent," find out where that number came from. Language models are excellent at reproducing a figure that appeared once in your notes, in a different context, about a different thing. Percentages, customer counts, and any sentence containing "first" or "only" get verified or cut.
- Any link. Open it, in the state a stranger would find it. A broken link is not punished by an algorithm, it is punished by the one reader interested enough to click, which is the only reader who mattered. Watch for mangled tracking parameters and staging URLs that came in with the source material.
- The opening line, alone. Read only the first line, as it appears truncated in the feed before anyone taps to expand. If it reads like a topic sentence ("Customer support migrations are a common challenge for growing teams"), rewrite it. That line is the entire post, as far as most people who see it are concerned.
- Could anyone else have written this. Mentally swap your logo for a competitor's and ask whether the draft still works. If it does, it is a category post and will perform like one. The fix is almost always a specific: a number from your own system, a decision you regret, a customer question quoted verbatim.
- The ask. Most drafts have none, a few have three, which is the same as none. One is right, and "nothing, this is just useful" is a legitimate choice made deliberately rather than by omission.
Edit the one variant, never regenerate the batch
You will find a problem in post three of four, and the instinct is to say "these are good but make post three punchier and regenerate." Resist it. Regenerating rewrites the three posts you already approved. You will not re-read them, because you just read them. You will approve them from memory, and memory is of versions that no longer exist. That is how an unreviewed claim gets published by a team that reviews everything.
Edit exactly the post that is wrong, by id. crm_update_social_post takes a postId and optional content, scheduledAt, mediaUrls and timeZone, with at least one of content, scheduledAt or mediaUrls required. Note what is not on that list: you cannot change platforms on an existing post. The platform was fixed when the row was created, which follows from one row per target account, and the way to change your mind about a network is to cancel that row and schedule a new one. Only a pending post is editable at all; anything else comes back as "Post 993 not found, or it is no longer editable (only pending posts can be edited)", which is also what you see if you try to fix a post while it is mid-publish. It is annotated idempotent, so running the same update twice is safe, and everything you do not pass stays as it was. Nothing else in the batch is touched and the ids you noted stay valid. Generation is batch work; correction is single record work. Mixing them is the main way a review gate silently stops working.
Scheduling mechanics that bite: UTC, IANA zones and the four in the morning post
Two fields, two different jobs, and mixing them up is the most common bug on this surface. We have made it ourselves.
scheduledAt is an instant, in ISO 8601, in UTC, with the trailing Z. It answers "when, on the world clock." timeZone is an IANA time zone identifier like Europe/Istanbul or America/New_York. It answers "whose local clock are we reasoning about," and it drives display, reporting alignment, and what a recurring slot means.
Say which clock you mean, in one of the two supported ways. A scheduledAt carrying a trailing Z or a numeric offset names the instant outright, and timeZone is ignored for it. A bare wall clock is converted from the timeZone you pass in that same call, which is exactly what the field is for. The failure is being explicit in neither: a bare value with no zone beside it is read as UTC, and the zone already stored on the post does not reach in and fix it. That is the version that looks reassuring in the review screen and is wrong. Here is the failure with real numbers: an agency in Istanbul runs social for a client whose audience is in Chicago, and the slot is Wednesday morning, nine o'clock, Chicago time.
Target: Wednesday 26 August 2026, 09:00 for an audience in Chicago.
Wrong
{
"postId": 993,
"scheduledAt": "2026-08-26T09:00:00Z"
}
The post carries "timeZone": "America/Chicago", so the 09:00 looks correct
in the client's review. It is not. In August, Chicago is on CDT, UTC-5.
09:00Z is 04:00 local. The post publishes at four in the morning.
Also wrong, and worse because it looks deliberate
{
"postId": 993,
"scheduledAt": "2026-08-26T09:00:00"
}
A bare wall clock with no Z and no offset is read as UTC, and the zone
already stored on the post does not reach in and convert it. This is the
same 04:00 local failure as above, reached by someone who thought they
had avoided it. Passing "timeZone": "America/Chicago" in this same call
is what makes 09:00 mean nine in Chicago.
Right
{
"postId": 993,
"scheduledAt": "2026-08-26T14:00:00Z"
}
14:00Z is 09:00 America/Chicago on that date. The equivalent
"2026-08-26T09:00:00-05:00" is the same instant, written the other way.
That middle case deserves its own sentence, because it is the trap set by the field names themselves. Always put the offset in scheduledAt itself. Send it with a trailing Z or with a numeric offset, and never rely on timeZone to convert a bare value for you. 2026-08-26T14:00:00Z and 2026-08-26T09:00:00-05:00 name the same instant, both are honoured exactly as written, and the response echoes back the UTC form either way so you can check the arithmetic in the confirmation. A bare 2026-08-26T09:00:00 is taken as UTC no matter what zone you pass alongside it. If you find that surprising, you are in the majority, which is exactly why the rule is worth writing on the wall rather than reasoning about each time.
Pass timeZone whichever form you choose. It is validated as a real IANA zone id, and an invalid one is rejected rather than ignored, so it catches a typo like Europe/Istanbol at the moment of the call. It is what the queue displays and what a human reviewing next week reads. And when the value it sits beside is a bare wall clock, it is the field that converts it, which is why the pair has to travel together.
The symptom is not an error. Nothing fails. The post publishes, the platform records it, and the analytics show a normal looking post with a fraction of the usual impressions. Then comes the expensive part: somebody concludes that Wednesday mornings do not work for this audience and moves the whole calendar. A silent five hour offset produces a wrong strategy, not a bug report.
The rule that prevents it
Make the assistant state both times before it calls the tool. Literally this sentence in your prompt: "Before scheduling, print the target local time, the IANA zone, the UTC offset in effect on that date, and the resulting scheduledAt." Reading "09:00 America/Chicago is 14:00Z on 2026-08-26" takes two seconds and catches every instance.
The seasonal version is nastier because it appears weeks later. America/Chicago is UTC-5 in August and UTC-6 in December, so a recurring slot built from a fixed UTC instant is 09:00 local in summer and 08:00 local in winter. Your slot drifts by an hour, twice a year, in opposite directions.
Teams in Türkiye ship this bug more often than most, for a specific reason: Europe/Istanbul has been fixed at UTC+03:00 with no daylight saving since 2016. If your own zone never shifts, you have no instinct for zones that do. One related trap: EST is not an IANA identifier, and it is not what you mean anyway, since it is a fixed offset New York uses for part of the year. Use America/New_York.
Listing a month of posts without losing half of them
Here is a place where an assumption from the REST world will quietly cost you. The MCP list tools do not use cursor pagination. There is no items envelope, no nextCursor, no hasMore and no after argument on this surface. A list takes limit, an integer from 1 to 100 defaulting to 25, and returns a named array plus a count. Cursor paging with ?after= belongs to the v1 REST API, where the caller is your code and an unbounded loop is a decision you made on purpose.
The failure mode is still real, and it is an assistant behaviour rather than an API behaviour. You ask "how many posts are scheduled in September", the assistant calls crm_list_social_posts without thinking about limit, counts what comes back, and answers with complete confidence. The number it gives you is the default page size. You have sixty three posts queued and it just told you twenty five. Nothing errored, and the answer is delivered in the same tone as a correct one.
The fix is a one line habit: ask for the limit you need, then reconcile count against something you did not get from the same call.
crm_list_social_posts
{
"status": "pending",
"fromDate": "2026-09-01",
"toDate": "2026-09-30",
"limit": 100
}
returns { "count": 63, "posts": [ 63 posts ] }
Sanity check against a total you did not get from that call:
crm_social_post_stats { "days": 30 }
-> "pending": 63
The two agree, so nothing was cut off at the limit.
Note the argument names while you are here, because they are the ones people guess wrong: the date filters are fromDate and toDate, not from and to. The status filter takes a real status value, so it is pending for things that have not gone out yet. There is no scheduled status and no draft status to filter on. Also worth knowing before you build a review screen on it: content is truncated to 400 characters in a list result, so read a full post with crm_get_social_post rather than trusting the list to hold the whole text.
The tell that you hit the ceiling is count arriving exactly equal to your limit. Sixty three against a limit of 100 is a real total. Exactly 100 against a limit of 100 almost never is, and the right response is to narrow the window and ask again rather than to raise the limit, since 100 is the maximum the server accepts. Put "state the limit you used and say whether count equalled it" in your standing instructions, which turns a silent truncation into a visible one.
Message history is the one place that genuinely pages, and it goes backwards rather than forwards. crm_list_social_messages takes beforeMessageId: pass the id of the oldest message you already hold and you get the batch before it. Anchoring on a message id rather than an offset is what makes it safe on a live thread, because new inbound messages arrive at the recent end and never shift the window you are walking back through.
crm_list_social_messages { "conversationId": 4821, "limit": 25 }
-> the 25 most recent, oldest of them id 88190
crm_list_social_messages { "conversationId": 4821, "limit": 25,
"beforeMessageId": 88190 }
-> the 25 before that
The same care applies to crm_list_social_conversations, where an undercount does real damage, because a missed conversation is a customer who did not get an answer. Compare the count you got against activeConversations from crm_social_inbox_summary, which is computed independently and is therefore an honest check rather than the same number twice.
Cross posting without sounding cross posted
One idea, several platforms, and the difference between doing that well and doing it lazily is visible to every reader in about half a second.
The mechanic first, because it explains the discipline. crm_schedule_social_post takes a platforms array, and passing ["linkedin", "x"] queues the same text to both. What it does not do is create a single shared object: one post row is created per target account, which is why the response hands back postIds: [993, 994] rather than one id. Each row then lives its own life, with its own status, its own error message if it fails, and its own published URL.
That has a pleasant consequence for the recovery you will eventually need. The working rule is unchanged: the platforms array is for identical text. If you catch yourself editing one platform's version, you wanted separate posts. But because the rows are already separate, splitting them is not surgery on a shared object. You cancel the row you no longer want, since platforms cannot be changed on an existing post, and schedule the variant fresh.
Step 1, cancel the LinkedIn row from the fan out (993 was LinkedIn):
crm_cancel_social_post
{
"postId": 993
}
-> { "postId": 993, "platform": "linkedin", "status": "cancelled",
"message": "Post cancelled; it will not be published." }
Post 994, the X row, is untouched and still pending.
Step 2, schedule the LinkedIn variant as its own post:
crm_schedule_social_post
{
"content": "We moved 40 support inboxes onto one queue last quarter.\n\nThree things I would tell my past self:\n\n1. Migrate the routing rules before the history.\n2. Nobody agrees on what 'resolved' means. Decide first.\n3. The old tool's saved replies are your real documentation.",
"platforms": ["linkedin"],
"accountIds": [12],
"scheduledAt": "2026-08-26T06:00:00Z",
"timeZone": "Europe/Istanbul"
}
One caution on step one. Cancel is safe to repeat, and cancelling an already cancelled post succeeds and says so, so a retry costs nothing. What it cannot do is take back something already published: the server answers that the copy on the network cannot be withdrawn from here. If the fan out has already fired, you are editing history rather than a queue, and the options are the ones in the incident section below.
Now the editorial part. What a LinkedIn variant does that an X variant must not is spell things out. LinkedIn readers expect context: who you are, what the situation was, why it mattered, in full sentences with the line breaks that make a phone screen readable. On X that same construction reads as someone posting their LinkedIn to X. X wants the whole idea to survive in the first post, with the thread as a deliberate choice rather than default overflow.
| Platform | What the variant has to do | What breaks it |
|---|---|---|
| Establish context in the first two lines, before the fold | Opening with the conclusion and no setup | |
| X | Land the whole idea in one post | A four line preamble and a thread nobody expands |
| Carry a visual; the caption is the second act | A wall of text posted as a screenshot of text | |
| Threads | Sound like a person mid conversation | Announcement voice |
| Speak to people who already bought from you | Cold acquisition copy aimed at strangers | |
| TikTok and YouTube | Treat the text as a description for a video that carries the idea | Writing the post first and filming to match |
| Answer a search that will still be searched next year | Anything time bound |
Those are reader conventions, not algorithm mechanics. Nobody outside those companies knows the ranking rules, and anyone who says otherwise is describing a correlation they found in their own account.
Where an identical dump is completely fine
Not every post needs a voice. A factual announcement with a date and a link should be the same text everywhere: the API is back up, the webinar starts in an hour, the release is out. Rewriting those three ways spends the only budget that matters, which is your attention on the posts that need thought.
One exception worth stating plainly: do not cross post to Reddit from a scheduler. Community norms there treat identical multi community posting as spam, and enforcement is human moderators who will remove it. Reddit is in the supported platform list because reading and replying there is useful, not because broadcast works.
When the plan meets reality: incidents, slips and cancellations
Two things reliably break a scheduled week: something goes wrong in production, or something you announced does not ship. The incident case is the urgent one. Your status page is red, support is fielding angry DMs, and a cheerful post about how smoothly everything runs goes out in forty minutes. The sequence takes under two minutes.
- List what is queued in the next 72 hours:
crm_list_social_postswithstatusofpendingand afromDateandtoDatewindow, withlimitset high enough thatcountcomes back below it. - Cancel anything that reads badly next to an outage:
crm_cancel_social_postwith{"postId": 993}. It is annotated idempotent, and cancelling an already cancelled post succeeds and says so, so cancelling twice is safe. - Leave the neutral ones alone. Going completely silent during an incident is its own signal, and not a good one.
- Reschedule rather than discard:
crm_update_social_postwith a newscheduledAtmoves a post into next week.
The launch slip case is slower and easier. Move the dates, and change any post whose text implies a shipping date. The failure here is not moving fast enough: a post that says "shipping Thursday" going out on Friday is a small credibility cost, and small credibility costs compound.
The API does not delete published posts, and that is deliberate
Look at the thirteen social tools and notice what is missing. crm_cancel_social_post stops something that has not gone out. There is no delete tool at all, and that is a design decision rather than an oversight. Deletion semantics differ across twelve platforms: some remove immediately, some tombstone, some drop the post but keep the media, some remove it from the feed while leaving it reachable by direct URL, and rate limits mean a bulk delete can partially fail. A delete tool that silently succeeds on nine platforms out of twelve is worse than none, because it manufactures confidence exactly when you most need the truth.
The deeper point: deletion is not retraction. If a post was live for forty minutes it was seen, and if it was bad enough to want gone, it was screenshotted. What to do instead, in order:
- Reply to your own post with the correction. It travels with the original, which a deletion does not.
- If the content is genuinely harmful (a wrong figure, a wrong security claim, a customer named without permission), delete it natively in the platform's own app, where you can confirm it is gone.
- Record what happened, so next month's review does not read the gap as "we did not post that week."
Feeding the loop from the inbox
This is the section that separates content which fills a calendar from content which lands, and almost nobody does it, because doing it by hand means reading a month of DMs. Your inbox contains people telling you, in their own words, exactly what they do not understand about your product. That is the highest quality content brief available anywhere, it costs nothing, and it renews every week.
crm_social_inbox_summaryfor the shape: how many active, how many unread, which platforms.crm_list_social_conversationswithstatusofactiveand an explicitlimit, then check the returnedcountagainst theactiveConversationsfigure from step one so you know nothing was cut off.crm_list_social_messageson the conversations whoselastMessagePreviewlooks like a question.- Cluster the inbound questions into themes, count how many distinct people asked each one, rank by count.
- Apply the threshold. Five or more independent askers in thirty days is a post. Twenty or more is a page on your site, and probably a fix to whatever page they were reading when they got confused.
Take the example from our own data shapes: "is the 12 month plan still available?" arriving as a lastMessagePreview. Asked once, it is a reply. Asked nine times in a month by nine different people, it is not a pricing question at all. It is a signal that your pricing page does not answer a question about contract length clearly enough for people to stop asking humans. The post that comes out of that cluster writes itself, and it beats anything you invent, because it answers a question that provably exists.
Notice what this replaces. Keyword research tells you what people type into a search box about your category. Your DM inbox tells you what people ask when they are already looking at your product and are one answer away from buying. The second corpus is smaller, later in the funnel and far more actionable.
The rules for using customer messages
Never publish a handle, a name, or a screenshot without asking. The tools hand you participantName and participantUsername because your CRM needs them to attach a conversation to a person. That is a CRM field, not a content asset. Paraphrase the question, drop every identifier, and if the paraphrase would still be recognisable to the person who asked, change it further.
Answer the question in the post, completely. A post that describes a question and then invites you to book a call to hear the answer is worse than not posting, because the person who asked is watching and now knows you are using their confusion as marketing.
Then the loop closes: the post you wrote from a recurring DM question becomes the answer you paste into the next DM that asks it. Write once, use twice. The reply side of that workflow, including the dm-reply-draft prompt, is covered in the companion piece on how to manage Instagram DMs with AI. One access note that connects to governance below: this step needs social:read, and a key scoped only to posts cannot see a single conversation, by design.
The monthly review that decides what you keep
Once a month, half an hour. Three tools do the mechanical part: crm_social_post_stats with days set to 30 for what actually went out, crm_list_social_posts for the individual rows behind that total, and crm_dashboard_summary for the business side, so the content conversation happens next to the pipeline conversation.
Set expectations correctly before you start, because this is where teams misuse the stats tool most. crm_social_post_stats is a delivery report, not a performance report. It tells you 34 posts were attempted, 24 published, 6 are still pending, 3 failed and 1 was cancelled, broken down per platform. It cannot tell you which post did well, because it holds no impressions, no engagement and no reach. Those numbers live in each platform's own analytics, and the honest version of a monthly review pulls them from there (or from the analytics hub for anything that touches revenue) rather than pretending the MCP surface has them.
Start the review with the delivery numbers anyway, and start with the failures, because they are the part no one else will surface. A month showing 3 failed posts is a month where three things you wrote reached nobody, and you will not find that in an engagement dashboard: as far as the platform is concerned, those posts never happened. Read the errorMessage on each failed row from crm_list_social_posts. It is usually one of two boring causes, an expired account connection or an unreachable media URL, and both are fixable in minutes once seen.
Then the judgement half. One prompt rule matters more than the rest. Tell the assistant to print every published post from the last 30 days as a table (platform, slot, and whatever performance figures you pasted in from the platforms) and to write no interpretation until that table exists. Only then does it produce three things to keep, three to cut and three to test, each citing a row above it.
An assistant asked for analysis will narrate first and select supporting numbers second, because that is what a coherent answer looks like and coherence is what it optimises for. Forcing the table out first breaks the ordering. Same failure a human analyst has, same fix.
| Decision | The rule | The trap |
|---|---|---|
| Keep | Top quartile on your chosen metric, and repeatable without heroics | Keeping a format that only worked because of one unusual news week |
| Cut | Bottom quartile and expensive to produce | Cutting cheap underperformers, which are inventory, not a problem |
| Test | Anything with exactly one data point, good or bad | Promoting one good post to "our best performing format" |
The "and expensive to produce" condition in the cut row is doing real work. A post that takes eight minutes and gets modest engagement is not a failure, it is a cheap unit that keeps you present. A post that takes half a day for the same numbers is the one to kill. Cost per post belongs in the review and almost never appears there.
Be honest about sample size. Twelve posts a month across three formats is four observations per format, which is not enough to rank formats, and any confident ranking from it is pattern matching on noise. What four observations can support is one conclusion: something that produced nothing at all, three months running, should stop. Kill decisions need less evidence than promote decisions, and treating them symmetrically is how teams end up chasing a format that got lucky once.
Metrics that mislead versus metrics that pay
The comfortable metrics are comfortable for a structural reason: they are large, they go up when you post more, and they are mostly not about you.
Impressions measure distribution, and distribution is a decision the platform made using inputs you do not control and cannot see. Watching impressions closely is watching someone else's decision closely. It is also the biggest number on the dashboard, which is why it gets screenshotted into the monthly report.
Follower count is a stock, not a flow. It accumulates, it rarely goes down, so it always looks like progress. Doubling it changes nothing about this month's revenue, and followers acquired from one viral post unrelated to your product are an audience that will never buy anything.
Likes are the cheapest signal a human can emit: a quarter of a second, committing the reader to nothing. None of these are fake. They are real measurements of things that do not pay you.
| Comfortable metric | What it actually measures | The harder metric that replaces it |
|---|---|---|
| Impressions | How widely the platform chose to distribute you | Saves and bookmarks: the reader expects to need this again |
| Follower count | Cumulative history, mostly from your best month ever | New followers who then messaged you within a week |
| Likes | Costless approval | Replies that contain a question mark |
| Engagement rate | A ratio whose denominator the platform controls | Profile visits that became conversations |
| Posting streak | Your own compliance with your own calendar | Posts that produced an inbound DM within 48 hours |
That last row is the one worth building, and you can only build it if publishing and the inbox live in the same system. Note which tool supplies which half, because it is not the one people reach for first. crm_list_social_posts filtered to status of published gives you the post side, since it returns the individual rows with a publishedAt timestamp on each. crm_social_post_stats cannot do this job: it returns totals for a window, not a list of posts with times, and it holds no performance figures at all. crm_list_social_conversations gives you the other half, inbound conversations each carrying a lastMessageAt. The assistant aligns the two: for each published post, count conversations that opened within 48 hours from people who had never messaged you before.
Be precise about what that number is. It is correlational: somebody who saw your post on Tuesday and messaged on Wednesday may have been about to message anyway. What it is good for is ranking. Over three months, the posts that consistently sit above your baseline for inbound DMs tell you something real about which ideas make people want to talk to you. Noisy, and still far more useful than impressions.
For the business side of that picture, the analytics hub and the contact records hold what social platforms never show you: what those conversations became. The connection between content and revenue runs through the CRM, not through the platform's native insights tab, which is the same argument we made in our piece on what a fragmented sales stack really costs.
Governance: who approves, and what the shared key can touch
Everything above works for one person. Adding a second person is where it either becomes a system or becomes a mess.
One approver per platform, named
Not a committee, not a channel, a person. The approver is not the author, and the roles stay separate even when both are you: the assistant drafts, you review. The moment approval becomes "post it in the channel and if nobody objects in two hours, ship," you have a process that approves everything, including the thing everyone was too busy to read.
For agencies running several clients the approver is the client, and the review gate has to survive the round trip. Since there is no draft state to park things in, build the round trip out of the schedule: queue each post as pending into a slot comfortably beyond the client's usual turnaround, send them the list from crm_list_social_posts, and let approval mean "leave it alone". Rejection is a crm_cancel_social_post, and a change of mind is a crm_update_social_post while it is still pending. This is stricter than a drafts folder in the way that helps: a post nobody looked at goes out on the date you agreed, so silence becomes a decision the client has to make rather than a queue that quietly rots. Set the slot far enough out that silence is genuinely consent, and never so far that the content is stale when it lands. Our notes for agency teams and marketing teams go further into the approval shape.
Scope the key to the job
Bearer keys look like csk_live_... and carry scopes granted per key. Four are relevant here.
| Scope | Grants | Give it to |
|---|---|---|
posts:read | Read scheduled and published posts, plus stats | Anyone working on content, plus analysts |
posts:write | Create, update and cancel posts | The person or contractor who drafts and schedules |
social:read | Read DM accounts, conversations and messages | Whoever runs the inbox, not whoever runs content |
social:write | Send DMs, mark conversations read | Support and sales, not the content contractor |
A content contractor's key carries posts:read and posts:write, and nothing else. Without social:read that key cannot list a single conversation or read a single message. Your customers' DMs are not part of the content job, and the scopes split along exactly that line for this reason. Create keys in the developer settings, one per human, never one shared: a shared key makes the audit trail worthless, because the answer to "who scheduled that" becomes "the marketing key."
Two extra layers live in the local proxy. --read-only drops every write tool before your client sees the list, the right default for an analyst who should read numbers and never touch the calendar. --tools posts narrows the exposed surface to the posts family. Both filters run locally in the stdio proxy, so a filtered tool is not listed and not callable. Treat them as defence in depth: the scope on the key is the real boundary, because it is enforced server side.
Keeping an audit trail that survives a question in three months
Every write tool returns a confirmation of what changed, never a data feed, and no tool on this server both reads and writes. A write result says precisely what happened and nothing else, so a saved session reads as a log.
- Keep the transcript of any session that scheduled something. Post ids are stable integers, so a transcript containing the
postIdsarray from each call reconciles againstcrm_list_social_postsmonths later. Record the whole array, not the first element: a fan out to four accounts produces four ids, and the one that failed is rarely the one you wrote down. - Never paste a key into a shared document, a ticket, or a prompt. It belongs in the client's environment config. A key in a prompt is a key in a log.
- Rotate on departure. When a contractor's engagement ends, revoke the key that day.
What the platform holds is documented on the security page. The architectural fact that matters here: the npm package is a proxy. It forwards JSON-RPC to the CRM Solid backend, which holds the platform connections. No platform password, token or cookie ever touches the machine running the assistant, which is what makes handing a scoped key to a contractor reasonable at all.
What this loop still cannot do
Four honest limits, because a workflow post that claims none is a brochure.
It does not know what you shipped. Nothing in the CRM watches your repository, so you paste the list or point the assistant at a changelog. That step stays manual. It has no taste. It will produce a correct, well structured, entirely unmemorable post with the same confidence as a good one, which is why the review gate exists.
It is not choosing your images. mediaUrls takes URLs you supply, and on a visual first platform the caption is the smaller half of the post. Platform native extras are not in the post object either: polls, carousels, collaborator tags and first comment placement live in each platform's own composer. If a post needs one, schedule the rest of the week here and do that one by hand.
Within those limits the loop holds, and it survives week three not because it writes faster but because Monday starts with three tool calls returning real material instead of a blank grid. A plan built from your own numbers, your own inbox and your own shipped work is one you can still defend on week eleven. The full tool reference lives in the MCP documentation, with a longer worked tutorial in the social media guide repository.
Frequently asked questions
Will the assistant publish something without asking me?
No, not by default and not by accident. crm_schedule_social_post requires scheduledAt unless you pass publishNow: true, and a call carrying neither is rejected with scheduledAt is required unless publishNow is true. An assistant that forgets to say when gets an error, not a surprise post. Immediate publication requires that explicit argument, so there is no phrasing and no model confusion that turns a planning session into a live post.
Note that there is no draft state to fall back on either: a post is pending, processing, published, failed or cancelled. Build your review gate out of the schedule by queueing into a slot at least a day and a half out, and for an extra layer run the proxy with --read-only during planning sessions, which drops every write tool locally before your client sees the list.
Should scheduledAt be in UTC or my local time?
Send it as an explicit instant, either UTC with the trailing Z (2026-08-26T06:00:00Z) or a local time carrying its numeric offset (2026-08-26T09:00:00+03:00). Those two forms name the same moment, both are honoured exactly as written, and the response echoes the UTC form back so you can check the arithmetic. A third form works too: a bare wall clock with the timeZone argument in the same call, which the server converts for you. What you must not send is a bare wall clock with nothing beside it, because 2026-08-26T09:00:00 on its own is read as UTC.
The timeZone field takes an IANA identifier like Europe/Istanbul or America/New_York. Keep passing it, because it is validated and an invalid id is rejected rather than ignored, so it catches a typo at the moment of the call, and it is the label a human reads on the queue next week. Treat it as that label, not as the converter. Mixing the two fields up is the most common bug here, and putting "print the target local time, the zone, the offset in effect on that date, and the resulting scheduledAt" in your standing instructions stops it happening.
Can I schedule one post to all twelve platforms at once?
Mechanically yes: pass every platform in the platforms array of a single crm_schedule_social_post call. Editorially you should only do that when the text is genuinely identical, which is true for factual announcements and false for almost everything else.
The moment you want to change one platform's copy, split it. Because the call creates one post row per target account, the rows are already separate: cancel the row for that platform with crm_cancel_social_post and schedule a fresh post for it. You cannot edit platforms on an existing post, so cancel and recreate is the supported path rather than a workaround. And do not include Reddit in a broadcast array, because moderators there remove identical multi community posts.
What happens if I cancel a post that already went out?
crm_cancel_social_post stops a post that has not published yet. It does not reach into a platform and remove something already live, and there is no delete tool on the MCP surface at all. That is deliberate: deletion behaves differently on all twelve platforms, and a bulk delete that silently fails on three of them would be worse than none.
If something published that should not have, reply with a correction, and if the content is genuinely harmful, delete it in the platform's own app where you can confirm it is gone.
How far ahead should I schedule?
One week firmly scheduled, plus a rough plan for the week after that you have not turned into posts yet, is the shape that survives. Scheduling a month out feels productive and produces a calendar full of posts written before the events they should have responded to. The exception is genuinely evergreen material: search driven Pinterest content, reference threads, seasonal announcements with fixed dates.
Since there is no draft state, keep that second week in whatever document you plan in rather than in the queue. Anything you create is a pending post with a real date attached and it will publish on that date if you forget about it, which is the correct behaviour for week one and the wrong place for a maybe.
Do I need a separate scheduling tool for each platform?
No. One post object carries a platforms array and one connection per account, and the same server exposes the DM side, which is the part separate scheduling tools cannot do. Twelve platforms are supported: Instagram, Facebook, X, LinkedIn, TikTok, YouTube, Threads, Pinterest, Reddit, Bluesky, Telegram and WhatsApp.
The practical argument for one system is the metric in the table above: posts that produced inbound DMs. You cannot compute that if publishing lives in one product and conversations live in another.
Which assistant should I use for this?
Any MCP client works, because the server speaks the protocol rather than a vendor API. Claude Desktop, Claude Code, Cursor and ChatGPT all connect through the same stdio package. Choose on how the client renders tables, whether it saves reusable prompts, and how clearly it shows a tool call before it runs.
That last point is the one that matters. If your client hides tool calls, you have lost the review gate the rest of this workflow depends on.
How do I stop the assistant inventing product claims?
Two mechanisms, and you need both. In the prompt, require a source for every claim and forbid numbers that did not come from a tool result or from text you pasted. In the review, verify every figure by hand, because a prompt rule reduces the rate of invented claims without taking it to zero.
The structural help is the provenance rule from the planning session. When every proposed post has to name a stat, an inbox theme or a shipped item as its source, invented claims lose their hiding place: they are the rows with a vague source, visible in the table before anything gets written.
Can two people share one API key?
They can, and it destroys the audit trail. Post IDs and write confirmations tell you exactly what changed, but not who did it if the key belongs to a team rather than a person. Create one key per human, scope each to the job (a content contractor needs posts:read and posts:write and nothing else), and revoke on the day someone leaves.
Keys are free to create and instant to revoke, so there is no operational reason to share one. It usually happens because somebody pasted a key into a shared document once, which is also why keys belong in environment config.