MCP tools & contracts
The full catalogue of the Scripe MCP server’s 76 tools (39 reads, 37 writes includingswitch_workspace), 6 resources, and 3 prompts, and
the contracts they share: scopes, two-phase confirmation, idempotency,
inline file content, and progress streaming. For connecting
a host and the workspace/project model, start at MCP.
Tools mirror the REST resources — the MCP tool is a thin adapter over
the same handler the REST endpoint calls, so any contract you’ve coded
against /v1/* is preserved. The exceptions are the verbs that only
make sense in a conversation (two-phase schedule_post and
publish_post, the destroy verbs, the settings, posting-schedule and
positioning writes, create_content_topic, generate_post_ideas, the
community profile-list trio, and
the per-post auto-engagement pair
get_post_engagements / update_post_engagements), which ship on MCP
only — with one exception: delete_source also has a REST route
(DELETE /v1/sources/:sourceId), two-phase there too (§2.13).
Most tools resolve a missing projectId from the connection’s default
project; eleven require it explicitly — the split is in
MCP §2.2.
1. Read tools
All read tools advertisereadOnlyHint: true. The required scope must
be on the OAuth grant; <resource>:write grants imply the matching
read. (draft_positioning_from_website also advertises
readOnlyHint: true but is listed with the write family in §2.10,
because it exists to feed a write.)
The subsections below are the read contracts an integrator (or an
agent) needs to answer a user correctly. Each is shaped around the ask
it serves.
1.1 A post’s status: two vocabularies, one truth
Every post row carries several status fields, because Scripe has two vocabularies for the same thing:
The truth is the custom status (
statusId), because that is what
the product writes: publishing sets it in one indivisible UPDATE and
the scheduler does the same. status is derived from that status’s
category, and only falls back to the stored column for a post that
carries no custom status at all. (Until 2026-08 this field served the
stored column directly, and nothing had written published to it since
2025-12 — so every post published in that window read back draft, and
list_posts({ status: "published" }) answered an empty list.)
Two consequences worth knowing before you answer a user:
draftcovers two columns.draftandinProgressboth render asstatus: "draft", so a workspace with an “In Progress” and a “Good to go” column reports both as drafts. ReadstatusTitlewhen the user asks where a post is.- Filter on the axis you mean.
statusfilters the lifecycle value (anddrafttherefore also returns theinProgresscolumns);statusCategoryfilters the board column and is the only way to ask forinProgressorreview. Both match through the custom status, so a filter and a read can never disagree.
1.2 “Did my post go out?” — read delivery, never infer from status
A post the publisher has given up on keeps its scheduled status
forever. The publish cron picks up a post whose scheduledAt is in the
past and within the last 24 hours; outside that window it is
skipped on every tick from then on, and nothing in the row changes. So
two posts can be byte-identical on status + scheduledAt while only
one of them will ever publish. Every post read therefore carries a
delivery block with the verdict:
- Never report a post as “on its way” from
statusalone. On production, 145 posts across 44 projects have been sittingscheduledand unsent for more than a day; 41 of them recorded no error at all, so the row contains no other evidence that anything went wrong. delivery.detailis the sentence to relay. It names the failure and the repair.lastPublishErrorbeside it is the raw message from the last attempt — useful, but not written for a user.retryUntilis the deadline. Whilestateisduethe user can still fix the cause (usually reconnecting LinkedIn) and the post goes out by itself. After it, onlyschedule_postwill.- Find them in one call:
list_posts({ delivery: "missed" }). The five states partition the project, so an empty page really does mean nothing is stuck. - Then ask why. 90 of those 145 stranded posts sit on a project whose LinkedIn authorization was revoked — §1.4.
1.3 “What’s waiting for my approval?” — read review, never the board column
Every post read carries where the post stands in the approval workflow,
and list_posts filters by it:
- A
reviewboard column is a different question. Across production those columns hold 1,018 posts: 231 awaiting a decision, 104 already approved, 11 sent back, and 672 nobody was ever asked to look at. SostatusCategory: "review"answers “what needs my approval?” 22.7% correctly. Filter byreview: "pending"(the two combine). - A review belongs to a STAGE, not to a post. A post keeps the
review rows of the columns it passed through — 2,144 of production’s
2,490 are at a stage their post has left, 313 of them still
PENDINGon an already-published post. Only the row for the post’s current column is reported here, which is why a published post readsnot_requested. reviewerUserIdis a Clerk user id.list_teamreturns the same id asuserId— resolve it there rather than showing the raw id.- Nothing on this surface can decide a review. Approving and
rejecting happen in the dashboard;
detailsays so on every state that is waiting on a person. requiredByWorkspacecan be ENFORCED on this surface (since 2026-09; it mirrorsget_workspace_context.requireReviewWorkflow). Two things have to be true before it refuses anything, andrequiredByWorkspace: trueon its own is only the first: the workspace must have set the flag AND be on a plan that includes content approvals (Advanced and Business among the plans sold today). Approving and rejecting are themselves plan-gated features, so in a workspace below that plan the flag is inert — nothing on this surface refuses, because the approval a refusal would send the user to get cannot be produced there at all. Do not branch onrequiredByWorkspacealone: treat it as “this workspace may refuse an unreviewed post” and handle the409when it comes, rather than pre-emptively blocking a schedule the API would have accepted. Where the gate does bind,schedule_postandpublish_postrefuse a post without an approved review with409 conflict(details.reason:review_not_requested/review_pending/review_rejected;details.reviewerUserIdis always present — the Clerk id of the reviewer on the post’s current-stage review when one exists, pending or rejected,nullotherwise — and the message itself never shows the raw id, so resolve it vialist_team). A post the caller could not schedule anyway answers the ordinarynot_found/forbidden_project, never a review state.create_post_draft/generate_postrefuse a non-nullscheduledForoutright there — a post that does not exist yet cannot have been reviewed, so create the draft first and schedule it after approval — and accept it normally in a workspace the gate does not bind on. A review approved at an earlier board column still counts after the post moves on (the dashboard’s approve-then-move flow). Approvals themselves still happen only in the dashboard. The REST write paths (POST /v1/posts,PATCH /v1/posts/:postId) deliberately do NOT enforce it — see Posts § review.
1.4 “Is my LinkedIn still connected?” — the linkedIn block
Every project read (get_project, list_projects,
get_workspace_context) carries the publishing precondition:
- Branch on
canPublish/state, never onconnectionStatus.connectionStatusis the stored health cache. On production it readsCONNECTEDfor 38 accounts that hold no access token at all, andUNKNOWNfor 15,352 that were simply never connected.stateapplies token presence first. - This read never probes LinkedIn. It reports stored health as of
lastValidatedAt. The live check runs insideschedule_postandpublish_post, where a write is about to happen — the proposals return it (§8). - You cannot fix it. Reconnecting LinkedIn is an OAuth flow a human
performs in the dashboard;
reconnectUrlis where. Relay it rather than retrying — no call on this surface can succeed until they do. - A company page has no connection of its own. It publishes through
the personal account it was linked to, so its
linkedInblock (and itsreconnectUrl) describe that account.nullmeans the page was never linked to one, which is itself the answer.
1.5 “Find my post about X” — use q, never page the project
list_posts and list_notes take a free-text q. Use it for any ask
whose subject is what the content is about.
- An empty result means the words are absent, not the topic. This
is a literal substring match with no stemming and no synonyms:
pricingdoes not findpriced. Retry with fewer or simpler words before concluding anything. - Paging is not a substitute. A project’s posts run into the
thousands; hundreds of production projects hold more than the 200-row
maximum page, so “read them all and look” cannot terminate. If
qreturns nothing, say so.
search_media searches the image library and search_viral_posts
searches other people’s posts — neither looks at the user’s own
writing.
1.6 “How many …?” — read pagination.total, never add up pages
list_posts and list_notes report pagination.total: how many rows
the filter matches, across every page.
limit: 1is the right shape for a counting ask — you want the number, not the posts.- The count excludes the cursor, so it is the same number on page 1 and page 4. It is not “rows remaining”.
- It counts what you filtered by, including
q. - Never report the page size as the total — paging to find out costs every post body in the project (measured: 3 calls and ~52k tokens on a 128-draft project; the largest production project holds 7,874 posts).
has_more is still
the only signal there.
1.7 A list row’s content is an EXCERPT — get_post has the post
list_posts serves ~280 characters of each post body, cut on a word
boundary, with contentTruncated: true beside it. A production post
averages 1,078 characters, so a default page of 50 whole bodies is
~54,000 characters — roughly 13.5k tokens spent on “show me my drafts”.
- Never quote a row whose
contentTruncatedis true back to the user as their post. It is an opening, not the text they wrote. - To read one post, call
get_post— it always returns the whole body and costs one row. content: "full"returns whole bodies for the page; pair it with a smalllimit.content: "none"omits the body entirely, which is the right shape for “what’s in my review column”.- When you pass
q, the excerpt is centred on the first matching term, so the row shows why it matched. A row whose excerpt does not contain your search word matched on its title or its hook.
list_post_analytics uses the same three modes with the same default.
1.8 “What’s in my pipeline?” — one call, then open the column
list_post_statuses is the board: every kanban column the workspace
runs, with postCount — how many posts sit in it right now — plus a
project rollup:
- One call answers the ask — not
list_postsonce per category. counts.byCategory[c]is the same numberlist_posts({ statusCategory: c, limit: 1 })reports aspagination.total.counts.totalalways equals the sum ofbyCategoryplusuncategorized(posts whose column was deleted).- Without a
projectId(REST callers only — the MCP tool defaults it) everypostCountisnullandcountsisnull: statuses are workspace-wide, posts are not. - Then open the column the user named:
list_posts({ statusId: "22e0…", content: "none" }). A category is not a substitute — a workspace can run several columns inside one category. An id the workspace does not own is refused with a400 invalid_request, never answered as an empty column. The sameidis whatupdate_post({ statusId })moves a post to.
1.9 “What did I publish last week?” — a different date from dateFrom
A post has two dates, and they are days apart: createdAt is when the
draft was written, publishedAt is when the LinkedIn copy went live.
On production the gap averages 6.3 days, and 51% of the posts
published in a given week were drafted before that week began. So
dateFrom/dateTo — which filter the CREATION date — cannot answer
“what did I publish last week”. Use the publication window:
publishedAtis the only field that dates a publish.scheduledAtis what somebody asked for and stays set on posts that never went out;updatedAtmoves on any edit.publishedAt: nullon a published post means “moments ago”. The date comes from LinkedIn’s own measurement row, which lands within the hour. It is never guessed from the schedule.publishedFrom/publishedToonly ever return published posts, and combine with every other filter.- The page is still ordered newest-DRAFTED first. Sort by
publishedAtyourself before narrating “my last three posts”.
list_post_analytics measures the LinkedIn
side, and its dateFrom/dateTo are publication dates too, so the
same window lines the two up. Every measured row carries post_id —
see §1.10.
1.10 “How did THIS post do?” — pass postId, never scan a window
list_post_analytics describes posts that are live on LinkedIn.
That is not the same object as the Scripe draft it may have come from,
so a row carries three ids and only one of them is actionable:
post_id is null for any post not published through Scripe —
imported history, posts written directly on LinkedIn. That is most of a
mature account’s back catalogue, and it is the honest answer rather
than an error. Use permalink to show the user the post, and content
(with content: "full") as source text for a generate_post rewrite.
When the user asks about one post, pass the id:
- One call, one row, ~330 tokens — versus ~12k tokens per date-window
page, and a window cannot work at all for a post published minutes
ago, whose
publishedAtis still null. - An array works too, up to 50 ids.
metaappears only whenpostIdwas passed, and it is what makes an empty answer readable:meta.unmeasuredsays which named post has no numbers and why —not_published(never went out) ornot_measured(published, but LinkedIn’s sync has not landed; it usually does within the hour).- An id the project does not contain is a
400, not an empty page. sortdoes not rank by rate.impressionsranks by views andengagementby total interactions; if the user meant rate, sort, then comparemetrics.engagement_rateyourself.
1.11 What search_viral_posts searched, and what its numbers mean
“Viral” here means an outlier, not a big number. Every post in
data beat its OWN author’s median engagement by at least
meta.min_outlier_multiplier (2 by default), so a large account’s
ordinary post is not reported as a hit. outlier_multiplier on each
post is that ratio and is capped at 10 — a 10 means “at least 10x”.
The search is bounded before it ranks: only posts published within
meta.published_within_days (90 by default, max 180), in the resolved
meta.language, above meta.min_engagement (100 by default), in any
meta.media_formats that were asked for, excluding the project’s own
author. A short or empty data may therefore be those filters at
work rather than a topic nobody writes about.
meta.narrowed_tois present when you narrowed beyond the default and the answer is short, and it names the three numbers to widen. One of those filters may be why: offer to relax one rather than reporting the topic as uncovered, and do not assert the cause — the search cannot tell a thin topic from a filtered one. The recoveries are a lowerminOutlierMultiplier(0 opts out of the outlier floor entirely and serves every post aboveminEngagement, scored or not), a widerpublishedWithinDays, nomediaFormats,language: "all", or a lowerminEngagement.mediaFormatsis how you ask for a shape.carouselis a swipeable deck (a document post),multi_imageseveral photos in one post,imageat most one; thenvideo,text(no media),article(a shared link) andother. A deck is a small share of the corpus, so expect a shorter answer there than for an unfiltered search.- An
outlier_multiplierofnullmeans NOT SCORED, never “average” — the post is too fresh, below the scoring floor, or its author has no usable baseline. It is only reachable withminOutlierMultiplier: 0. Onlyoutlier_baseline: "AUTHOR"supports a sentence about that author’s own usual performance;CORPUScompared the post to a corpus median instead. meta.returnedis how many posts came back, capped bylimit(default 12, max 30). There is no paging and no corpus-wide total.metrics.viewsisnull, not0, when unknown. Only a post’s own author can read its impressions, so LinkedIn withholds the count for roughly 97% of third-party posts. Report anullas unknown.metrics.reactionsdoes not have to add up tototal_engagement, because LinkedIn reports reaction types it does not break out.- Two of the three ids are links, and the third is not usable.
permalinkopens the post,author.profile_urlopens its writer, andidis the internal feed row — no Scripe tool accepts it. To act on an angle you liked, feed the post’scontenttogenerate_postas context.
1.12 The two engagement rates and the three click figures in get_analytics_report
daily[] is one row per day in the range, zero-filled, and every
figure in a row is that day’s own — including engagementRate,
which is that day’s engagement over that day’s impressions and is
therefore 0 on a day with no impressions.
cumulativeEngagementRate is the separate running figure: the rate
from the start of the range through that day — what the in-app KPI card
plots, deliberately carrying forward across quiet days.
Read engagementRate to compare days, cumulativeEngagementRate for a
trend line. Reading the running rate as a per-day figure is how a month
with four posts appears to have had engagement on all thirty-one days.
The CSV rendering of GET /v1/analytics/report carries both columns;
the PDF’s Daily Metrics table shows the per-day rate only.
get_analytics_report’s posts[] carries the same postId as
§1.10, plus a ~100-character content excerpt with
contentTruncated: true. The list is the genuine top 20 of the whole
period, ranked by impressions in the database; postsTotal counts
every post in the period and postsTruncated says the list is capped —
page the full set with list_post_analytics.
summary carries three click figures from two sources, and the
dashboard never adds them up — report each under its own name:
A reading off the funnel’s Tracked link clicks stage (labelled Link
clicks before SCR-1719) is
totalTrackedLinkClicks and nothing else;
one off the Link clicks KPI card is totalLinkClicks. Neither is a
component of the other, and totalPremiumCtaClicks belongs to neither.
totalTrackedLinkClicks is null only when a report is built without
a workspace, which never happens on this surface.
1.13 “When’s my next slot?” — say localTime, and ask again for the next one
get_next_free_slot is the one tool whose answer IS a time said out
loud, so it hands back the resolved wall clock rather than the inputs
to a conversion:
- Do not convert
isoyourself. Projects that never picked a calendar timezone store the legacy"CET"— an abbreviation that names UTC+1 but observes EU summer time, so a naive conversion is an hour off half the year.timezoneis therefore always reported as the IANA name the stored zone is equivalent to. - “Free” is checked, so ask again for each post. A slot is free when no post is scheduled at that exact minute and nothing else on the calendar — a queue slot, a note, an idea placed on the board — reserves that day. Schedule the first post into the returned slot, then call again and it returns the next one.
1.14 Which clock get_posting_times answers on
An hour is not a value on its own — the same posts land on a different
hour in every timezone — so the response always names the zone it used:
The default is the project’s calendar timezone — the same zone
list_calendar renders days in and get_next_free_slot reports. Quote
timeZone whenever you name an hour back to a user. Pass timeZone
only to deliberately answer for a different audience; it must be a full
IANA name, and a name the server cannot resolve is rejected with
invalid_request rather than silently answered in UTC.
An absent hour was never tried. Only slots with count > 0 are
returned — a bucket the project has never posted in carries no
measurement, so serving it as {count: 0} would claim the hour
performs badly. Say it is untested; and when totalPosts is small
(below roughly three posts per slot), say the signal is thin rather
than naming a best time.
1.15 “Is anything engaging as me?” — read effective, not policy
get_engagement_policy returns two shapes of the same three actions,
and only one of them answers the question:
policy: null is the normal state — roughly 98% of production
projects have no row — and it is not “unconfigured, so anything might
happen”: activation fails closed, so an actor with no policy row is
declined. effective reports that as all-OFF with
source: "no_policy", versus source: "policy" for stored defaults.
Answer the user from effective and never infer from a null.
The distinction is load-bearing. “Never answered” (no_policy) can
still be seeded with the entitled defaults when the workspace upgrades
its plan or reconnects LinkedIn; “answered, and said no” (a stored
all-OFF row) may never be promoted back by anything. That is why
update_engagement_policy writes no row when it disarms a project
that has none: the ask is already true, and the placeholder would
silently remove the project from seeding forever. The proposal says
exactly that in its effect field before the user confirms.
One direction only: the write can lower a default to OFF or
REQUEST, never raise one to ON. A project you disarm here cannot be
re-armed here — that is a dashboard action, and the proposal says so.
1.16 “Can I still generate posts this week?” — ask before you spend
Two 402s can refuse any AI verb (generate_post, generate_image,
generate_carousel, add_to_knowledge_base, create_source_file):
get_usage reports both before either fires — full field reference on
Usage and limits.
- Read
canGeneratefirst. It isfalseexactly when the next AI job would be refused, andblockedBynames which limit. Deriving it from the meters is easy to get wrong twice over: a meter at 100% only blocks whenai.enforcementisblock, and the daily cap compares spend + the job’s estimate, so 2¢ of headroom is already blocked for a 5¢ post generation. - Before a batch, check once. Ten generations that die on the sixth leave the user with half a plan and no explanation.
- AI is a percentage, never money. The budget is denominated in provider cost, so the meaningful figure is how much of the allowance is gone. Storage is bytes; the daily cap is cents.
- A company page has no budget of its own — it spends from the
workspace pool, which
ai.scopenames.
1.17 Analytics across every workspace, in one call
Every other tool runs against one active workspace, and that is structural:switch_workspace moves the anchor, it does not widen it,
and a project id from another workspace answers not_found on purpose.
So “give me the numbers for all my clients” used to be a loop the model
had to invent — switch_workspace → list_projects →
get_analytics_overview, per client, then add the figures up in prose.
Nothing told it to run that loop, so it usually answered from the
default project and stopped.
get_cross_workspace_analytics_overview and
get_cross_workspace_analytics_report do the loop server-side. Neither
takes projectId — they aggregate every project you can read in each
workspace, which is the question they exist to answer — and neither
requires (or performs) a workspace switch.
Reading their answers correctly:
- Every row is attributed.
data.workspaces[].workspacecarries theid, the displayname,is_default, andreach:member(Clerk membership) oragency_owner(the workspace is billed to an agency the user owns — the grant the dashboard has always applied). Name the workspace beside every figure you report. - Read
data.totals, never a sum of the rows. It is a fresh query over the union of every project in the response, because an engagement rate has no meaningful average. When it isnull, report no total at all —meta.totals_omitted_reasonsays whether there was nothing to total or the union was too large for another pass. meta.skippedis not zero engagement. It names reachable workspaces in which the caller can read no projects. That is missing visibility, not a quiet client.meta.workspaces_reachableis your full reach, on both selectors — not the count you named, and not a truncation signal. On an explicit selection it normally exceedsmeta.workspaces_returnedwhilemeta.truncatedis false; readtruncatedfor that.meta.truncatedmeans the response holds the firstmeta.max_workspacesreachable workspaces. Say so, and name the remaining workspaces explicitly inworkspaceIdto cover them — at mostmeta.max_workspacesper call, since an explicit list longer than the cap is refused rather than truncated (see Analytics).meta.projects_truncatednames any workspace holding more readable projects than one call measures, whose row — and its share ofdata.totals— therefore covers only its newest projects. Empty when every workspace fitted; measure those workspaces throughget_analytics_overviewwith explicitprojectIds.- A named workspace you cannot reach is refused
(
workspace_unavailable), never dropped — so a selection you asked for is either answered or reported, never silently narrowed. - The report variant is larger by design. Each entry’s
report.posts[]holds only the topmeta.posts_per_workspaceof the period whilereport.postsTotalcounts them all; page the rest withlist_post_analytics, one workspace at a time.
get_analytics_overview
for one workspace or for specific projects.
2. Write tools
Annotations:
readOnlyHint: false on every write except
draft_positioning_from_website, which makes no persistent change and
says so. destructiveHint: true on the destroy verbs
(delete_idea, delete_knowledge_doc, delete_note, delete_post,
delete_source, delete_media_asset) and on update_post_engagements,
whose remove permanently deletes the engagement’s tracked links, their
click history and their redirect-cache entries — and which, unlike the
destroy verbs, is single-phase, so the hint is the host’s only cue to ask
first.
idempotentHint: true on the naturally idempotent writes —
attach_media_to_post and attach_media_to_idea (full-state),
cancel_job, schedule_idea
(re-placing on the same day is a no-op), switch_workspace,
update_engagement_policy, update_tone_of_voice,
update_positioning, update_global_tone_of_voice,
update_personal_dna, and the delete verbs whose replay reads as
not_found / a no-op (delete_knowledge_doc,
delete_note, delete_source, delete_media_asset, publish_post
via its already_published short-circuit). delete_idea is not idempotent — its replay
hard-deletes a cascade, so a second call is a different operation.
2.1 Idempotency
The resource-creating toolscreate_note, create_post_draft,
create_source_text, create_source_file, add_to_knowledge_base,
create_content_topic, add_profile_to_list and generate_post accept
an optional
idempotencyKey argument —
the tool-argument equivalent of the REST Idempotency-Key header,
since MCP has no header surface. Pass the same key when retrying a call
that may already have succeeded and the original envelope is returned
instead of a second resource. Reusing a key with different arguments
returns idempotency_key_conflict.
Omitting it is still safe. The server derives a fallback key from
the tool arguments, scoped to a short window, so an agent that retries
an identical call gets the original result rather than a duplicate.
This matters most for create_post_draft with scheduledFor —
duplicates there would each publish to LinkedIn. To deliberately create
the same resource twice, wait out the window or pass distinct
idempotencyKey values.
update_post_engagements is dedup’d the same way but takes no
idempotencyKey argument — only the derived fallback key. Its add
entries mint rows, and only LIKE and REPOST are caught by the
once-per-actor rule, so a retried call would otherwise queue the same
comment twice under the acting brand’s LinkedIn identity.
Because it changes rows rather than creating a resource, its replay is
additionally checked against the current state: the stored envelope comes
back only while the rows that call determined are still as it left them —
every row it added still exists, every row it modified still carries the
payload it set, every row it removed is still gone. If that is no longer
true the call executes again. So a deliberate repeat inside the
window is honoured rather than swallowed: add an entry, remove it, send
the identical add again and the entry really is queued again, and
re-sending a modify you have since reverted re-applies it. A genuine
retry — one whose writes are all still in place — still replays without
a second write. Only what the call itself set is compared, so a row
moving through its lifecycle underneath you (an approval granted in the
dashboard, the scheduler firing) does not turn a retry into a second
write, and neither does an edit to a row the call never touched.
The two generators have their own replay form: generate_image and
generate_carousel take an idempotencyKey that dedups for 24 hours
by attaching a retried call to the job already running instead of
billing twice. create_media_asset needs no key at all — it dedups on
the content itself (§2.5).
2.2 Two-phase confirmation
The irreversible verbs —publish_post, schedule_post,
delete_post, delete_idea, delete_knowledge_doc, delete_source,
delete_media_asset, permanent delete_note, update_tone_of_voice,
update_engagement_policy, and update_posting_schedule, plus the
positioning and personal-DNA writes when they would replace existing
text — share one server-enforced contract:
- Propose. Call the tool without
confirmationToken. Nothing executes. The result is a proposal — exactly what will happen, plus aconfirmationTokenandtokenExpiresAt(~5-minute TTL). - Show the proposal to the user and get an explicit yes. This step is yours. The server cannot see it: both calls arrive from the model, so a host that chains them executes without any human in the loop.
- Execute. Call the tool again with the identical arguments plus the token.
- The token is bound to the action, the acting user, the subject, and
the proposed change — the arguments you sent. Confirm with
different arguments than you proposed and the replay fails with
invalid_request(details.reason: "confirmation_invalid"). Re-propose so the user sees what changed. An expired token fails the same way withdetails.reason: "confirmation_expired". - What that binding does not cover, for every verb except
publish_post, is the subject drifting underneath you. Onlypublish_postalso binds a hash of the post’s content, media, and engagement flags, so editing the post between its proposal and its confirm invalidates the token. Editing a note between adelete_noteproposal and its confirm does not — that token binds{ permanent }, not the note’s body. If the subject changing matters to your flow, re-read it before you confirm. - Four verbs additionally fold the surface into the bound action, so a
token minted in chat cannot be redeemed over MCP or vice versa:
publish_post,update_positioning,update_global_tone_of_voiceandupdate_personal_dna. Every other verb’s token is not surface-scoped —update_tone_of_voiceincluded, despite writing settings text. - Tokens are not single-use; duplicate protection comes from conditional writes on the subject (see §2.3), so a network retry of a confirmed call is safe.
posts:publish, settings:write, and
the *:destroy scopes are never implied by anything, so a grant that
can propose a destructive action is always one a human consented to by
name. Reversible paths deliberately stay cheap — delete_note’s
default archive rides plain notes:write with no confirmation, and
delete_post has no such half only because a post is not archivable
anywhere in the product.
2.3 Publishing (publish_post)
publish_post makes an irreversible external write, so it carries the
strictest version of the contract.
Phase 1 — propose.
commentary (the exact string
LinkedIn will render, footer included), postsAs + identityKind
(personal profile vs company page), preflight (the live LinkedIn
connection check), the post’s engagement flags, publishesThisWindow
(e.g. "2/5"), irreversible: true, and the confirmationToken.
preflight is reported, not enforced: anything other than "ok" means
the LinkedIn connection is expired, revoked, or (for a company page) has
no admin with a working connection — publishing anyway will fail at the
LinkedIn call, so surface the warning and let the user reconnect first.
Phase 2 — confirm. After an explicit yes, the same call plus the
token. The server re-verifies everything it showed:
- Token binding — the content hash covers the rendered commentary, the
media (a poll counts as media here), and the engagement flags, so a
draft edited between the two calls (including via
attach_media_to_postorupdate_post) fails closed rather than publishing something the human never saw. - Every eligibility check re-runs: reshare, empty draft, existing LinkedIn URN, in-flight publish lock, published status, the workspace’s review requirement (§1.3), and the 5-per-24h budget.
- The publish itself is guarded twice: a stored LinkedIn URN
short-circuits to
result: "already_published"without contacting LinkedIn, and an in-flight lock returnsconflict. There is no force/override argument. - The post’s configured extras run — auto-like, employee advocacy, Slack notification, engagement activation. These are the same side effects a scheduled publish runs; an agent publish and a cron publish are deliberately the same operation.
- Success returns
result: "published"with the LinkedIn URN.
postIdis the publicpost_…id — the one every other tool returns and accepts. The internal database id is not an input anywhere on this surface; passing one isnot_foundlike any other unknown id.result.postIdcomes back in the same public form.- A publish that failed within the last 10 minutes blocks a retry
with
conflict— a failure after LinkedIn accepted the post is indistinguishable from one before it, so check the post on LinkedIn instead of retrying. - Exceeding the 5-per-24h budget returns
rate_limitedon both phases — schedule the post instead. - Reshares, empty drafts, and posts already live are refused with teaching errors.
- Only treat a post as live once you have seen
result: "published". - A video’s custom cover is fail-closed: a
thumbnailKeyLinkedIn will not take fails the whole publish rather than posting the video with another cover — see Media § attaching to a post. - A post that carries no media but does carry a link publishes with a
LinkedIn link-preview card, built from the linked page’s Open Graph
metadata. LinkedIn does not scrape URLs, so Scripe supplies the card;
it is best-effort and not part of the proposal — a page with no
og:title, or a thumbnail that cannot be fetched, simply publishes with a smaller card or none. A post that carries its own media is unaffected: media always wins over the link.
schedule_post — a time in the request
is a schedule, never a publish. To unschedule (revert to an unsent
draft), update_post({ postId, scheduledFor: null }) — reversible, so
no confirmation.
2.4 Media on a post, and on an idea (attach_media_to_post, attach_media_to_idea)
The media write is full-state: it replaces everything the post
carried. So “add one more image” is a read-then-write, and the read is
get_post, whose media block reports the post in exactly the shape
this tool takes back. To add a second image, resend the first one by
key:
key is a stored file key, and the publish step classifies it by
extension to decide whether the asset goes to LinkedIn as an image, a
document or a video. A key it cannot classify — an img_… id, a CDN
URL — is rejected with unprocessable naming the branch that works.
(It used to be accepted with a 200 and then fail on the publish cron
hours later, with the post looking attached the whole time.)
The response carries the post’s resulting media, because the
resolution is not the identity: a library id comes back as the stored
key you will need next time, and a .pdf sent under kind: "images"
comes back as kind: "document" — which is how LinkedIn will publish
it.
A post the dashboard gave a LinkedIn poll refuses media with conflict
and is left unchanged — a post publishes media or a poll, never both; see
Media § Attaching to a post.
kind: "none" on such a post is allowed and leaves the poll alone.
Idea media (attach_media_to_idea) is the same full-state contract
on a BOARD IDEA, with a narrower payload: library images only, as
{ "kind": "images", "images": [{ "assetId": "img_…" }] } (first =
cover, max 20), or kind: "none" to clear. There is no key branch —
an idea’s media is a library reference, not a stored post file — and no
video/PDF kinds; the one non-image case is a single carousel that
carries a cover image, attached alone: that cover becomes the media
image and the idea’s visual format becomes CAROUSEL. That includes
a carousel built by generate_carousel: the job renders a page-1
cover and re-hosts it to Scripe’s CDN, so a freshly finished deck may
answer the transient processing refusal for the few seconds the
re-host takes, then attaches like any image. A carousel generated
before covers existed stored a rendered document with no durable cover,
so attaching one of those is refused permanently (not_attachable),
not temporarily; it is attachable to a POST either way. A multi-image
stack needs an image-producing visual format (a CAROUSEL-format idea
refuses a stack with the repair named), and an asset is otherwise refused
until Scripe has re-hosted it on its CDN — an idea stores the image URL
itself, so only the DURABLE one may be written onto a card, and
status: "READY" is not the signal (a READY row can still be waiting
on the re-host). That is stricter than attach_media_to_post, which
stores a storage key and so attaches a just-created asset immediately;
here, retry shortly (usually seconds) — but only when the refusal is the
transient one, since a media with no durable image at all says so and
will answer the same on every retry. Reference-only style picks (shown
by get_idea as media.mode: "reference") are made in the Scripe app
and are readable but not writable here — and because the write is
full-state, it DISCARDS one the idea was carrying (with either kind),
which nothing on this API can recreate. Clearing an idea that has media
also clears its assetFormat, exactly like the idea page’s “remove
media”; on an idea that carries none it changes nothing, because the
visual format on its own is a brief label update_idea owns — the
response’s wrote: false is how you tell that no-op from a real clear.
The media URL fields get_idea returns
(media.images[].imageUrl, media.styleImageUrl) are display links to
the image bytes and need media:read alongside ideas:read; without
it they come back null while the img_… ids, mode, style name and
image count stay intact — images with a null imageUrl mean the link
is withheld, not that the idea has no media (media: null is what
that means).
A reference pick often has no picture to link at all: the Scripe app
draws the preview live from the customer’s own brand — their quote
card carrying the idea’s hook, a carousel cover in their identity,
their brand over one of their own photographs. Those come back with
media.styleImageUrl: null whether or not you hold media:read, and
the decision in media.brandPreview: kind (quote-card |
carousel-cover | photo-overlay), the text the card carries, an
optional designId / variant, and for a photo overlay a photoUrl
— the one link in it, gated by media:read like the others and simply
absent without that scope. It is a structure, not an image: there is
nothing to fetch, and redrawing it takes the app’s renderers. The
field is MCP-only — the REST detail read (GET /v1/ideas/{ideaId})
emits the properties its OpenAPI schema documents and no brand
preview.
2.5 The user’s own image (create_media_asset)
attach_media_to_post takes an img_… library id or a key the post
already carries. A photo the user just handed you is neither —
create_media_asset is the step in between. The whole arc is two
calls:
status: "PROCESSING"does not mean “wait”. The asset is attachable the instant it is returned — attach resolves the stored file, not the status. Cloudflare re-hosting and vision tagging run asynchronously;search_media(which listsREADYrows only) shows it once they finish.- Two ways in, exactly one per call.
content_base64is simpler for SMALL files (≤ ~3 MB decoded — anything larger dies at the platform edge as a bare HTTP 413, so pre-check the size). Passsha256of the decoded bytes alongside when the host can compute it; a mismatch is rejected instead of storing corrupted bytes.uploadId— anupl_…handle fromcreate_upload_url, after you have PUT the bytes to its signed URL — is the path for everything else, up to the full 25 MB image cap. - Images only. SVG is refused, and a PDF or an audio file is
refused naming the tool that takes it (
add_to_knowledge_base,create_source_file). - Retries are free. The same bytes, or the same upload handle, return the asset already created rather than a second copy — and re-sending bytes that were previously deleted revives the asset.
- An
upl_…handle whose bytes were never PUT is a422that says so, not a broken asset.
delete_media_asset (media:destroy, two-phase): the
dashboard’s tombstone delete, so the image leaves the library and every
future attachment while posts already carrying it keep their copy and a
LinkedIn-synced photo is never re-imported. A foreign or unknown
img_… id answers the same not_found either way.
Deleting needs the OWNING project, attaching does not. Profile
assignment makes an image reachable — enough for search_media to list
it and for attach_media_to_post to use it — but the delete runs the
dashboard’s own check against the project that owns the row. So
search_media can hand back an img_… whose delete answers
not_found; the refusal says the rule out loud, and a host should
report it as an image owned by a different project rather than as one
that does not exist.
“Keep their copy” is bounded at 30 days. The delete stamps the
row’s updatedAt, and the retention sweep erases the stored file and
the CDN copy 30 days later — every embedded reference (saved carousel decks, saved templates, exported PDFs) then returns 404. A post
scheduled to publish more than 30 days out loses its image. Posts
already published to LinkedIn are unaffected, because LinkedIn hosts
its own copy. Both phases of the tool say this, and
kept.storedFileForExistingReferencesDays carries the number rather
than a boolean, so a host cannot read it as permanent.
And the erasure reaches every version, not only the current file.
Every render of a generated asset repoints the row at a fresh storage
key, so an asset edited more than once left one stored object per
render. A nightly inventory sweep erases every media-library object no
row references once it is older than the same 30-day window, working
from the bucket itself rather than from anything a writer recorded — a
superseded render is by definition older than its replacement, so all
of them are gone within 30 days of the delete. Both phases say so, and
a host may report the delete as total removal of the stored file and
its history.
The REST twin is POST /v1/media — see Media; deletion
is MCP-only.
2.6 Removing a post (delete_post)
delete_post is two-phase and irreversible, and the two facts are
connected: posts have no archive. A note archives by default and
only confirms on the permanent path; a post has nowhere to go, so every
post delete confirms. The first call returns a proposal, not a
deletion:
linkedInPost before you show the user anything. “Delete my
post” almost always means take it off LinkedIn, and this tool cannot
do that: it removes Scripe’s copy. A published post stays live at the
permalink, and its analytics history is kept but stops linking back to
a Scripe post. Say so, and offer the LinkedIn link.
What the confirmed delete removes, in one transaction: the post, its
calendar slot, its labels, review rows, version history, variations,
carousel deck, inline comments, and any armed auto-engagements. What it
keeps: analytics rows, tracked links and notifications — records of
something that really happened — and the idea, its attached source
material and the topic the post was written from, whose pointer to the
post is cleared rather than the row deleted.
To take a post out of the schedule instead, update_post with
scheduledFor: null is reversible and needs no confirmation.
2.7 The posting schedule (update_posting_schedule)
Two different asks share the word “schedule”, and they are different
tools:
The template is what
get_next_free_slot walks, what the calendar
draws as an open slot, and what the pillar rotation offers next. Most
projects do not have one, which is why that tool commonly answers
source: "fallback".
Read it from get_settings, where the calendar timezone and week
start already live:
slots[].time is a wall clock in effectiveTimezone — always an IANA
name, so it can be converted safely. postsPerWeek is slots × their
days, the number the user says out loud.
The write is full-state. The slots you send become the whole
week, so read the current ones and send them back with your edit
applied. An empty array removes the schedule. One entry per time of
day: daily 09:00 is one slot with seven days, not seven slots —
sending the same time twice is refused rather than merged, because a
merge would guess which day-set you meant.
The first call writes nothing and returns the resulting week plus the
diff:
schedule (what is stored now),
previous (what it displaced — enough to put it back) and changed.
Pass timeZone (IANA) to change the zone the times are expressed in,
or omit it to keep the project’s. Setting a template publishes nothing
and does not move posts that are already scheduled.
2.8 Writing style preferences (update_tone_of_voice)
Three of the fields are stored as integers whose numbers are neither
ordinal nor consistent with each other — 3 is the longest post
length and the shortest sentence style. So the tool takes the word,
and get_settings returns the word next to the number it read:
The legal numbers are still accepted, and a word and its number bind to
the same confirmation token. Any other number is refused: the columns
have no scale between these values, so writing one changes nothing
about generation while telling the user their settings changed.
On the read side,
get_settings’s context carries
postLengthPreferenceLabel, formattingPreferenceLabel and
emojiPreferenceLabel, derived through the same mapping the generators
use — so a project holding a legacy value no picker offers still reads
back as what it will actually produce. What “never chose” reads back as
differs by project type: a company page stores 0 for “never set” and
reads back moderate, the band it generates at; a personal-brand project
stores 1 (its column default) and reads back none.
2.9 Engagement policy writes
update_engagement_policy is covered by its table row plus §1.15 —
the read and the write share the same semantics, including the
deliberate no-row disarm.
2.10 Positioning and the org-wide voice
Three tools write at WORKSPACE level, where every brand shares the result:update_positioningwrites the company and target-audience documents. A field that is still empty is written on the first call. A field that already holds text is written only viaoverwrite: true, which returns a PROPOSAL naming each field with its current and new text and writes nothing until the token is replayed. Withoutoverwrite, such a field comes back inneedsConfirmationwith itscurrenttext — not a refusal, the cue to show the user what a replacement would cost.overwriteis inside the confirmation binding, andreplacedreturns the previous text once, since positioning has no version history.update_global_tone_of_voicewrites the organization-wide tone of voice: custom instructions injected into every generation for every brand, the shared footer, and whether brands may add instructions of their own. Business plan and above (plan_not_eligiblenamescompanyToneOfVoicebelow it). Same empty-vs-occupied rule; only the two TEXT fields ever needoverwrite, andoverwriteis per-CALL — set it and the whole change set, boolean switches included, becomes two-phase.draft_positioning_from_websitereads a company’s public site and drafts the positioning documents, returningwouldFill(empty fields — a direct write) andwouldReplace(fields that would go through the overwrite confirmation) inupdate_positioning’s own field names. It persists nothing — nothing changes until you callupdate_positioningwith the parts the user accepted. Splitting the scrape from the write is what lets the write’s confirmation hash the exact text that lands.
update_personal_dnawrites one project’s personal DNA — the person document behind the dashboard’s Brand → Positioning → “You” tab: who the author is (intro, 1–2 sentences),credibilitypoints,opinions& hot takes, and personalstories(the list sections hold one entry per line, exactly as the dashboard stores them).projectIdis required — this text becomes prompt content for the brand, so the model must name the project. The same empty-vs-occupied rule applies, with the sameoverwriteconfirmation,needsConfirmation/currentreport and one-timereplacedreturn. The read side isget_personal_dna, which does fall back to the default project and echoesproject { id, name, fromDefault }.
get_positioning.
2.11 Removing knowledge (delete_knowledge_doc)
Everything add_to_knowledge_base creates can be removed again,
including a page ingested with type: "url". The delete is two-phase
and takes the document’s chunks, their embeddings and its
project-visibility assignments with it, in one transaction — so “take
that page back out” actually stops it answering questions.
One case refuses, and only one: a document that is one page of a
dashboard sync (a website crawl, a Notion connection, or a GitHub,
Google Drive, Granola or Post history synced source). Deleting a
single page of a sync is pointless — the next sync run restores it — so
the refusal names the sync instead:
type says. type: "website" only means the text came from a web
page, which is equally true of a one-off
add_to_knowledge_base({ type: "url" }) — those have no folder, no
sync behind them, and delete normally.
2.12 Generating for a company page
A workspace’s projects are not interchangeable.list_projects reports
a type per project, and generate_post treats two of them
differently:
Pass the company page’s
proj_… id and nothing else changes about the
call. list_company_pages is where you find it: only a page with
status: "activated" has an activatedProject.id; a discovered page
has no project to write into yet.
A company page’s language, post length, formatting and emoji
preferences are stored on the page itself, not inherited from whichever
personal brand discovered it — read them with
get_settings({ projectId }) and set them with
update_tone_of_voice({ projectId }), the same as any other project.
If the page’s LinkedIn organisation details are missing (a partially
connected page), generation fails unprocessable with a message saying
to reconnect the page — not ai_error. Retrying will not help; fixing
the connection will.
2.13 Removing a source (delete_source)
delete_source is two-phase and irreversible, and — uniquely among the
destroy verbs — it also exists on REST (DELETE /v1/sources/:sourceId,
same two phases, token via ?confirmationToken=). The proposal names
the blast radius: the source row, the full transcript (paragraphs and
sentences), its topics and their hooks, its knowledge-base copy
(document, chunks, embeddings — knowledge search stops returning the
content immediately), and the stored audio/video/file object, whose
storage quota is released.
Two things deliberately survive, and both the proposal and the executed
response say so — do not present this as total erasure:
- Posts generated from the source are kept. They are the user’s
content, not the source’s child; only their link to the source goes
stale. The proposal counts them (
kept.derivedPosts); removing one isdelete_post, its own two-phase decision. - The internal usage-accounting ledger survives — deleting it would silently rewrite historical cost reporting.
delete_knowledge_doc applies to those rows, enforced in
both phases.
Without it the call fails admin_required and nothing is deleted; the
proposal counts them in toDelete.workspaceSharedKnowledgeDocuments and
warns that colleagues outside the project rely on them.
Executing emits source.deleted (identity and last status only — the
transcript is never in the payload; a receiver that wanted the content
had source.created). Requires sources:destroy, which no alias and
no sources:write grant ever implies. A token is bound to the source
and the acting principal; a replay after success reads not_found and
deletes nothing further.
2.14 Content topics (create_content_topic)
A content topic is one of the themes the workspace wants to be known
for. The dashboard lists them under Content topics at Settings →
Organization → Positioning; the older per-brand board called the same
thing a topic group, and the tool’s description still carries that
word so a model that learned the old name finds it. Scripe mines the
LinkedIn corpus around each one and builds post ideas from it.
Four nearby things it is not, each with its own tool:
- the positioning documents (
update_positioning) and the tone of voice (update_tone_of_voice,update_global_tone_of_voice) — free text, no topic list; - the topics a recording was cut into (
tpc_…ids onget_source); - the legacy per-project
pillarsarrayget_settingsstill reports; - an idea (
create_idea), which is one post-to-be rather than a theme.
workspaceId may only name the connection’s active
workspace (omit it and it does). Because one topic reaches every brand
it is assigned to, the write needs a workspace-admin acting user on
top of settings:write — the same bar the dashboard enforces. An
API-key principal has no acting human and is therefore refused.
profileIds is optional and takes public proj_… ids from
list_projects. Each is re-authorized on its own; a project outside the
workspace and an amplifier (which never owns content topics) both refuse
the whole call with not_found. Omitting it creates the topic
unassigned, and the response says so: an unassigned topic is listed
but influences no generation until it is assigned. Creating is the only
thing the surface does with content topics: there is no read tool, so a
topic cannot be listed back, and assignment changes, renames, deletes
and the relevance analysis stay in the dashboard.
The response carries the new topic’s wct_… id, its title,
searchTerm, status, order and the profiles it was assigned to.
Creating twice with the same title creates two topics (the dashboard
allows that too); retries are covered by the shared idempotency window
(§2.1), so pass an idempotencyKey when retrying a call that may
already have succeeded.
2.15 Post ideas from material (generate_post_ideas)
generate_post_ideas is the dashboard chat’s idea cards on MCP: the
same producer, the same result. It turns material into For You
ideas — the recommendations the dashboard shows on Explore → For you —
and persists them to the brand’s idea pool, where they appear under the
“Your files” source filter. Each idea carries a title, a hook, an
angle, the postType and pillar it suggests, its funnelStage
(the content lane, derived from the postType, and null for a format
Scripe cannot place in one), a relevanceScore, the brief (the
argument for the idea, as prose) and the citedPassages of the
material that brief was built on. The response
also echoes the project it resolved to (id, name, fromDefault),
because projectId is optional here as it is for the whole idea family.
Material comes from one of three places:
text— an article, notes, a transcript the user pasted, passed verbatim. Scripe cuts it into passages, scores each against the LinkedIn corpus and cites the ones an idea is built on.knowledgeDocumentId— akb_…document fromlist_knowledge. It is held to the same tenant rule the dashboard applies: the authorized brand’s own documents and its workspace’s; anything else answersnot_found. When given,textis ignored.- neither — the ideas build on the brand’s strategy, knowledge base and the request alone.
create_source_file; once processed its transcript is in the knowledge
base, and from there it is a knowledgeDocumentId.
count is optional and bounded (1-12); omit it unless the user named a
number — the generator then picks a sensible three to six. request
carries the user’s own words, and a number named there counts too.
funnelStage is optional and takes one of REACH, TRUST or CONVERT
— the content lane the user asked for BY NAME (“give me Convert ideas”).
Pass it only then: the ideas are then confined to that lane’s post
formats (every returned idea’s funnelStage reads that lane), while a
run without it lets the brand’s strategy weights and the model choose.
The lanes and what each is for are the same three the dashboard shows
(Reach — top of funnel, Trust — middle, Convert — bottom); they are a
mix, not a sequence.
What it does not do: it never writes to the idea board. The chat
card’s “Add” is create_idea here — call it with an idea’s title and
hook to board one the user picked. It is not generate_post (a post)
and not search_viral_posts (a read of the corpus).
Cost and bounds: one premium reasoning call per invocation, so the tool
draws from the job rate bucket (§5 in MCP), and on top of
that the producer’s own per-brand hourly bound — shared with the
dashboard chat — answers rate_limited when the brand has generated
ideas too often in the last hour. A run that produced no usable idea
(the model refused, or every candidate duplicated an idea the brand
already holds) answers unprocessable with the reason. Retries are
covered by the shared idempotency window (§2.1).
2.16 Community profile lists (list_profile_lists, get_profile_list, add_profile_to_list)
A profile list is a named watchlist of LinkedIn people whose posts
Scripe collects, so the customer can read and engage with them from
inside the product. The dashboard shows them under Community →
Profiles. A list belongs to ONE brand, and a member is somebody else’s
LinkedIn profile — never a Scripe brand.
The sentence these exist for is “this hot lead just came in from the
CRM — add them to my Prospects list”:
What profile accepts, and what it refuses
The LinkedIn profile URL is the one thing the caller has to supply.
A full URL, a country-subdomain URL (de.linkedin.com/in/…), a URL
carrying CRM tracking parameters, or a bare vanity handle all resolve to
the same person — a paste straight out of a CRM record needs no
cleaning up.
Scripe cannot look a person up by NAME, so a name is not an input
this tool can resolve. LinkedIn publishes no people-search API and
Scripe’s own corpus is a corpus of creators, not of people — 14.8% of
its rows share a display name with another row. A best match would put a
stranger on a list the customer then engages from under their own
LinkedIn identity. A name with a space or a non-ASCII character is
refused (invalid_request) with what to supply instead; a single bare
word is indistinguishable from a vanity handle and is treated as one,
resolving to whoever owns it — so pass the URL or handle from the CRM
record, never the person’s name. Company pages and email addresses are
refused.
A person who has never posted is a member like any other
add_profile_to_list resolves a profile whether or not they have ever
published, and reports it: postsCollected: false on the add,
postsCollected: 0 on get_profile_list. That is an ordinary state,
not a half-failure — most CRM leads read LinkedIn rather than write on
it. They are watched from now on, and posts appear if and when they
publish. Somebody already on the list answers added: false, alreadyMember: true with a note saying nothing changed — before any
limit below is consulted, since nothing would be written.
A profile that does NOT resolve is one of three things, and the answer
says which:
The middle row is what a non-existent handle usually produces: probed on
2026-09-17,
/profile/detail for a made-up username answered HTTP 200
with {"success":false,"message":"error, try again later"} — the same
status, shape and message as a throttled request — so Scripe cannot
honestly claim either reading and says so.
Limits
Each added profile is a standing obligation — Scripe re-checks them for new posts until somebody removes them — so this verb is bounded more tightly than an ordinary write:
Every one of them refuses the whole call and adds nothing; none
truncates silently. The two caps are counted per add with no lock, so
concurrent adds can overshoot one slightly — they are soft caps, not
guaranteed maxima.
get_profile_list returns at most 200 members, newest first.
list.memberCount is always the list’s true count; a list holding more
than that answers membersTruncated: true with a note saying how many
are shown of how many there are.
Not in this family, by decision
There is no tool that likes, comments on or reposts. Every engagement in Scripe is an explicit human click in the dashboard (captain decision, 2026-09-01) and the data model admits nothing else — two-phase confirmation would not restore the human, since both calls are model-initiated (§3 in MCP). There is also noremove_profile_from_list (removal stops the collection, a
destroy-family verb) and no create_profile_list (a model that cannot
find a list would invent one). Removing a profile, creating a list and
engaging all happen in the Scripe dashboard.
Profile lists are a Business-plan feature; a workspace below it gets
plan_not_eligible. Client accounts of an agency workspace cannot reach
any of the three tools, matching the dashboard.
3. Tool error envelope
Failed tools returnisError: true with structured content:
code is stable and shared with
the error reference. Models react to the structured
form; hosts that don’t parse it show the JSON-stringified text fallback.
3.1 Unknown arguments are refused, not ignored
Every tool’s input schema is closed —tools/list advertises
additionalProperties: false, and a call carrying a parameter the tool
does not declare is rejected before the handler runs:
isError text
block with no code and is not audit-logged.
The alternative was worse. Until 2026-08 an undeclared parameter was
silently discarded, so list_posts({ q: "pricing" }) returned the
newest 50 posts with a 200, and update_post({ postId, status: "published" }) returned a success envelope having changed nothing — a
wrong answer that nothing downstream could tell from a right one. If
you are porting an integration that relied on extra keys being
tolerated, drop them; the refusal names every parameter the tool
accepts.
Nested object arguments follow the same rule only where they opt in:
generate_post.source and generate_post.options reject unknown keys
and name the object in the message. The add / modify entries of
update_post_engagements do not — an unknown key inside one of those
entries is still silently stripped, so validate them against the
tool’s schema yourself.
The REST API remains permissive today — an unknown query parameter or
body key is ignored there, so port REST habits to MCP with care.
4. Inline file content (content_base64)
create_source_file, add_to_knowledge_base, and create_media_asset
accept file content inline as base64, in addition to the two-step
create_upload_url + signed-PUT flow. Inline is for small files
only (the cap below) — it exists because most hosts cannot reliably
drive a separate signed PUT from a tool call. For anything larger the
two-step flow is the only path, and it is the preferred one whenever
the host can drive it.
Limits: ~3 MB decoded per file (~4 MB on-wire as base64). The
serving platform rejects request bodies at ~4.5 MB on the wire with a
bare
413 FUNCTION_PAYLOAD_TOO_LARGE that never reaches the API — no
error envelope, no docs_url — so pre-check the file size and use
the two-step presigned-PUT path for anything larger rather than
retrying inline. Scripe’s own cap sits just under that edge so a
payload slightly over it still gets a teaching error naming the
repair. The per-content-type caps from
Uploads (image
25 MB, PDF/docx 100 MB, audio/video 500 MB) apply to the presigned-PUT
path, which is the only way to send a file the inline cap does not
fit.
Idempotent by construction: the inline path derives the storage key
from a hash of the content, so retries with an identical payload land on
the same upload handle and the ingest workers de-duplicate — a tool
retry never creates a duplicate source or knowledge row.
Two-step form ({ "type": "file", "uploadId": "upl_…" }) is the
required path for files over the inline cap, and what non-MCP
integrations use.
5. Progress streaming
The async tools (create_source_file, add_to_knowledge_base,
generate_post, generate_image, generate_carousel) stream MCP
notifications/progress when the host opts in by passing a
progressToken in the request _meta:
- An initial 0-progress notification as soon as the tool sees a
non-terminal job, updates every ~750 ms while the worker reports
(capped at 60 notifications per request), and a final
progress: 1when the job reaches a terminal state (DONE/FAILED/CANCELLED).total: 1is always set so hosts render percentages correctly. - The tool’s result returns after the final notification with the latest job state.
- Cancellation: if the host sends
notifications/cancelled, the tool stops streaming and returns the current job state. The underlying job keeps running — callcancel_jobexplicitly to stop the worker (hosts often cancel a call to start a follow-up, not to kill the job). - Hard cap: 18 seconds — deliberately under the route’s 25-second
platform ceiling, so the answer has room to serialise and reach you.
At the cap the tool returns whatever job state exists — for a post
generation (32–35 s end to end) that is normally a
RUNNINGjob. The job continues in the background: readdata.idand pollget_job. (The cap used to equal the platform ceiling, so a streamed call could never answer — hosts got no JSON-RPC response at all and lost the job id with it. ARUNNINGjob you can poll is strictly better than a dead socket.)
6. Resources
scripe:// resources let the host attach a record for @-mention or
inspection:
All resources return
mimeType: "application/json" with the same
envelope the REST API uses. Resources are read-only; mutate via the
matching write tool. They are read-on-demand — the server does not emit
notifications/resources/updated, so re-read when you need fresh state.
7. Prompts
Slash-command-style entry points the host renders in its UI. They produce a single user-role message that primes the model with investigate-then-execute steps; they never call tools themselves.
All arguments are strings (per MCP
Prompt.arguments constraints);
numeric ones (weeksOut, ageDays) are parsed server-side with
sensible defaults.
8. Canonical flows
The server’s instructions steer models through these; they’re documented here so you can validate a host renders them correctly. Draft a post.generate_post with the user’s brief as a text
source — the worker applies the project’s tone, pillars, voice samples,
and knowledge base, so the brief shouldn’t over-prescribe. The model
shows the draft and asks before scheduling anything.
File post text the user wrote themselves. create_post_draft
stores that text verbatim as a real post — it lands with the project’s
posts, never on the idea board, and generate_post is the tool that
AI-writes a draft instead. create_idea is the board card for
something still to be written, so any “post draft” ask resolves to
create_post_draft; when a request hands over finished post text
without naming either (“send this to my Scripe project”), the model is
instructed to ask which the user meant rather than guess.
Capture a note for a future day. create_note with content and a
resolved date — the note lands on that day in the dashboard calendar.
Send a file to the knowledge base. add_to_knowledge_base with
inline content_base64 for a small file — over the inline cap (§4),
create_upload_url + PUT first and pass the uploadId — or
type: "text" / "url" / "youtube".
Omitting projectId uses the default project; explicitly passing
projectId: null scopes it workspace-wide (“my org’s KB”). Returns a
Job; progress streams.
Ingest a source, then draft. create_source_file → wait for the
job → get_source — once status is "Success" the response carries
topics[], best-first by score (ranking is just the position
in the served order, and fromSeconds says where the topic sits in the
recording — an earlier chapter is not a better one). The model presents
the topics, the user picks, then generate_post with
source: { "type": "topic", "topicId": "tpc_…" }. The server reads
that topic’s transcript chunk itself — the full transcript is
deliberately never served over this API, so the topic id is the only
way to generate from what the user actually said rather than from the
summary about it. 404 means the topic’s source is outside your
workspace; 422 means it belongs to a different project than the
projectId you passed. Two things not to build on: hooks[] is
empty for anything ingested through this API (hooks are a dashboard
artefact, and post generation writes its own), and the flow is
deliberately not auto-chained — a source can surface several distinct
topics, and the user picks the angle.
Turn a board idea into a post. Pass the id — never re-type the
card into source.text:
pillar fills options.contentType when you did not
send one (an explicit value always wins), and the generated post is
linked back to the idea — the idea’s status is derived from the linked
post, so this is what moves its status from inbox to in_production
(when no post was linked to it yet — the card’s board column is not
changed, since columns are customised per workspace and move only by
hand), and what stops the next session seeing an untouched card and generating
the same post again. Read it back with get_idea (linkedPost.id).
Schedule. Two-phase schedule_post (§2.2). The proposal — the only
thing the user sees before consenting — resolves everything:
scheduledForcomes back as an absolute UTC instant, andscheduledForLocalrenders it in the project’s calendar timezone (timeZone) — e.g.Tue, 18 Aug 2026 09:00 (Europe/Berlin). ShowscheduledForLocal; a raw instant is not something a user can check.- A timestamp with no offset (
2026-08-18T09:00:00) is read in the project’s timezone, not UTC and not the server’s — that is what a user means by “9am”. Send an offset when you mean an absolute instant. - A time that cannot be scheduled is refused at the proposal,
before any token exists: unparseable strings (resolve “next
Tuesday” yourself — the server does no date math), a bare
YYYY-MM-DD(it would schedule midnight; useget_next_free_slotfor a sensible hour), and anything in the past. A proposal you can confirm is a proposal that will execute. - The LinkedIn connection is checked live at the proposal too, and
the verdict comes back as a
linkedInblock:preflight(ok | not_connected | token_invalid | no_company_page_admin),canSchedule,detail, andreconnectUrl. Branch oncanSchedule— when it isfalse, confirming will fail with409 conflict(details.reason,details.reconnectUrl,details.retryable: false), so relaydetailand the link instead of asking the user to confirm; only a human can reconnect LinkedIn. You still get aconfirmationToken, because reconnect-then-confirm is exactly the flow this enables; phase two re-runs the same check. (no_company_page_admincarries noreconnectUrl: its repair is on a different project — some admin’s own LinkedIn account.)
scheduled-category status with the calendar slot in one
step, emits a post.scheduled webhook, and the publish cron posts to
LinkedIn within ~2 minutes of the scheduled time. To reschedule, run
the flow again; to unschedule, update_post({ scheduledFor: null })
(reversible — it emits post.unscheduled).
Publish now. Two-phase publish_post (§2.3).
Work the idea board. create_idea per idea (top of the inbox
column) → schedule_idea for date-level planning → update_idea to
edit → two-phase delete_idea to remove.