Meet the new Scripe, live on October 7.Register

API v1 · MCP — agents

MCP tools & contracts

The full catalogue of the Scripe MCP server's 79 tools (40 reads, 39 writes including switch_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, the content-topic tools (list_content_topics, create_content_topic, assign_content_topic, unassign_content_topic), generate_post_ideas, the community profile-list trio, and the per-post team 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 advertise readOnlyHint: 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.)

ToolScopeReturns
get_workspaceworkspace:readThe active workspace + plan + principal, including defaultProject: { id, name } | null.
list_workspacesworkspace:readEvery workspace the user can reach (id, name, isDefault).
get_workspace_contextworkspace:readOne-call overview: plan + features, review-workflow gate, up to 25 projects each with its linkedIn connection block (§1.4) and posting streak.
list_teamworkspace:readTeam members (≤100; truncated + totalCount when more): name, email, role (the dashboard's label, e.g. Admin, Client), per-member project assignments capped at 25 distinct projects and at the caller's own reach. Never returns credentials.
list_projectsprojects:readPaginated list of projects. Every row carries linkedIn — whether Scripe can publish as it right now (§1.4).
get_projectprojects:readSingle project read, with the same linkedIn block (§1.4).
list_company_pagesprojects:readCompany pages — the delta over list_projects: activation status, STORED admin-token health, follower count, engagement-policy state.
list_notesnotes:readList notes for a project (date / cursor + free-text q search, §1.5). Reports pagination.total (§1.6).
get_notenotes:readSingle note read.
list_postsposts:readList posts for a project — status / statusCategory / statusId / review / delivery filters, q search, creation and publication date windows, content modes. Rows carry the delivery (§1.2) and review (§1.3) verdicts and pagination.total (§1.6).
get_postposts:readSingle post read — the whole body, which list_posts only excerpts (§1.7).
list_post_statusesposts:readThe pipeline: every kanban column with postCount, plus a counts rollup for the project (§1.8). Each row's id is the statusId both update_post and list_posts take.
list_calendarcalendar:readA project's content calendar for a date range (max 92 days): scheduled/planned posts with workflow status + any last publish error, note slots, idea placements, the posting-time template. Days are interpreted in the project's calendar timezone.
get_next_free_slotcalendar:readThe next free posting slot, spelled out (localTime, date, time, utcOffset beside iso/timezone, plus source: template | fallback) — the same computation the dashboard and chat scheduling use (§1.13).
list_ideasideas:readPaginated compact idea-board list: brief fields, the DERIVED workflow status (inbox | in_production | review | done), calendar placement, whether the idea is waiting on someone's input. Responses echo project { id, name, fromDefault }.
get_ideaideas:readOne idea in full: creative brief, capture evidence, the idea's media (attached images / a carousel cover with img_… ids, or a reference-only style pick — the display URLs additionally need media:read, §2.4), AI readiness with open questions, attached sources, linked post. Same project echo.
list_sourcessources:readPaginated source (transcription) list — previews only; fetch a single source for its topics.
get_sourcesources:readSingle source read. Includes topics[], best-first by score, once status: "Success". Pass a topic id to generate_post (§8); hooks[] is a dashboard artefact and is empty here.
list_knowledgeknowledge:readPaginated knowledge-document list visible to a project (workspace docs honour assignment visibility; ephemeral chat uploads never appear).
get_knowledge_docknowledge:readOne knowledge document: metadata, status, tags, chunk count, 2,000-char preview — never the full body.
search_mediamedia:readSearch the project's reachable media-library images (owned + profile-assigned, READY only): display URLs, tags, provenance.
get_settingsworkspace:readCurated project settings: generation-context knobs with word labels (§2.8), project tone of voice, org custom-instruction state, calendar settings including the recurring posting-time template (calendar.schedule, §2.7), content pillars. Structured fields only — never the rendered prompt context.
get_engagement_policyworkspace:readEngagement defaults (OFF | REQUEST | ON), per-requester overrides, stored connection state, plan-lock state. Answer from effective, not policy — §1.15.
get_post_engagementsposts:read + projects:readOne POST's team engagement timeline (the editor's team engagement panel): queued likes/comments/reposts with status + approval state, availableActors — the workspace brands/pages the post could engage from, each with its resolved per-action policy and connection state — and the plan lock. The eng_… row ids and actor ids update_post_engagements takes come from here. projects:read is required on top of posts:read because availableActors is workspace brand/company-page data (ids, names, LinkedIn connection state, engagement-policy states), not this post's content.
get_positioningworkspace:readThe WORKSPACE's positioning, shared by every brand in it: the company document (description, mission, vision, core statements, audience line, promised results, synced website), the target-audience document, and the organization-wide tone of voice with its plan-lock state. Distinct from get_settings, which is per-project.
list_content_topicsworkspace:readThe WORKSPACE's content topics (the dashboard's topic groups / Themen, Settings → Organization → Positioning), each with its wct_… id and the brands it is assigned to (profiles: [{ id, name }]). Membership-level, like the dashboard list; a member sees only the brands they can reach named and the rest as otherProfileCount. Optional profileId filters to one brand's topics. §2.14.
get_personal_dnaworkspace:readThe BRAND's personal DNA — the per-project person document: who the author is (intro), credibility points, opinions & hot takes, and stories, list sections one entry per line. projectId optional (default-project fallback, project echo on the response). Per-brand, unlike the workspace-wide get_positioning. §2.10.
list_profile_listscommunity:readThe brand's community profile lists — the named watchlists of LinkedIn people whose posts Scripe collects, under Community → Profiles. Returns each list's plist_… id, name, description and member count. projectId optional (default-project fallback, project echo on the response). The only way to name a list; call it before get_profile_list or add_profile_to_list. §2.16.
get_profile_listcommunity:readOne list with its members: name, headline, LinkedIn profile URL, follower count, the note whoever added them left, whether they are muted, when Scripe last checked them, and how many of their posts Scripe holds. postsCollected: 0 is an ordinary state, not a failure. §2.16.
get_usageworkspace:readUsage and limits: the weekly AI budget as a percentage, storage, today's API AI-spend cap, and canGenerate — whether the next AI job would be accepted at all. The pre-flight for both 402s (§1.16).
get_analytics_overviewanalytics:readCurrent vs previous-period rollup (posts, impressions, engagement rate, follower growth, activity).
list_post_analyticsanalytics:readPer-post LinkedIn metrics (views, likes, comments, shares, engagement rate) for posts already live on LinkedIn, newest first. Offset-paginated. For one specific post pass postId (§1.10) — never scan a date window. Body is an excerpt by default (content modes).
search_viral_postsanalytics:readSemantic search over the inspiration feed for high-performing posts on a topic (excludes your own author). Outliers only by default — posts that beat their own author's median by 2x or more — in the project's language, above minEngagement, inside publishedWithinDays (90 by default), optionally narrowed to a media shape with mediaFormats (§1.11).
get_analytics_reportanalytics:readFull report as JSON: executive summary + earned media value, per-author performance, the period's top 20 posts by impressions (postsTotal counts every post; list_post_analytics pages the rest), daily engagement series and the three click figures (§1.12), follower timeline. (REST's GET /v1/analytics/report renders the same figures as PDF/CSV with the period's posts, row-capped — see Analytics.)
get_cross_workspace_analytics_overviewanalytics:read + workspace:readThe same rollup as get_analytics_overview, for EVERY workspace the connection can reach, in one call — the tool for "how are all my clients doing", instead of a switch_workspace loop. Each row names the workspace it belongs to and the grant that reaches it; data.totals is one query over the union, not a sum (§1.17).
get_cross_workspace_analytics_reportanalytics:read + workspace:readThe cross-workspace twin of get_analytics_report: one whole report per reachable workspace. Visits fewer workspaces per call than the overview and keeps fewer top posts each, because it returns a document per workspace (§1.17).
get_posting_timesanalytics:readThe project's own best posting times (hourly + weekday×hour buckets), on the project's calendar clock by default and carrying only the slots it has actually posted in (§1.14). Plan-gated — lower plans get plan_not_eligible with an upgrade pointer.
list_jobsjobs:readList async jobs (paginated).
get_jobjobs:readSingle job read with progress snapshot.

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:

FieldVocabularyWhat it is
statuslifecycle enumwaitingProcessing | draft | waitingApproval | approved | rejected | published | scheduled | suggested
statusCategorykanbansuggested | draft | inProgress | review | scheduled | published — what list_post_statuses returns
statusIdkanbanThe workspace column the post sits in; update_post moves a post to it and list_posts({ statusId }) filters by it
statusTitlekanbanIts human name, e.g. Good to go
publishedAttimestampWhen the LinkedIn copy went live — the only field that dates a publish (§1.9)

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:

  • draft covers two columns. draft and inProgress both render as status: "draft", so a workspace with an "In Progress" and a "Good to go" column reports both as drafts. Read statusTitle when the user asks where a post is.
  • Filter on the axis you mean. status filters the lifecycle value (and draft therefore also returns the inProgress columns); statusCategory filters the board column and is the only way to ask for inProgress or review. 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:

jsonc
get_post({ postId: "post_9f4a…" })
→ { "data": { "status": "scheduled",
              "scheduledAt": "2026-08-11T06:00:00.000Z",
              "lastPublishError": "LinkedIn access for this brand was revoked, so this post was not published. Reconnect your LinkedIn account to resume publishing.",
              "delivery": {
                "state": "missed",
                "willRetry": false,
                "retryUntil": "2026-08-12T06:00:00.000Z",
                "detail": "This post never went out. The last attempt failed: … The scheduler stopped retrying at 2026-08-12T06:00:00.000Z (24h after the scheduled time). Fix the cause, then re-schedule it with schedule_post." } } }
stateMeansWill anything try again?
not_scheduledIn a non-scheduled column. A scheduledAt here is a leftover intention.No
scheduledFuture time, armed.Yes
dueTime has passed, inside the retry window — publishing now, or failing and retrying.Yes
missedPast the window (or no time at all), or stopped by a failure no retry can fix (an image LinkedIn refused, media that could not be fetched) until the post is edited or rescheduled. Nothing will pick it up again on its own.No
publishedIt went out.—
  • Never report a post as "on its way" from status alone. On production, 145 posts across 44 projects have been sitting scheduled and 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.detail is the sentence to relay. It names the failure and the repair. lastPublishError beside it is the raw message from the last attempt — useful, but not written for a user.
  • retryUntil is the deadline. While state is due the user can still fix the cause (usually reconnecting LinkedIn) and the post goes out by itself. After it, only schedule_post will.
  • 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:

jsonc
list_posts({ review: "pending", content: "none" })
→ { "data": [ { "id": "post_…", "statusCategory": "review",
                "review": {
                  "state": "pending",
                  "awaitingDecision": true,
                  "reviewerUserId": "user_33O0iUlcQyhyVo5FJG3mIcZ0y2d",
                  "deadline": "2026-07-30T22:00:00.000Z",
                  "overdue": true,
                  "decidedAt": null,
                  "notes": null,
                  "requiredByWorkspace": false,
                  "detail": "Waiting for a decision from user_33O0… , who was asked to review it by 2026-07-30T22:00:00.000Z — that deadline has passed. Approvals happen in the Scripe dashboard; nothing on this API can decide it." } } ],
    "pagination": { "total": 1, … } }
stateWaiting on someone?Means
not_requestednono review is open at the post's current board column
pendingyesa reviewer was asked and has not decided
approvednosigned off — it does not move on by itself
rejectednosent back; notes carries the reason when one was given
  • A review board 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. So statusCategory: "review" answers "what needs my approval?" 22.7% correctly. Filter by review: "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 PENDING on an already-published post. Only the row for the post's current column is reported here, which is why a published post reads not_requested.
  • reviewerUserId is a Clerk user id. list_team returns the same id as userId — resolve it there rather than showing the raw id.
  • Nothing on this surface can decide a review. Approving and rejecting happen in the dashboard; detail says so on every state that is waiting on a person.
  • requiredByWorkspace can be ENFORCED on this surface (since 2026-09; it mirrors get_workspace_context.requireReviewWorkflow). Two things have to be true before it refuses anything, and requiredByWorkspace: true on 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 on requiredByWorkspace alone: treat it as "this workspace may refuse an unreviewed post" and handle the 409 when it comes, rather than pre-emptively blocking a schedule the API would have accepted. Where the gate does bind, schedule_post and publish_post refuse a post without an approved review with 409 conflict (details.reason: review_not_requested / review_pending / review_rejected; details.reviewerUserId is always present — the Clerk id of the reviewer on the post's current-stage review when one exists, pending or rejected, null otherwise — and the message itself never shows the raw id, so resolve it via list_team). A post the caller could not schedule anyway answers the ordinary not_found / forbidden_project, never a review state. create_post_draft / generate_post refuse a non-null scheduledFor outright 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:

jsonc
get_project({})
→ { "data": { "id": "proj_…", "name": "Luke Matthews",
              "linkedIn": {
                "state": "revoked",
                "canPublish": false,
                "connectionStatus": "REVOKED",
                "lastValidatedAt": "2026-06-02T08:11:00.000Z",
                "accessTokenExpiresAt": null,
                "detail": "Scripe's LinkedIn authorization for this project was revoked on 2026-07-30T08:00:00.000Z: … Nothing will publish as this project until someone reconnects LinkedIn in Scripe. Reconnecting cannot be done through the API.",
                "reconnectUrl": "https://app.scripe.io/oauth?projectId=…&reconnect=true" } } }
statePublishes?Means
connectedyesa token is on file and nothing is wrong
expiring_soonyesthe access token lapses within 14 days (or already has) and has not been renewed
degradedyesrecent LinkedIn calls failed; three consecutive failures revoke the connection
disconnectednono usable token — never connected, or the token was cleared
revokednothe authorization was withdrawn
  • Branch on canPublish / state, never on connectionStatus. connectionStatus is the stored health cache. On production it reads CONNECTED for 38 accounts that hold no access token at all, and UNKNOWN for 15,352 that were simply never connected. state applies token presence first.
  • This read never probes LinkedIn. It reports stored health as of lastValidatedAt. The live check runs inside schedule_post and publish_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; reconnectUrl is 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 linkedIn block (and its reconnectUrl) describe that account. null means 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.

Fields searchedposts: body, title and hook. notes: the body.
Matchingcase- and accent-insensitive substring
Multiple wordsAND-ed — every word must appear somewhere in the row
"quoted phrase"matched whole
% and _matched literally; they are not wildcards to the caller
Orderingunchanged: newest-first. q narrows the page, it does not rank by relevance
Limits200 characters, 6 terms; a longer query is a 400 invalid_request, never a silent truncation
  • An empty result means the words are absent, not the topic. This is a literal substring match with no stemming and no synonyms: pricing does not find priced. 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 q returns nothing, say so.

Not searchable this way: knowledge-base documents (semantic search only, through the dashboard's assistant), ideas, and the calendar. 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.

jsonc
list_posts({ projectId, status: "draft", limit: 1 })
→ { "data": [ /* 1 row */ ],
    "pagination": { "next_cursor": "eyJ…", "has_more": true, "total": 128 } }
  • limit: 1 is 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).

The other list tools do not report a total yet — 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 contentTruncated is 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 small limit. 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:

jsonc
list_post_statuses()                     // projectId defaults, like every read
→ { "data": [ { "id": "7cae…", "title": "Draft",       "category": "draft",      "postCount": 126 },
              { "id": "22e0…", "title": "Good to go",  "category": "inProgress", "postCount": 1   } ],
    "counts": { "projectId": "proj_…", "total": 657,
                "byCategory": { "suggested": 493, "draft": 126, "inProgress": 3,
                                "review": 4, "scheduled": 0, "published": 31 },
                "uncategorized": 0 } }
  • One call answers the ask — not list_posts once per category.
  • counts.byCategory[c] is the same number list_posts({ statusCategory: c, limit: 1 }) reports as pagination.total. counts.total always equals the sum of byCategory plus uncategorized (posts whose column was deleted).
  • Without a projectId (REST callers only — the MCP tool defaults it) every postCount is null and counts is null: 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 a 400 invalid_request, never answered as an empty column. The same id is what update_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:

jsonc
list_posts({ publishedFrom: "2026-08-01", publishedTo: "2026-08-08", content: "none" })
  • publishedAt is the only field that dates a publish. scheduledAt is what somebody asked for and stays set on posts that never went out; updatedAt moves on any edit.
  • For a post Scripe published, publishedAt is set at publish time — the publish commit — and later refined to LinkedIn's own time when the sync lands. It is never guessed from the schedule. publishedAt: null on a published post means there is no measurement row for it: the publish-time placeholder was not written (e.g. no LinkedIn author to attribute it to), or the post was removed at the source.
  • publishedFrom/publishedTo only ever return published posts, and combine with every other filter.
  • The page is still ordered newest-DRAFTED first. Sort by publishedAt yourself before narrating "my last three posts".

Then ask how it did. 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:

fieldisaccepted by
idthe analytics measurement rownothing
post_idthe Scripe post, post_…, or nullget_post, update_post, schedule_post, publish_post, delete_post
linkedin_post_id / permalinkthe LinkedIn activity URN and its URLLinkedIn itself

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:

jsonc
list_post_analytics({ postId: "post_0bf5a28fe9155883" })
→ { "data": [ { "post_id": "post_0bf5…", "permalink": "…",
                "posted_at": "2026-08-03T06:00:15.000Z",
                "metrics": { "views": 1254, "likes": 27, "engagement_rate": 2.95, … } } ],
    "pagination": { "total": 1, "has_more": false },
    "meta": { "requested_post_ids": ["post_0bf5…"], "unmeasured": [] } }
  • One call, one row, ~330 tokens — versus ~12k tokens per date-window page, and a window leaves you guessing for a post published minutes ago, which is in the window but carries no numbers until LinkedIn's sync lands. The id says so instead, in meta.unmeasured.
  • An array works too, up to 50 ids.
  • meta appears only when postId was passed, and it is what makes an empty answer readable: meta.unmeasured says which named post has no numbers and why — not_published (never went out) or not_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.
  • sort does not rank by rate. impressions ranks by views and engagement by total interactions; if the user meant rate, sort, then compare metrics.engagement_rate yourself.

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_to is 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 lower minOutlierMultiplier (0 opts out of the outlier floor entirely and serves every post above minEngagement, scored or not), a wider publishedWithinDays, no mediaFormats, language: "all", or a lower minEngagement.
  • mediaFormats is how you ask for a shape. carousel is a swipeable deck (a document post), multi_image several photos in one post, image at most one; then video, text (no media), article (a shared link) and other. A deck is a small share of the corpus, so expect a shorter answer there than for an unfiltered search.
  • An outlier_multiplier of null means NOT SCORED, never "average" — the post is too fresh, below the scoring floor, or its author has no usable baseline. It is only reachable with minOutlierMultiplier: 0. Only outlier_baseline: "AUTHOR" supports a sentence about that author's own usual performance; CORPUS compared the post to a corpus median instead.
  • meta.returned is how many posts came back, capped by limit (default 12, max 30). There is no paging and no corpus-wide total.
  • metrics.views is null, not 0, 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 a null as unknown. metrics.reactions does not have to add up to total_engagement, because LinkedIn reports reaction types it does not break out.
  • Two of the three ids are links, and the third is not usable. permalink opens the post, author.profile_url opens its writer, and id is the internal feed row — no Scripe tool accepts it. To act on an angle you liked, feed the post's content to generate_post as 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:

FieldSourceAttributed byDashboard surface
totalLinkClicksLinkedIn LINK_CLICKS — clicks on any link inside a postthe post's publish datethe Link clicks KPI card; posts[].linkClicks per post
totalTrackedLinkClicksScripe click tracking — the workspace's tracked linksthe click's own date, workspace-wide (tracked links are workspace-owned, so projectId does not narrow it)the conversion funnel's Tracked link clicks stage and the Click tracking section
totalPremiumCtaClicksLinkedIn PREMIUM_CTA_CLICKS — the profile's Premium call-to-action buttonthe post's publish datethe Premium CTA clicks card; posts[].premiumCtaClicks per post

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:

FieldMeaning
isoThe slot as an absolute UTC instant.
timezoneThe IANA zone the local fields are in.
date / timeThe slot on the project's own clock — 2026-08-15, 09:00.
utcOffsetThat zone's offset at this instant, e.g. +02:00.
localTimeSat, 15 Aug 2026 09:00 (Europe/Berlin) — quote this.
sourcetemplate = a configured posting-time slot. fallback = the project has no posting schedule (or none of it was free), so 09:00 on the first open day is a suggestion; say so.
  • Do not convert iso yourself. 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. timezone is 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:

FieldMeaning
timeZoneThe zone the hourly / weekly buckets are expressed in.
timeZoneSourceproject when it came from the project's calendar setting (the default), caller when the timeZone argument chose it.

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:

FieldMeaning
policyThe stored EngagementPolicy row, or null when the project has none.
effectiveWhat actually happens right now, always present, plus source.

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):

ErrorLimitResets
usage_limit_exceededThe weekly AI budget — the primary limitMonday 00:00 UTC
spend_cap_exceededThe daily API AI-spend cap — an abuse valve00:00 UTC

get_usage reports both before either fires — full field reference on Usage and limits.

  • Read canGenerate first. It is false exactly when the next AI job would be refused, and blockedBy names which limit. Deriving it from the meters is easy to get wrong twice over: a meter at 100% only blocks when ai.enforcement is block, 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.scope names.

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[].workspace carries the id, the display name, is_default, and reach: member (Clerk membership) or agency_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 is null, report no total at all — meta.totals_omitted_reason says whether there was nothing to total or the union was too large for another pass.
  • meta.skipped is 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_reachable is your full reach, on both selectors — not the count you named, and not a truncation signal. On an explicit selection it normally exceeds meta.workspaces_returned while meta.truncated is false; read truncated for that.
  • meta.truncated means the response holds the first meta.max_workspaces reachable workspaces. Say so, and name the remaining workspaces explicitly in workspaceId to cover them — at most meta.max_workspaces per call, since an explicit list longer than the cap is refused rather than truncated (see Analytics).
  • meta.projects_truncated names any workspace holding more readable projects than one call measures, whose row — and its share of data.totals — therefore covers only its newest projects. Empty when every workspace fitted; measure those workspaces through get_analytics_overview with explicit projectIds.
  • 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 top meta.posts_per_workspace of the period while report.postsTotal counts them all; page the rest with list_post_analytics, one workspace at a time.

The single-workspace tools are unchanged: use get_analytics_overview for one workspace or for specific projects.

2. Write tools

ToolScopeNotes
switch_workspaceworkspace:readSync. Moves the connection's active workspace (mutates connection state, not data — idempotent). See MCP § Workspaces.
create_notenotes:writeSync. Optional date places the note's calendar slot.
update_notenotes:writeSync. Edit body and/or move between folders (folderId: null = root). Calendar placement is not writable here.
delete_notenotes:write (archive) / notes:destroy (permanent)DEFAULT is the reversible archive — no confirmation. permanent: true removes the note and its paired calendar slot forever; that path is two-phase and requires notes:destroy.
create_post_draftposts:writeSync. Pass a future scheduledFor to schedule, omit for an unsent draft. With scheduledFor set, the LinkedIn connection is verified first — a dead connection fails with conflict (details.reason + reconnectUrl) and creates nothing — and a workspace that requires review refuses the create-and-schedule shortcut with conflict (§1.3): create the draft, get it approved, then schedule_post.
update_postposts:writeSync. Edit content/title/contentType, set status (statusCategory/statusId), unschedule via scheduledFor: null. Setting a schedule lives on schedule_post — a non-null scheduledFor here returns a teaching error.
schedule_postposts:writeTwo-phase. Proposal + token first; replay executes. The proposal resolves the time (§8, "Schedule") and runs a live LinkedIn pre-flight; execution re-verifies, then queues the post (live ~2 min after the scheduled time). Re-scheduling re-arms configured team engagement against the new time. In a workspace that requires review, BOTH phases refuse a post without an approved review with conflict (§1.3).
attach_media_to_postposts:write + media:readFull-state media write onto a post — §2.4 for the two accepted branches. kind: "none" clears.
create_media_assetmedia:writeSync. The customer's OWN image into the project's media library, from inline content_base64 (small files, ≤ ~3 MB decoded; optional sha256 integrity check) or a two-step uploadId. Returns the img_… id attach_media_to_post takes. §2.5.
delete_media_assetmedia:destroyTwo-phase. Removes an image from the media library — the dashboard's tombstone delete: gone from search and every new attachment, while posts/carousels already carrying it keep it for 30 days (they hold their own copy of the stored file), after which the retention sweeps erase the stored file, every earlier version of it and the CDN copy, and those references 404. A LinkedIn-synced photo is never re-imported. Re-sending the identical bytes to create_media_asset restores it. §2.5.
delete_postposts:destroyTwo-phase and irreversible. The only way to remove a post — posts have no archive. The proposal previews the cascade and whether the LinkedIn copy survives. §2.6.
publish_postposts:publishTwo-phase and irreversible. Publishes an existing post to LinkedIn now. Never implied by posts:write or the write alias. Capped at 5 agent publishes per user per rolling 24 h. In a workspace that requires review, both phases refuse a post without an approved review with conflict (§1.3). §2.3.
create_source_textsources:writeSync. Returns a Source envelope.
create_upload_urlsources:writeSync. Returns a presigned S3 PUT URL — openWorldHint: true. The upl_… handle it returns is consumed by create_media_asset, create_source_file or add_to_knowledge_base; it is never itself a media key. The preferred path for anything over the ~3 MB inline cap (§4).
create_source_filesources:writeAsync. Returns a Job envelope. Accepts a two-step uploadId (preferred — a real recording never fits inline) or inline content_base64 for files under ~3 MB decoded. Streams progress (§5).
delete_sourcesources:destroyTwo-phase and irreversible. Deletes a source with its transcript, topics + hooks, knowledge-base copy (embeddings included) and stored file, and releases the storage quota. NOT total erasure: posts generated from the source and the usage ledger survive, and the proposal says so. Also on REST as DELETE /v1/sources/:sourceId, same two phases. §2.13.
add_to_knowledge_baseknowledge:writeAsync. Accepts text, file (inline or uploadId), URL, or YouTube. Omit projectId to use the default project; pass projectId: null explicitly for a workspace-wide document. Streams progress.
generate_postposts:generateAsync. AI post generation; the worker loads the project's tone, pillars, voice samples, and knowledge base. Sources: text, note, topic, or idea (§8). Streams progress. scheduledFor is validated (parseable, in the future) but schedules nothing — the generation always lands a draft, so put the returned post on the calendar with two-phase schedule_post afterwards. In a workspace that requires review, a non-null scheduledFor is refused with conflict (§1.3) — generate the draft, get it approved, then schedule_post.
generate_imagemedia:writeAsync. AI image generation into the media library (kind: PHOTO / PHOTO_EDIT / IMAGE_EDIT; references and subjects by img_… id only). Metered by the AI budget; spend-capped per workspace per day (402 when exhausted). Result carries the new img_… id. Streams progress.
generate_carouselmedia:writeAsync. AI carousel generation: the model picks a design, storyboards the deck, writes every slide in the project's voice, generates the imagery, stamps the brand identity, renders a PDF. Reserves 45¢ up front, then each image slot's own cost, and reconciles to actuals; a cap that runs out mid-job degrades (stand-in art) rather than fails. Result carries the img_… id, a documentKey for attach_media_to_post's document kind, and imageSlots ({ pending, filled, budgetDeclined, failed }). Decks run two to three minutes — poll get_job rather than sync-waiting.
cancel_jobjobs:cancelSync. Idempotent on the resource — a second call leaves the job CANCELLED but errors not_cancellable.
create_ideaideas:writeSync. Inserts at the top of the column (board-UI semantics; ordering is never an input). Status accepts only inbox | in_production — review/done are derived from the linked post. project echo on the response.
update_ideaideas:writeSync. The board's brief field set; null clears a field. Same writable-status restriction.
schedule_ideaideas:writeSync. DATE-level board placement (YYYY-MM-DD); null unplans. Planning only — never creates a post, never schedules to LinkedIn.
attach_media_to_ideaideas:write + media:readFull-state media write onto a board idea — library images only (img_… ids; one generated carousel may be attached alone). Replaces whatever the idea carried, a reference-mode style pick included. kind: "none" clears the media decision, visual format included. §2.4.
delete_ideaideas:destroyTwo-phase, destructive. First call returns a cascade PREVIEW (linked posts kept, open input requests whose public answer links die, note-mirror warning) + token; replay hard-deletes the idea and its child records in one transaction. The preview names the project it resolved to, and the token is bound to it.
delete_knowledge_docknowledge:destroyTwo-phase, destructive. Permanently deletes a document plus every chunk/embedding, its visibility assignments, and any stored file — transactionally. Workspace-wide docs additionally need workspace-admin authority. One case refuses: a page of a dashboard sync — §2.11.
update_tone_of_voicesettings:writeTwo-phase + admin-only. Project-level tone-of-voice/context write; the proposal renders the FULL new text next to current values plus a summary of the preference changes. Takes words, not magic numbers — §2.8. Org-level tone of voice is update_global_tone_of_voice.
update_engagement_policysettings:writeTwo-phase + admin-only + direction-restricted. Sets auto-like/comment/reshare defaults to OFF or REQUEST only — ON returns a teaching error naming the dashboard. All-OFF (disarm) is always allowed and writes no row on a project that has none (§1.15); non-OFF needs an eligible plan. A LinkedIn connection is needed to publish, not to store the policy.
update_post_engagementsposts:write + projects:readSync. Add / modify / remove one POST's queued team engagement — the editor's panel with its limits enforced server-side: Advanced+ plan (the editor's gate, removal included), actors must be same-workspace, LinkedIn-connected and non-OFF for the action, COMMENT ≤1250 chars, the editor's fixed delays, LIKE/REPOST once per actor, and rows already executed on LinkedIn can be neither edited nor removed (naming one refuses the whole call). Composing is not consent: policies resolve fail-closed at activation, granting a REQUEST approval is not available on this API — approvals happen in the Scripe dashboard or the mobile app — and editing an approved row's payload revokes the approval (same rule as the editor). Removing a row permanently deletes the tracked links bound to it, their click history and their redirect-cache entries. projects:read is required on top of posts:write for the same reason as the read — the response carries the workspace actor inventory, and this verb acts under those identities.
update_posting_schedulecalendar:writeTwo-phase, full-state. Replaces the project's RECURRING posting-time template — the weekly slots get_next_free_slot suggests from. The proposal renders the resulting week and names every slot that would be REMOVED. §2.7.
update_positioningsettings:writeAdmin-only. Empty fields write directly; replacing existing text is two-phase. Writes the WORKSPACE's company and target-audience documents. §2.10.
update_global_tone_of_voicesettings:writeAdmin-only + Business plan. 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. Same empty-vs-occupied rule. §2.10.
draft_positioning_from_websitesettings:writeWebsite sync that PERSISTS NOTHING (readOnlyHint: true, openWorldHint: true). Crawls a public site and drafts positioning text for update_positioning to write. §2.10.
update_personal_dnasettings:writeAdmin-only. Empty fields write directly; replacing existing text is two-phase. Writes ONE brand's personal DNA (person document: intro, credibility, opinions, stories); projectId required. §2.10.
create_content_topicsettings:writeAdmin-only. Sync. Adds one content topic — the list under Settings → Organization → Positioning — to the WORKSPACE, optionally assigning it to brands with profileIds. §2.14.
assign_content_topicsettings:writeAdmin-only. Sync. ADDS one content topic to one or more brands — the topic by wct_… id or exact title, each brand by proj_… id or exact display name; brands that already hold it keep it (alreadyAssigned). §2.14.
unassign_content_topicsettings:writeAdmin-only. Sync, single-phase, destructiveHint: true. Removes one content topic from the named brands only; the topic stays in the workspace, and the sub-topics each named brand kept under it are deleted. §2.14.
add_profile_to_listcommunity:writeSync. Adds ONE LinkedIn person to a community profile list, so Scripe starts collecting their posts. profile is the person's LinkedIn profile address (URL, country-subdomain URL, tracking parameters or bare handle); never a NAME — Scripe cannot look a person up by name (a name is refused, a single word is read as a handle). Optional note (≤500 chars). Adding an existing member is reported, not duplicated. Draws from its own tighter rate bucket and is capped per list and per brand per day. Does NOT like, comment or repost. §2.16.
generate_post_ideasideas:writeSync, one premium model call. Turns material — pasted text, or a kb_… knowledgeDocumentId — into suggested post ideas on the brand's idea pool (the dashboard's "Suggested ideas for you"), each with a hook, an angle and a brief citing the passages it was built on. Never writes to the idea board (create_idea does that). Draws from the job rate bucket and is capped per brand per hour (rate_limited). §2.15.

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), on update_post_engagements, whose remove permanently deletes the engagement's tracked links, their click history and their redirect-cache entries, and on unassign_content_topic, which deletes the sub-topics a brand kept under the topic — both, unlike the destroy verbs, 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, assign_content_topic and unassign_content_topic (set semantics), 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 tools create_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:

  1. Propose. Call the tool without confirmationToken. Nothing executes. The result is a proposal — exactly what will happen, plus a confirmationToken and tokenExpiresAt (~5-minute TTL).
  2. 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.
  3. Execute. Call the tool again with the identical arguments plus the token.

Rules the server enforces, not suggestions:

  • 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 with details.reason: "confirmation_expired".
  • What that binding does not cover, for every verb except publish_post, is the subject drifting underneath you. Only publish_post also 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 a delete_note proposal 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_voice and update_personal_dna. Every other verb's token is not surface-scoped — update_tone_of_voice included, 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.

The scope model backs this up: 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.

json
{ "name": "publish_post", "arguments": { "projectId": "proj_…", "postId": "post_…" } }

Nothing is published. The server re-reads the post, resolves the publishing identity, checks the per-user budget, renders the commentary, and pings LinkedIn. The proposal carries 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:

  1. 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_post or update_post) fails closed rather than publishing something the human never saw.
  2. 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.
  3. The publish itself is guarded twice: a stored LinkedIn URN short-circuits to result: "already_published" without contacting LinkedIn, and an in-flight lock returns conflict. There is no force/override argument.
  4. 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.
  5. Success returns result: "published" with the LinkedIn URN.

Operational edges:

  • postId is the public post_… id — the one every other tool returns and accepts. The internal database id is not an input anywhere on this surface; passing one is not_found like any other unknown id. result.postId comes 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_limited on 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 thumbnailKey LinkedIn 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.

To publish at a future time, use 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:

jsonc
{
  "kind": "images",
  "images": [
    { "source": "key", "key": "rrb7bw8pfuc.png", "alt": "the chart" },
    { "source": "library", "assetId": "img_d85211200fcc4f68" }
  ]
}

Two branches, and picking the wrong one is refused rather than stored:

you havebranch
an img_… id from search_media or a finished generate_image{ "source": "library", "assetId": "img_…" }
a key from the post's own media block{ "source": "key", "key": "…" }
a displayUrl, an upl_… upload handleneither — turn it into an img_… id with create_media_asset first

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:

jsonc
// 1. the bytes become a library asset
create_media_asset({
  "content_base64": "<the image>",
  "fileName": "keynote-stage.jpg",
  "alt": "On stage at the keynote"
})
// → { "data": { "id": "img_d85211200fcc4f68", "status": "PROCESSING", … } }

// 2. the id goes into the branch attach already had
attach_media_to_post({
  "postId": "post_…",
  "media": { "kind": "images",
             "images": [{ "source": "library", "assetId": "img_d85211200fcc4f68" }] }
})
  • 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 lists READY rows only) shows it once they finish.
  • Two ways in, exactly one per call. content_base64 is simpler for SMALL files (≤ ~3 MB decoded — anything larger dies at the platform edge as a bare HTTP 413, so pre-check the size). Pass sha256 of the decoded bytes alongside when the host can compute it; a mismatch is rejected instead of storing corrupted bytes. uploadId — an upl_… handle from create_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 a 422 that says so, not a broken asset.

The undo is 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:

json
{
  "phase": "proposal",
  "postId": "post_a1b2c3d4",
  "title": "Why we stopped doing demos",
  "status": "Published",
  "contentExcerpt": "If I had to go from 1-10k followers again…",
  "scheduledFor": "2026-08-05T06:00:00.000Z",
  "alsoRemoved": {
    "calendarSlots": 1,
    "ideaBoardLink": { "id": "idea_9f8e7d6c", "title": "Demo-less sales" },
    "versionHistoryAndVariations": true,
    "armedAutoEngagements": true
  },
  "linkedInPost": {
    "stillLive": true,
    "permalink": "https://www.linkedin.com/feed/update/urn:li:share:7490647861550800896/"
  },
  "confirmationToken": "…"
}

Read 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 team engagement. 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:

AskTool
"Post this on Tuesday at 9"schedule_post — one post, one instant
"Post me every Tuesday and Thursday at 8"update_posting_schedule — the recurring template

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:

json
"calendar": {
  "timezone": null,
  "effectiveTimezone": "Europe/Berlin",
  "weekStartsOn": "monday",
  "schedule": {
    "slots": [
      { "time": "09:00", "hour": 9, "minute": 0,
        "days": ["monday", "thursday", "friday"] }
    ],
    "postsPerWeek": 3
  }
}

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:

json
{
  "phase": "proposal",
  "current": { "slots": [ … ], "postsPerWeek": 3 },
  "new":     { "slots": [ … ], "postsPerWeek": 2 },
  "summary": [
    "08:00 on Tue, Thu — added",
    "09:00 on Mon, Thu, Fri — REMOVED",
    "Posting times per week: 3 → 2"
  ],
  "readable": "08:00 on Tue, Thu (Europe/Berlin)",
  "confirmationToken": "…"
}

Show the REMOVED lines. They are the only place a user finds out that a slot they set months ago is about to disappear, and a whole-template replace is exactly the shape where that happens by accident.

The executed call answers with 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:

fieldwords it acceptsstored as
postLengthPreferenceshort | standard | long1 | 2 | 3
formattingPreference (sentence length)short | standard | long3 | 0 | 2
emojiPreferencenone | moderate | frequent1 | 3 | 5

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_positioning writes 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 via overwrite: true, which returns a PROPOSAL naming each field with its current and new text and writes nothing until the token is replayed. Without overwrite, such a field comes back in needsConfirmation with its current text — not a refusal, the cue to show the user what a replacement would cost. overwrite is inside the confirmation binding, and replaced returns the previous text once, since positioning has no version history.
  • update_global_tone_of_voice writes 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_eligible names companyToneOfVoice below it). Same empty-vs-occupied rule; only the two TEXT fields ever need overwrite, and overwrite is per-CALL — set it and the whole change set, boolean switches included, becomes two-phase.
  • draft_positioning_from_website reads a company's public site and drafts the positioning documents, returning wouldFill (empty fields — a direct write) and wouldReplace (fields that would go through the overwrite confirmation) in update_positioning's own field names. It persists nothing — nothing changes until you call update_positioning with the parts the user accepted. Splitting the scrape from the write is what lets the write's confirmation hash the exact text that lands.

A fourth write is per-brand, not workspace-wide:

  • update_personal_dna writes one project's personal DNA — the person document behind the dashboard's Brand → Positioning → "You" tab: who the author is (intro, 1–2 sentences), credibility points, opinions & hot takes, and personal stories (the list sections hold one entry per line, exactly as the dashboard stores them). projectId is 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 same overwrite confirmation, needsConfirmation/current report and one-time replaced return. The read side is get_personal_dna, which does fall back to the default project and echoes project { id, name, fromDefault }.

All four writes are admin-only. The workspace-level read side is 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:

json
{
  "code": "conflict",
  "message": "This document is one page of a website sync (\"acme.com\") and cannot be deleted on its own — the next sync run would restore it. Manage that sync in the dashboard instead."
}

Which dashboard control that means depends on the sync: a profile's Post history folder is curated post by post, with Remove from knowledge base — see Synced knowledge sources.

Sync membership is what a document's folder says, not what its 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:

typewrites asprofile it writes fromtone of voice
PERSONAL_BRAND / AMPLIFIERthe person, in the first personthe connected LinkedIn profilethe project's personal tone of voice
COMPANY_PAGEthe company, as "we"the LinkedIn organisation profile — name, description, industries, specialties, positioningthe page's own tone of voice

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 is delete_post, its own two-phase decision.
  • The internal usage-accounting ledger survives — deleting it would silently rewrite historical cost reporting.

If any of those knowledge-base documents was shared with the whole company (workspace-wide), the delete additionally requires an admin of the workspace that document is shared with, acting through an OAuth grant — not necessarily the caller's own, since moving a project between workspaces leaves its company-shared documents behind. That is the same authority rule 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 (list_content_topics, create_content_topic, assign_content_topic, unassign_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 on get_source);
  • the legacy per-project pillars array get_settings still reports;
  • an idea (create_idea), which is one post-to-be rather than a theme.

The tool is workspace-scoped: a topic belongs to the workspace, not to a brand, and 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 (assign_content_topic below). Renames, deletes, Scripe's topic suggestions 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.

Reading and changing assignments. list_content_topics returns the workspace's active topics in list order, each with the brands it is assigned to — an assigned topic is one Scripe builds that brand's post ideas around. It is membership-level, like the dashboard list, and takes an optional profileId to narrow to one brand. A member whose own reads would not admit a brand sees it counted in otherProfileCount rather than named.

assign_content_topic and unassign_content_topic change who holds one topic. They require settings:write and a workspace-admin acting user, the bar the dashboard's assignment control enforces, and write through the same code. They are two verbs rather than one "set the profiles" write on purpose: each takes only the brands to add or remove, and leaves every brand it was not told about as it was, so a model changing one person never has to resend — and cannot accidentally drop — the rest. Naming a brand that already holds the topic (assign) or does not (unassign) is a no-op reported under alreadyAssigned / notAssigned.

  • topic is the wct_… id or the topic's exact title as listed, compared case-insensitively after trimming and collapsing whitespace — "führung & leadership" finds Führung & Leadership; "Führung" does not. Only active topics are found; a suggestion is accepted in the dashboard.
  • profiles takes, per entry, a proj_… id or the brand's exact display name as list_projects shows it, under the same comparison. Every resolved brand is re-authorized like create_content_topic's profileIds, and amplifiers are refused.
  • An unknown title or name answers not_found with the candidates in details.candidates; a title or name shared by two rows answers invalid_request with details.reason ambiguous_topic / ambiguous_profile and both ids. One bad reference refuses the whole call before anything is written.
  • A topic is held by at most 100 brands in total. An assign that would take it past that answers unprocessable with details.reason topic_profile_limit and the limit, current, remaining and requested counts, and writes nothing.

Unassigning deletes the brand's copy of the topic together with any sub-topics kept under it for that brand; assigning again restores the topic (with the workspace's analysis), not those sub-topics. It is single-phase, like the dashboard toggle, and advertises destructiveHint: true so a host can ask first.

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 suggested ideas — the recommendations the dashboard shows on Explore → "Suggested ideas 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 strategy, 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 — a kb_… document from list_knowledge. It is held to the same tenant rule the dashboard applies: the authorized brand's own documents and its workspace's; anything else answers not_found. When given, text is ignored.
  • neither — the ideas build on the brand's strategy, knowledge base and the request alone.

There is no file upload on this tool. A recording goes through 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 strategy the user asked for BY NAME ("give me Convert ideas"). Pass it only then: the ideas are then confined to that strategy's post formats (every returned idea's funnelStage reads that strategy), while a run without it lets the brand's strategy weights and the model choose. The strategies 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":

jsonc
// 1. find the list
list_profile_lists({})
// → { lists: [{ id: "plist_…", name: "Prospects", memberCount: 12, … }],
//     project: { id: "proj_…", name: "Anna Schmidt", fromDefault: true } }

// 2. add the person
add_profile_to_list({
  listId: "plist_…",
  profile: "linkedin.com/in/anna-schmidt",
  note: "Hot lead from Acme, demo 20 Sep"
})

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 provider…AnswerWhat to do
answered positively that there is no such profilenot_foundfix the URL
gave its one soft-failure answer, which it gives for a wrong address AND for a brief overload alikeunprocessable, reason: "profile_unresolved"check the URL against the person's LinkedIn page first; if it is right, retry once after a few minutes — never in a loop
could not be reached at all, or refused with an errorservice_unavailableretry in a few minutes

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:

BoundAnswer when hit
Per-list membership capconflict, naming the cap
Per-brand daily additions, across all of its listsconflict, naming the cap and that it resets at 00:00 UTC
Its own rate bucket, tighter than writerate_limited
Scripe's global profile-lookup budgetservice_unavailable

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 no remove_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 return isError: true with structured content:

json
{
  "isError": true,
  "content": [
    {
      "type": "text",
      "text": "{\"error\":{\"code\":\"plan_not_eligible\",\"message\":\"…\",\"request_id\":\"…\",\"docs_url\":\"…\"}}"
    }
  ],
  "structuredContent": {
    "error": {
      "code": "plan_not_eligible",
      "message": "\"Update global tone of voice\" is not included in this workspace's plan. Upgrade to use it.",
      "request_id": "mcp-…",
      "docs_url": "https://docs.scripe.io/api/v1/errors#plan_not_eligible"
    }
  }
}

The envelope is identical to the REST error body, so one parser handles both surfaces. 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:

MCP error -32602: Input validation error: Invalid arguments for tool
update_post: [{"code":"unrecognized_keys","keys":["status"],"message":
"Unknown parameter `status` for `update_post`. This tool accepts only:
autoComment, autoLike, content, contentType, postId, scheduledFor,
statusCategory, statusId, title. Did you mean `statusCategory` or
`statusId`? A parameter this tool does not declare is never applied, so
the call was refused rather than answered without it — retry with a
supported parameter, or omit it and narrow the result yourself."}]

This is deliberate and it is the one place a tool answers outside the error envelope above: a schema rejection is raised by the MCP SDK before the request reaches Scripe, so it arrives as an 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.

json
{
  "type": "file",
  "content_base64": "JVBERi0xLjQKJ…",
  "mimeType": "application/pdf",
  "filename": "Q3-strategy.pdf"
}
FieldRequiredNotes
content_base64yesRaw file bytes, base64-encoded. No data: URL prefix.
mimeTypeyese.g. application/pdf, audio/mpeg, image/png, text/plain.
filenameyesDisplay name + extension fallback for content-type detection.
sha256noHex SHA-256 of the decoded bytes. Strongly recommended when the host can compute it — base64 relayed through model output corrupts silently, and a digest mismatch is rejected naming both digests instead of storing the wrong bytes. Inline path only: passing it beside uploadId is refused, and on add_to_knowledge_base it is accepted for type: "file" with inline bytes only, since the other types send no bytes to check.

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:

json
{
  "method": "tools/call",
  "params": {
    "name": "generate_post",
    "arguments": { "source": { "type": "text", "text": "…" } },
    "_meta": { "progressToken": "client-12345" }
  }
}
  • 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: 1 when the job reaches a terminal state (DONE/FAILED/CANCELLED). total: 1 is 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 — call cancel_job explicitly 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 RUNNING job. The job continues in the background: read data.id and poll get_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. A RUNNING job you can poll is strictly better than a dead socket.)

6. Resources

scripe:// resources let the host attach a record for @-mention or inspection:

URI templateBodyRequired scope
scripe://workspace/meWorkspace JSON (includes defaultProject)workspace:read
scripe://project/{id}Project JSONprojects:read
scripe://post/{id}Post JSONposts:read
scripe://note/{id}Note JSONnotes:read
scripe://source/{id}Source JSON (includes topics[] once processed — hooks[] stays empty on API-created sources)sources:read
scripe://job/{id}Job JSONjobs:read

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.

PromptArgumentsPurpose
weekly_planningprojectId?, weeksOut?Plan the next week(s) of LinkedIn content. Investigates state, proposes a calendar, schedules drafts only after user confirmation.
repurposesourceRef (a note_*, src_*, or post_* id), format?, projectId?Turn an existing asset into 1–3 new drafts, staying tonally consistent with recent posts.
inbox_zeroprojectId?, ageDays?Triage stale notes and failed jobs. Clusters by theme and confirms each batch.

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:

json
{ "name": "generate_post",
  "arguments": { "source": { "type": "idea", "ideaId": "idea_…" } } }

An idea card is a brief, not a sentence — title, body, hook, topic, post format, visual format, capture evidence, attached material. The server renders the whole brief itself, so nothing is lost to a copy-paste, and two things follow that a caller cannot do any other way: the card's 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:

  • scheduledFor comes back as an absolute UTC instant, and scheduledForLocal renders it in the project's calendar timezone (timeZone) — e.g. Tue, 18 Aug 2026 09:00 (Europe/Berlin). Show scheduledForLocal; 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; use get_next_free_slot for 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 linkedIn block: preflight (ok | not_connected | token_invalid | no_company_page_admin), canSchedule, detail, and reconnectUrl. Branch on canSchedule — when it is false, confirming will fail with 409 conflict (details.reason, details.reconnectUrl, details.retryable: false), so relay detail and the link instead of asking the user to confirm; only a human can reconnect LinkedIn. You still get a confirmationToken, because reconnect-then-confirm is exactly the flow this enables; phase two re-runs the same check. (no_company_page_admin carries no reconnectUrl: its repair is on a different project — some admin's own LinkedIn account.)

Execution verifies the token binding, re-runs the LinkedIn check, stamps the 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.